Inheritance and Polymorphism
|
This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline. This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
C# supports single implementation inheritance: a class has exactly one base class, and may implement any
number of interfaces. Polymorphism comes from virtual
members that a derived class `override`s.
Base and Derived Classes
public class Shape
{
public Shape(string name) => Name = name;
public string Name { get; }
public virtual double Area => 0;
public virtual string Describe() => $"{Name} with area {Area:F2}";
}
public class Circle : Shape
{
public Circle(double radius) : base("circle") => Radius = radius;
public double Radius { get; }
public override double Area => Math.PI * Radius * Radius;
}
public class Square : Shape
{
public Square(double side) : base("square") => Side = side;
public double Side { get; }
public override double Area => Side * Side;
public override string Describe() => $"{base.Describe()} (side {Side})";
}
public static class InheritanceDemo
{
public static void Run()
{
Shape[] shapes = [new Circle(1), new Square(2)];
foreach (Shape shape in shapes)
{
Console.WriteLine(shape.Describe()); // dispatches to the runtime type
}
}
}
Everything derives from object; : Shape names a different base. A class with no : clause derives from
object implicitly.
virtual, override and new
| Keyword | Meaning |
|---|---|
|
The member may be overridden. Dispatch is on the run-time type. |
|
Replaces a |
|
Hides a base member with an unrelated one. Dispatch is on the compile-time type. |
|
Overrides, and forbids further overriding down the chain. |
Hiding is almost always a mistake, and the difference is worth seeing once:
public class Base
{
public virtual string Virtual() => "Base.Virtual";
public string NonVirtual() => "Base.NonVirtual";
}
public class Derived : Base
{
public override string Virtual() => "Derived.Virtual";
public new string NonVirtual() => "Derived.NonVirtual"; // hides, does not override
}
public static class DispatchDemo
{
public static void Run()
{
Derived derived = new();
Base asBase = derived; // same object, different static type
Console.WriteLine(derived.Virtual()); // Derived.Virtual
Console.WriteLine(asBase.Virtual()); // Derived.Virtual <- run-time dispatch
Console.WriteLine(derived.NonVirtual()); // Derived.NonVirtual
Console.WriteLine(asBase.NonVirtual()); // Base.NonVirtual <- compile-time dispatch!
}
}
Omitting new when you hide produces warning CS0108. override is mandatory — unlike Java, C# never overrides
implicitly, which is why accidental overriding is not a hazard here.
abstract
An abstract class cannot be instantiated and may declare members with no implementation:
public abstract class Repository<T>
{
// Abstract: no body; every concrete derived class must supply one.
public abstract T? FindById(int id);
public abstract void Save(T entity);
// Abstract property.
public abstract string TableName { get; }
// Virtual: a default the derived class may replace.
public virtual bool Exists(int id) => FindById(id) is not null;
// Non-virtual: shared behaviour the derived class inherits as-is.
public string Describe() => $"{GetType().Name} over {TableName}";
}
public sealed class InMemoryUserRepository : Repository<string>
{
private readonly Dictionary<int, string> _rows = [];
public override string TableName => "users";
public override string? FindById(int id) => _rows.GetValueOrDefault(id);
public override void Save(string entity) => _rows[_rows.Count + 1] = entity;
}
abstract members are implicitly virtual. An abstract class may have constructors (called by derived
classes through base(…)), fields and full implementations — which is the difference from an interface.
sealed
// A sealed class cannot be derived from -- the default you should prefer.
public sealed class ApiKey
{
public ApiKey(string value) => Value = value;
public string Value { get; }
}
public class Middle : Base
{
// Overrides, and stops anyone below from overriding again.
public sealed override string Virtual() => "Middle.Virtual";
}
Sealing is a design statement ("this type is not an extension point") and occasionally a performance one — the JIT can devirtualise calls on a sealed type. The framework design guidelines recommend sealing by default and unsealing deliberately.
Closed Hierarchies (C# 15 preview)
|
Preview feature — C# 15 / .NET 11
This requires a .NET 11 preview SDK and |
closed marks a hierarchy whose derived types can only be declared in the same assembly, letting the compiler prove a
switch over them is exhaustive — the classic sum type, without the default arm that silently swallows new
cases:
public closed record Shape
{
public sealed record Circle(double Radius) : Shape;
public sealed record Square(double Side) : Shape;
public sealed record Rectangle(double Width, double Height) : Shape;
}
public static class Areas
{
// No `_` arm needed: the compiler knows the three cases are all of them,
// and will start erroring here if a fourth is ever added.
public static double Of(Shape shape) => shape switch
{
Shape.Circle c => Math.PI * c.Radius * c.Radius,
Shape.Square s => s.Side * s.Side,
Shape.Rectangle r => r.Width * r.Height,
};
}
Until this ships, the C# 14 equivalent is an abstract record with sealed derived records and a _ arm that
throws:
public abstract record Shape2
{
private Shape2() { } // private constructor: only nested types can derive
public sealed record Circle(double Radius) : Shape2;
public sealed record Square(double Side) : Shape2;
}
public static class Areas2
{
public static double Of(Shape2 shape) => shape switch
{
Shape2.Circle c => Math.PI * c.Radius * c.Radius,
Shape2.Square s => s.Side * s.Side,
_ => throw new NotSupportedException($"Unknown shape {shape.GetType().Name}."),
};
}
Constructors and Inheritance
Construction runs base first:
public class Animal
{
public Animal(string name)
{
Name = name;
Console.WriteLine($"Animal({name})");
}
public string Name { get; }
}
public class Dog : Animal
{
public Dog(string name) : base(name) // base constructor runs first
=> Console.WriteLine($"Dog({name})");
}
// new Dog("Rex") prints: Animal(Rex) then Dog(Rex)
If the base has no accessible parameterless constructor, the derived constructor must name one with base(…).
|
Never call a |
Upcasting and Downcasting
public static class CastingDemo
{
public static void Run()
{
Shape shape = new Circle(2); // upcast: implicit, always safe
// Downcast with a cast: throws InvalidCastException if wrong.
Circle circle = (Circle)shape;
Console.WriteLine(circle.Radius);
// Downcast with `as`: null if wrong.
Square? square = shape as Square;
Console.WriteLine(square is null); // True
// Downcast with a pattern: the idiomatic form.
if (shape is Circle { Radius: > 1 } big)
{
Console.WriteLine($"big circle, radius {big.Radius}");
}
// Exhaustive dispatch without virtual members.
string description = shape switch
{
Circle c => $"circle r={c.Radius}",
Square s => $"square s={s.Side}",
_ => "unknown",
};
Console.WriteLine(description);
}
}
Covariant Return Types
Since C# 9, an override may return a more derived type:
public abstract class Document
{
public abstract Document Clone();
}
public sealed class Invoice : Document
{
public decimal Total { get; init; }
// The override narrows the return type -- callers holding an Invoice get an Invoice.
public override Invoice Clone() => new() { Total = Total };
}
public static class CovariantDemo
{
public static void Run()
{
Invoice invoice = new() { Total = 10m };
Invoice copy = invoice.Clone(); // no cast needed
Console.WriteLine(copy.Total);
}
}
The object Members
| Member | Contract |
|---|---|
|
A human-readable representation. Override it on anything you will ever log or debug. |
|
Value equality. Must be reflexive, symmetric, transitive and consistent. |
|
Equal objects must return equal hash codes. Use |
|
The run-time type. Not virtual; cannot be overridden. |
public class Version2
{
public Version2(int major, int minor) => (Major, Minor) = (major, minor);
public int Major { get; }
public int Minor { get; }
public override string ToString() => $"{Major}.{Minor}";
public override bool Equals(object? obj)
=> obj is Version2 other && other.Major == Major && other.Minor == Minor;
public override int GetHashCode() => HashCode.Combine(Major, Minor);
}
A record synthesises all three correctly, which is the main reason to prefer one for data types. See
Equality and Operator Overloading.
Composition over Inheritance
Inheritance couples a derived type to its base’s implementation, not just its contract — the fragile base class problem. Prefer composing behaviour behind an interface:
// Inheritance: LoggingRepository is locked to SqlRepository's implementation.
public class SqlRepository
{
public virtual void Save(string entity) => Console.WriteLine($"SQL save {entity}");
}
public class LoggingRepositoryByInheritance : SqlRepository
{
public override void Save(string entity)
{
Console.WriteLine($"before {entity}");
base.Save(entity);
}
}
// Composition: the decorator works with ANY implementation and is trivially testable.
public interface IRepository
{
void Save(string entity);
}
public sealed class SqlRepository2 : IRepository
{
public void Save(string entity) => Console.WriteLine($"SQL save {entity}");
}
public sealed class LoggingRepository(IRepository inner) : IRepository
{
public void Save(string entity)
{
Console.WriteLine($"before {entity}");
inner.Save(entity);
Console.WriteLine($"after {entity}");
}
}
Use inheritance when there is a genuine is-a relationship with shared implementation and a stable base; use composition and interfaces for everything else. C#'s default interface members and extension members cover many cases that once needed a base class.
See Also
-
Interfaces — contracts, default members and static abstract members.
-
Classes and Objects — access modifiers and constructors.
-
Pattern Matching — type-based dispatch without
virtual. -
Records — record inheritance and
EqualityContract. -
Equality and Operator Overloading — overriding
Equalscorrectly in a hierarchy.
References
-
github.com/dotnet/csharplang — the C# language design repository, home of the union types and closed hierarchies proposals.