Base and derived classes

Inheritance lets one class build on another: the new class gets the members of the existing one and adds or changes behavior. The general idea, with its benefits and costs, is on Object-Oriented Concepts; this page shows the C# rules.

The class you inherit from is the base class; the class that inherits is the derived class. In C# you write the base class after a colon. A derived class object is a base class object too, so it can be used anywhere a Device is expected.

var meter = new SmartMeter("MTR-0042", "Delft", "10.20.0.42");
Console.WriteLine(meter.Describe());
Console.WriteLine(meter.Ip);
Console.WriteLine(meter is Device);

class Device
{
    public string Id { get; }
    public string Location { get; }

    public Device(string id, string location)
    {
        Id = id;
        Location = location;
    }

    public string Describe() => $"{Id} at {Location}";
}

class SmartMeter : Device
{
    public string Ip { get; }

    public SmartMeter(string id, string location, string ip) : base(id, location)
    {
        Ip = ip;
    }
}
MTR-0042 at Delft
10.20.0.42
True

Constructors are not inherited. The derived constructor must pass what the base class needs with : base(...), and the base constructor body runs before the derived constructor body, so the inherited part is set up before the derived constructor's own code runs (the derived class's field initializers run earlier still). With primary constructors the same call is written in the class header: class SmartMeter(string id, string location, string ip) : Device(id, location).

Three rules that differ from some other languages:

  • Single inheritance. A class has exactly one base class. To combine several capabilities, use interfaces.
  • One root. A class without a base class derives from object (System.Object), which supplies ToString(), Equals() and GetHashCode(). Structs and enums cannot be inherited from, and a struct cannot inherit from a class.
  • Access stays checked. A derived class cannot see the base class's private members. Members marked protected are visible to derived classes but not to other code (see access modifiers).
var m = new SmartMeter("MTR-0042");
m.Reboot();
Console.WriteLine(m.Status);

class Device(string id)
{
    public string Id { get; } = id;
    public string Status { get; protected set; } = "online";
    protected void Log(string text) => Console.WriteLine($"[{Id}] {text}");
}

class SmartMeter(string id) : Device(id)
{
    public void Reboot()
    {
        Log("rebooting");
        Status = "rebooting";
    }
}
[MTR-0042] rebooting
rebooting

Virtual methods and overriding

A derived class often needs to change what an inherited method does. In C# the base class decides whether that is allowed. A virtual method is marked virtual and may be replaced in derived classes. Method overriding is the replacement itself: the derived class declares a method with the same signature and the keyword override. Inside it, base.Describe() calls the base version, so you can extend behavior instead of copying it.

Device[] devices =
[
    new Device("GW-0001", "Delft"),
    new SmartMeter("MTR-0042", "Delft", "10.20.0.42"),
];

foreach (var d in devices)
    Console.WriteLine(d.Describe());

class Device(string id, string location)
{
    public string Id { get; } = id;
    public string Location { get; } = location;

    public virtual string Describe() => $"{Id} at {Location}";
}

class SmartMeter(string id, string location, string ip) : Device(id, location)
{
    public override string Describe() => $"{base.Describe()}, IP {ip}";
}
GW-0001 at Delft
MTR-0042 at Delft, IP 10.20.0.42

The array has type Device[], yet the second element prints the smart meter's version. A virtual call is decided at run time by the object's actual type, not by the type of the variable. That is what makes polymorphism work.

Console.WriteLine(new SmartMeter().Describe());

class Device
{
    public string Describe() => "device";
}

class SmartMeter : Device
{
    public override string Describe() => "smart meter";
}
example.cs(10,28): error CS0506: 'SmartMeter.Describe()': cannot override inherited member 'Device.Describe()' because it is not marked virtual, abstract, or override
SmartMeter meter = new SmartMeter();
Device asDevice = meter;

Console.WriteLine(meter.Describe());
Console.WriteLine(asDevice.Describe());

class Device
{
    public string Describe() => "device";
}

class SmartMeter : Device
{
    public new string Describe() => "smart meter";
}
smart meter
device

The most common override of all is ToString(), which object declares as virtual (see ToString and printing objects). Equals() and GetHashCode() are virtual too, but a record generates them correctly for you.

Abstract classes

