Inheritance, Interfaces and Polymorphism
Building on other types in C#: base and derived classes, virtual and override, abstract and sealed classes, interfaces, and polymorphism, with advice on when composition is better.
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 suppliesToString(),Equals()andGetHashCode(). 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
privatemembers. Members markedprotectedare 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
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).