Default Interface Members

Since C# 8 an interface member can carry a body, which lets you add to a published interface without breaking every implementor.

Syntaxinterface ILogger { void Log(string message); public void LogWarning(string message) => Log("WARN: " + message); }

Normally an interface member has no body and every implementing type must supply one. Since C# 8 a member may carry a body instead. That body is the default implementation. A type that implements the interface and says nothing about that member gets the default. A type that writes its own member replaces it. The feature needs runtime support that arrived in .NET Core 3.0, so it is not available on .NET Framework.

What it is actually for

The reason it exists is versioning. Adding a member to a published interface breaks every class that implements it, including classes in other people's code. A default implementation lets you add the member and keep those classes compiling. That is the case where it earns its place.

Why it is not a base class

An interface holds no instance data. It cannot declare an instance field, and a property written as int Count { get; set; } in an interface declares a member to implement rather than storing anything. So a default body can only call other interface members. If the shared behaviour needs shared state, you want an abstract class.

What trips people up

A default member is reachable only through a variable of the interface type. If ConsoleLogger does not declare LogWarning itself, then new ConsoleLogger().LogWarning("x") does not compile, while the same call on an ILogger variable does. C# 11 added a separate feature, static abstract interface members, which demands a static member from implementors; generic maths covers that later.

Example

Example · csharp
ILogger plain = new ConsoleLogger();
plain.Log("starting");

// ConsoleLogger wrote no LogWarning, so the interface's default body runs.
plain.LogWarning("disk almost full");

ILogger loud = new PrefixLogger();

// PrefixLogger wrote its own, so that one runs instead.
loud.LogWarning("disk almost full");

interface ILogger
{
    // No body: every implementor must supply this one.
    void Log(string message);

    // A body: the default implementation, written in terms of Log.
    public void LogWarning(string message) => Log($"WARN: {message}");
}

class ConsoleLogger : ILogger
{
    public void Log(string message) => Console.WriteLine(message);
}

class PrefixLogger : ILogger
{
    public void Log(string message) => Console.WriteLine($"[app] {message}");

    // Replaces the default body for this type.
    public void LogWarning(string message) => Log($"!! {message}");
}

// Output:
// starting
// WARN: disk almost full
// [app] !! disk almost full

When to use it

  • Add a convenience method to an interface your own packages already publish, without breaking implementors.
  • Give a small interface a helper that is always written the same way in terms of its other members.
  • Let most implementors inherit sensible behaviour while one specialised type overrides it.

More examples

Adding a member to a shipped interface

This is the situation default members were designed for. You are shipping an interface other people implement, you need a new member, and you cannot break their build. Reach for it when the new member can be written in terms of members that already exist.

Example · csharp
// Version 1 of the interface had only Area.
// Version 2 adds Describe with a default body, so DeckPlan still compiles
// even though its author never heard of Describe.
interface IShape
{
    double Area { get; }

    public string Describe() => $"shape of area {Area}";
}

// Written against version 1 and left untouched.
class DeckPlan : IShape
{
    public double Area => 12;
}

// Written against version 2 and choosing its own wording.
class Circle : IShape
{
    public Circle(double radius) => Area = Math.PI * radius * radius;

    public double Area { get; }

    public string Describe() => "a circle";
}

IShape[] shapes = { new DeckPlan(), new Circle(1) };

foreach (IShape shape in shapes)
{
    Console.WriteLine(shape.Describe());
}

// Output:
// shape of area 12
// a circle

The shorthand and the longhand of a default body

A default body obeys the same rules as any other method body, including the arrow shorthand. Reading the two forms side by side is worth a minute, because library code mixes them freely and the arrow form hides the word return.

Example · csharp
interface IRange
{
    int Low { get; }

    int High { get; }

    // Expression-bodied default implementation.
    public int Size() => High - Low;

    // The same thing written with a block body. Only one of the two
    // may exist at a time; they compile to identical code.
    // public int Size()
    // {
    //     return High - Low;
    // }
}

class Window : IRange
{
    public int Low => 3;

    public int High => 10;
}

IRange range = new Window();
Console.WriteLine(range.Size());

// Output:
// 7

Where a default body cannot help you

The contrast is the lesson: the interface version pushes the storage onto every implementor, the abstract class keeps it in one place. When the shared behaviour needs a field, stop and use a base class.

Example · csharp
interface ICounter
{
    // An interface holds no instance data, so this declares a member
    // to implement. It does not store a number.
    int Count { get; set; }

    // The default body can only reach other interface members.
    public void Bump() => Count += 1;
}

// An abstract class can hold the field itself, which is why shared state
// belongs in a base class rather than an interface.
abstract class CounterBase
{
    private int _count;

    public int Count => _count;

    public void Bump() => _count += 1;
}

class Clicks : CounterBase
{
}

var clicks = new Clicks();
clicks.Bump();
clicks.Bump();
Console.WriteLine(clicks.Count);

// Output:
// 2

Discussion

  • Be the first to comment on this lesson.