Sometimes the base class is only a general idea. Every grid asset has an ID and an inspection interval, but there is no such thing as "an asset" that is not a transformer, a cable or something else. An abstract class, marked abstract, cannot be instantiated. It can contain abstract members: declarations without a body that every non-abstract derived class must override. The compiler checks that none is forgotten.

Asset[] assets = [new Transformer("TR-12", 400), new Cable("CB-7", 2.5)];

foreach (var a in assets)
    Console.WriteLine($"{a.Id}: {a.Summary()}, next check in {a.InspectionMonths} months");

abstract class Asset(string id)
{
    public string Id { get; } = id;
    public abstract int InspectionMonths { get; }
    public abstract string Summary();
}

class Transformer(string id, int kva) : Asset(id)
{
    public override int InspectionMonths => 12;
    public override string Summary() => $"transformer, {kva} kVA";
}

class Cable(string id, double km) : Asset(id)
{
    public override int InspectionMonths => 36;
    public override string Summary() => $"cable, {km} km";
}
TR-12: transformer, 400 kVA, next check in 12 months
CB-7: cable, 2.5 km, next check in 36 months
Class hierarchy: Transformer and Cable derive from the abstract class Asset, which derives from object. Both derived classes override Summary. object Asset {abstract} Transformer Cable is a is a overrides Summary() overrides Summary()
The hierarchy of the example. The hollow arrowhead means "derives from". Code that works with Asset works with every box below it.
var a = new Asset("X-1");

abstract class Asset(string id)
{
    public string Id { get; } = id;
}
example.cs(1,9): error CS0144: Cannot create an instance of the abstract type or interface 'Asset'

An abstract class can also hold normal fields, constructors and fully written methods that all derived classes share. That is the main difference from an interface, which describes what a type can do but (apart from default methods) holds no shared state.

Sealed classes

The opposite of abstract is sealed. A sealed class cannot be used as a base class. Designing a class for inheritance takes work (which methods may be overridden, what may a derived class rely on?), so sealing a class you did not design for it is a sensible default. Many .NET types are sealed, including string.

Console.WriteLine(new DiscountTariff());

class Tariff { }
sealed class FixedTariff : Tariff { }
class DiscountTariff : FixedTariff { }
example.cs(5,24): error CS0509: 'DiscountTariff': cannot derive from sealed type 'FixedTariff'

A single method can be sealed too: sealed override stops further overriding of that method lower in the hierarchy. Sealing can also let the JIT compiler call methods slightly faster, because it knows no override can exist.

Interfaces

An interface is a contract: a named list of members that a type promises to provide, without saying how. By convention interface names start with a capital I. A class or struct implements an interface by listing it after the colon and providing every member. Code that only needs "something that can send a notification" asks for an INotifier and does not care which class it gets.

INotifier[] channels =
[
    new EmailNotifier("mei.chen@example.com"),
    new SmsNotifier("+31 6 1234 5678"),
];

foreach (var channel in channels)
    channel.Send("INC-40112 resolved");

interface INotifier
{
    void Send(string message);
}

class EmailNotifier(string address) : INotifier
{
    public void Send(string message) => Console.WriteLine($"Email to {address}: {message}");
}

class SmsNotifier(string number) : INotifier
{
    public void Send(string message) => Console.WriteLine($"SMS to {number}: {message}");
}
Email to mei.chen@example.com: INC-40112 resolved
SMS to +31 6 1234 5678: INC-40112 resolved

Interface members are public by default, and an implementing method needs no override. Unlike base classes, a class can implement any number of interfaces, and it can do so next to a base class: class SmartMeter : Device, IReadable, IRestartable (the base class comes first).

Interfaces in .NET

You implement .NET's own interfaces to plug your types into the libraries. IComparable<T> tells sorting methods how to order your objects, IEnumerable<T> makes a type usable in foreach and LINQ (see Loops), and IDisposable makes it work with using (see Exceptions and Error Handling).

var tariffs = new List<Tariff>
{
    new("Green Fixed", 0.31m),
    new("Dynamic", 0.27m),
    new("Night Saver", 0.29m),
};
tariffs.Sort();
foreach (var t in tariffs)
    Console.WriteLine($"{t.Name}: {t.PricePerKwh:F2}");

record Tariff(string Name, decimal PricePerKwh) : IComparable<Tariff>
{
    public int CompareTo(Tariff? other) =>
        other is null ? 1 : PricePerKwh.CompareTo(other.PricePerKwh);
}
Dynamic: 0.27
Night Saver: 0.29
Green Fixed: 0.31

