C# · lesson 11 of 26
Object-Oriented Programming in C#
C# object-oriented programming by example: classes, constructors, properties, encapsulation, inheritance, virtual/override, abstract and sealed classes.
By SkillByExample · Updated
Defining a Class
A class bundles data with the methods allowed to change it. Here the balance is private, so every change goes through Deposit or Withdraw, which enforce the rules.
var acct = new BankAccount("Alice", 500m);
acct.Deposit(250m);
Console.WriteLine(acct.Withdraw(1000m));
Console.WriteLine(acct.Withdraw(100m));
Console.WriteLine(acct);
try { acct.Deposit(-5m); }
catch (ArgumentOutOfRangeException ex) { Console.WriteLine(ex.Message); }
public class BankAccount
{
private decimal _balance;
public string Owner { get; }
public decimal Balance => _balance;
public BankAccount(string owner, decimal initialBalance = 0)
{
Owner = owner ?? throw new ArgumentNullException(nameof(owner));
_balance = initialBalance;
}
public void Deposit(decimal amount)
{
if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount), "Must be positive");
_balance += amount;
}
public bool Withdraw(decimal amount)
{
if (amount > _balance) return false;
_balance -= amount;
return true;
}
public override string ToString() => $"{Owner}: {Balance:0.00}";
}
$ dotnet run
False
True
Alice: 650.00
Must be positive (Parameter 'amount')
Withdrawing 1000 from 750 fails without changing anything; withdrawing 100 succeeds. Nothing outside the class can write _balance, so a negative balance is impossible by construction. The exception message includes both the text we passed and the parameter name, which nameof(amount) keeps in sync if the parameter is renamed. Without the ToString override, Console.WriteLine(acct) would print just the type name, BankAccount.
Properties and Object Initializers
Properties look like fields to callers but run code. An object initializer sets accessible properties right after the constructor runs.
var p = new Person { Name = " Ada Lovelace ", Email = "ada@example.com" };
Console.WriteLine($"[{p.Name}] {p.Email} {p.Initials}");
p.Name = "Grace Hopper";
Console.WriteLine(p.Initials);
try { p.Name = " "; }
catch (ArgumentException ex) { Console.WriteLine(ex.Message); }
public class Person
{
private string _name = "";
public string Name
{
get => _name;
set
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Name cannot be blank");
_name = value.Trim();
}
}
public string Initials => string.Concat(_name.Split(' ').Select(w => w[0]));
public required string Email { get; init; }
}
$ dotnet run
[Ada Lovelace] ada@example.com AL
GH
Name cannot be blank
The setter trimmed the name on the way in, so the stored value has no stray spaces. Initials has no backing field; it is recomputed from Name on every read, which is why it follows the rename. Email is init-only: it can be set in the initializer, but p.Email = ... afterwards would not compile, and required makes leaving it out a compile error too. Records build a whole value-equality model on top of init — see Records.
Inheritance and Polymorphism
A derived class reuses its base class and overrides virtual members. A variable of the base type calls whichever override the actual object has.
var animals = new List<Animal>
{
new Dog("Rex", 3, "German Shepherd"),
new Cat("Whiskers", 5),
new Animal("Generic", 1),
};
foreach (var animal in animals)
Console.WriteLine($"{animal.Speak(),-6} {animal.Describe()}");
public class Animal(string name, int age)
{
public string Name { get; } = name;
public int Age { get; } = age;
public virtual string Speak() => "...";
public virtual string Describe() => $"{GetType().Name} named {Name}, age {Age}";
}
public class Dog(string name, int age, string breed) : Animal(name, age)
{
public override string Speak() => "Woof!";
public override string Describe() => base.Describe() + $", breed: {breed}";
}
public class Cat(string name, int age) : Animal(name, age)
{
public override string Speak() => "Meow!";
}
$ dotnet run
Woof! Dog named Rex, age 3, breed: German Shepherd
Meow! Cat named Whiskers, age 5
... Animal named Generic, age 1
The list’s element type is Animal, yet each element speaks with its own voice — dispatch is decided at run time by the object, not by the variable’s declared type. GetType().Name in the base class also reports the runtime type. Dog.Describe calls base.Describe() and appends to it rather than duplicating the base logic. These classes use C# 12 primary constructors: (string name, int age) on the class line replaces a constructor body that only copies parameters.
override vs new
new on a method hides the base member instead of overriding it — and a base-typed variable then still calls the base version.
Base viaBase = new Derived();
Derived viaDerived = new Derived();
Console.WriteLine($"{viaBase.Virtual()} {viaDerived.Virtual()}");
Console.WriteLine($"{viaBase.Hidden()} {viaDerived.Hidden()}");
public class Base
{
public virtual string Virtual() => "Base.Virtual";
public string Hidden() => "Base.Hidden";
}
public class Derived : Base
{
public override string Virtual() => "Derived.Virtual";
public new string Hidden() => "Derived.Hidden";
}
$ dotnet run
Derived.Virtual Derived.Virtual
Base.Hidden Derived.Hidden
Same object, two variables. The overridden method gives the same answer either way. The hidden one gives a different answer depending on the variable’s static type — Base.Hidden through viaBase. That inconsistency is why new is rarely what you want; the compiler warns (CS0108) if you hide a member without writing new at all.
Abstract Classes
An abstract class can mix shared implementation with abstract members that every derived class must supply. It cannot be instantiated itself.
Shape[] shapes = { new Circle(5), new Rectangle(3, 4) };
foreach (var s in shapes)
s.Print();
Console.WriteLine(shapes.Sum(s => s.Area()).ToString("F2"));
public abstract class Shape
{
public abstract double Area();
public abstract double Perimeter();
public void Print() =>
Console.WriteLine($"{GetType().Name}: area={Area():F2}, perimeter={Perimeter():F2}");
}
public class Circle(double radius) : Shape
{
public override double Area() => Math.PI * radius * radius;
public override double Perimeter() => 2 * Math.PI * radius;
}
public class Rectangle(double w, double h) : Shape
{
public override double Area() => w * h;
public override double Perimeter() => 2 * (w + h);
}
$ dotnet run
Circle: area=78.54, perimeter=31.42
Rectangle: area=12.00, perimeter=14.00
90.54
Print is written once in Shape but calls the abstract Area and Perimeter, which resolve to each subclass’s implementation. new Shape() would not compile (CS0144), and a subclass that forgot Perimeter would fail with CS0534.
sealed Classes and Methods
sealed on a class forbids subclasses; sealed override on a method stops further overriding below that point.
Animal a = new GoldenRetriever("Max");
Console.WriteLine(a.Speak());
Console.WriteLine(typeof(GoldenRetriever).IsSealed);
Console.WriteLine(ReferenceEquals(Config.Instance, Config.Instance));
public class Animal
{
public virtual string Speak() => "...";
}
public class Dog : Animal
{
public override string Speak() => "Woof!";
}
public sealed class GoldenRetriever(string name) : Dog
{
public string Name => name;
public sealed override string Speak() => $"{name}: Bark bark!";
}
public sealed class Config
{
private static readonly Lazy<Config> _instance = new(() => new Config());
private Config() { }
public static Config Instance => _instance.Value;
}
$ dotnet run
Max: Bark bark!
True
True
GoldenRetriever is the end of its line: nothing can inherit from it, so the JIT can call its methods without a virtual lookup. Config combines sealed with a private constructor and Lazy<T> for a thread-safe singleton — every access returns the same instance.
Static Members
A static member belongs to the type, not to any one object, so all instances share it.
var c1 = new Counter();
var c2 = new Counter();
var c3 = new Counter();
Console.WriteLine($"{c1.Id} {c2.Id} {c3.Id} total={Counter.TotalCount}");
Parallel.For(0, 1000, _ => new Counter());
Console.WriteLine(Counter.TotalCount);
public class Counter
{
private static int _totalCount;
public int Id { get; }
public Counter() => Id = Interlocked.Increment(ref _totalCount);
public static int TotalCount => _totalCount;
}
$ dotnet run
1 2 3 total=3
1003
Each constructor bumps the one shared _totalCount, so every instance gets the next ID. The Parallel.For line creates 1,000 counters from several threads at once and still ends at exactly 1003 — Interlocked.Increment makes the read-add-write atomic. With a plain _totalCount++, concurrent increments can overwrite each other and the total can come up short.
Encapsulation
Expose read-only views and route every change through a method, so the class can enforce its rules.
var order = new Order();
order.AddLine("Pen", 3, 1.50m);
order.AddLine("Pad", 1, 4.00m);
Console.WriteLine($"{order.Lines.Count} lines, total {order.Total}");
Console.WriteLine(order.RemoveLine("Pen"));
Console.WriteLine(order.RemoveLine("Ink"));
Console.WriteLine(order.Total);
Console.WriteLine(order.Lines is List<OrderLine>);
public record OrderLine(string Product, int Qty, decimal UnitPrice)
{
public decimal Total => Qty * UnitPrice;
}
public class Order
{
private readonly List<OrderLine> _lines = new();
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
public decimal Total => _lines.Sum(l => l.Total);
public void AddLine(string product, int qty, decimal unitPrice)
{
if (qty <= 0) throw new ArgumentOutOfRangeException(nameof(qty));
_lines.Add(new OrderLine(product, qty, unitPrice));
}
public bool RemoveLine(string product) =>
_lines.RemoveAll(l => l.Product == product) > 0;
}
$ dotnet run
2 lines, total 8.50
True
False
4.00
False
Total is computed from the lines, so it can never drift out of sync with them. The last line matters: if Lines returned _lines directly, a caller could cast it back to List<OrderLine> and call Add, skipping the quantity check. AsReadOnly() returns a wrapper, so that cast fails and prints False.
Practice
- Overdraft. Give
BankAccountanOverdraftLimitset through the constructor, and letWithdrawgo negative down to-OverdraftLimit.
Solution
var acct = new BankAccount("Bob", 50m, overdraftLimit: 100m);
Console.WriteLine(acct.Withdraw(120m) + " " + acct.Balance);
Console.WriteLine(acct.Withdraw(40m) + " " + acct.Balance);
public class BankAccount(string owner, decimal balance, decimal overdraftLimit = 0)
{
public string Owner { get; } = owner;
public decimal Balance { get; private set; } = balance;
public decimal OverdraftLimit { get; } = overdraftLimit;
public bool Withdraw(decimal amount)
{
if (Balance - amount < -OverdraftLimit) return false;
Balance -= amount;
return true;
}
}
$ dotnet run
True -70
False -70
The second withdrawal would take the balance to -110, below the -100 limit, so it is refused.
- Another shape. Add a
Triangle(double a, double b, double c)to the abstract-class example, using Heron’s formula for area.
Solution
new Triangle(3, 4, 5).Print();
public abstract class Shape
{
public abstract double Area();
public abstract double Perimeter();
public void Print() =>
Console.WriteLine($"{GetType().Name}: area={Area():F2}, perimeter={Perimeter():F2}");
}
public class Triangle(double a, double b, double c) : Shape
{
public override double Perimeter() => a + b + c;
public override double Area()
{
double s = Perimeter() / 2;
return Math.Sqrt(s * (s - a) * (s - b) * (s - c));
}
}
$ dotnet run
Triangle: area=6.00, perimeter=12.00
- Spot the hiding. In the override-vs-new example, change
HiddeninBasetovirtualand inDerivedtooverride. What does the second line print now?
Solution
Base viaBase = new Derived();
Console.WriteLine(viaBase.Hidden());
public class Base
{
public virtual string Hidden() => "Base.Hidden";
}
public class Derived : Base
{
public override string Hidden() => "Derived.Hidden";
}
$ dotnet run
Derived.Hidden
With override, the object’s type decides, so both variables now agree.
- Protect the invariant. Make
Order.AddLinemerge a repeated product into the existing line instead of adding a second one.
Solution
var order = new Order();
order.AddLine("Pen", 3, 1.50m);
order.AddLine("Pen", 2, 1.50m);
foreach (var l in order.Lines) Console.WriteLine(l);
public record OrderLine(string Product, int Qty, decimal UnitPrice);
public class Order
{
private readonly List<OrderLine> _lines = new();
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
public void AddLine(string product, int qty, decimal unitPrice)
{
int i = _lines.FindIndex(l => l.Product == product);
if (i >= 0) _lines[i] = _lines[i] with { Qty = _lines[i].Qty + qty };
else _lines.Add(new OrderLine(product, qty, unitPrice));
}
}
$ dotnet run
OrderLine { Product = Pen, Qty = 5, UnitPrice = 1.50 }
Because callers can only reach _lines through AddLine, this rule holds everywhere at once.