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

virtual

The member may be overridden. Dispatch is on the run-time type.

override

Replaces a virtual/abstract/override member of the base. Same signature, same accessibility.

new

Hides a base member with an unrelated one. Dispatch is on the compile-time type.

sealed override

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 <LangVersion>preview</LangVersion>. It is not available in C# 14 / .NET 10, may change before release, and the snippet below is written from the published feature specification rather than compiled against a shipped compiler.

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}."),
    };
}
classDiagram class Shape { <<closed>> } class Circle { +double Radius } class Square { +double Side } class Rectangle { +double Width +double Height } Shape <|-- Circle Shape <|-- Square Shape <|-- Rectangle note for Shape "closed: the compiler knows these\nthree are the complete set, so a\nswitch over them needs no default arm"

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 virtual member from a constructor. The override runs while the derived object’s own fields are still unassigned, so it sees null and zero. If you need post-construction initialisation, use a factory method or an explicit Initialize() call.

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

ToString()

A human-readable representation. Override it on anything you will ever log or debug.

Equals(object?)

Value equality. Must be reflexive, symmetric, transitive and consistent.

GetHashCode()

Equal objects must return equal hash codes. Use HashCode.Combine.

GetType()

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