Default interface methods

Since C# 8 an interface can contain a method with a body. A default interface method gives implementing types that behavior for free, which lets a library add a member to a published interface without breaking every existing implementation. There is a catch: the default method belongs to the interface, not to the class. You can only call it through a variable of the interface type.

var meter = new SmartMeter();
IReadable readable = meter;

Console.WriteLine(readable.Describe());
Console.WriteLine(meter.Describe());

interface IReadable
{
    double ReadKwh();
    string Describe() => $"reading {ReadKwh()} kWh";
}

class SmartMeter : IReadable
{
    public double ReadKwh() => 412.5;
}
example.cs(5,25): error CS1061: 'SmartMeter' does not contain a definition for 'Describe' and no accessible extension method 'Describe' accepting a first argument of type 'SmartMeter' could be found (are you missing a using directive or an assembly reference?)

Remove the last top-level line and the program prints reading 412.5 kWh. A class can still write its own Describe(), which then replaces the default. Use default methods sparingly; they are mainly a tool for evolving interfaces, not a replacement for base classes.

Abstract class or interface?

Abstract class Interface
How many per type One (single inheritance) Any number
Fields and constructors Yes No instance fields or constructors
Implemented or inherited by structs No (a struct cannot declare a base class) Yes
Expresses "is a kind of" with shared code "can do" a capability

Start with an interface when you only need a contract; reach for an abstract class when several related types really share state and code.

Polymorphism

Polymorphism means that one piece of code works with many types through a shared base class or interface. The loops above never ask "is this an email or an SMS?"; they call Send and each object does the right thing. Adding a Teams notifier later needs a new class, but no change to the loop. That is the main payoff of virtual methods and interfaces.

Sometimes you do need to know the concrete type, for example to report on a mixed list of assets. Then use type patterns rather than casts: is Cable cable tests the type and gives you a typed variable in one step, and a switch expression handles several types at once (see Control Flow and Pattern Matching).

Asset[] assets = [new Transformer("TR-12", 400), new Cable("CB-7", 2.5), new Transformer("TR-15", 630)];

foreach (var a in assets)
{
    string text = a switch
    {
        Transformer { Kva: >= 500 } t => $"{t.Id}: large transformer",
        Transformer t => $"{t.Id}: transformer",
        Cable c => $"{c.Id}: {c.Km} km of cable",
        _ => $"{a.Id}: unknown asset",
    };
    Console.WriteLine(text);
}

if (assets[1] is Cable cable)
    Console.WriteLine($"Cable length: {cable.Km} km");

abstract record Asset(string Id);
record Transformer(string Id, int Kva) : Asset(Id);
record Cable(string Id, double Km) : Asset(Id);
TR-12: transformer
CB-7: 2.5 km of cable
TR-15: large transformer
Cable length: 2.5 km

This example also shows that record classes can inherit from each other. The order of the switch arms matters: the more specific Transformer { Kva: >= 500 } must come before plain Transformer. When the same type check appears in many places, that is a hint to move the behavior into a virtual method or an interface instead.

Composition over inheritance

Inheritance couples a derived class tightly to its base: a change in the base class ripples down, and a class cannot switch its base at run time. Deep hierarchies (Device → Meter → SmartMeter → ThreePhaseSmartMeter) become hard to change. The common advice is composition over inheritance: give a class the objects it needs (a "has a" relationship) instead of inheriting from them, and depend on interfaces.

var alerts = new AlertService(new ConsoleNotifier());
alerts.Raise("INC-40112", "No power in Leiden");

interface INotifier
{
    void Send(string message);
}

class ConsoleNotifier : INotifier
{
    public void Send(string message) => Console.WriteLine($"[notify] {message}");
}

class AlertService(INotifier notifier)
{
    public void Raise(string ticket, string summary) => notifier.Send($"{ticket}: {summary}");
}
[notify] INC-40112: No power in Leiden

AlertService does not inherit from a notifier; it has one, passed in through its constructor. Swapping email for SMS, or for a fake notifier in a unit test, needs no change to AlertService at all. This pattern, called dependency injection, is how ASP.NET Core applications are built, and it makes classes easy to test (see Testing and Code Quality).