Blog

Abstraction Without Inheritance: Interfaces, Traits and Functions

Why implementation inheritance breaks, shown in running Java code; how interfaces, Go’s embedding, Rust traits and plain functions abstract without it; and what static and dynamic dispatch cost, measured in Rust, Go and C#.

Inheritance was sold as the way to reuse code: take a class that almost does what you need, extend it, and override the part that differs. It turned out to be the most fragile kind of coupling in object-oriented code. Two of this series’ four languages, Go and Rust, left implementation inheritance out entirely, and the other two now offer ways to avoid it.

This part is about the alternatives: interfaces, abstract classes used carefully, Go’s embedding, Rust’s traits, and functions passed as values. It shows the problem with inheritance in code you can run, and it measures what the alternatives cost at run time, because “interfaces are slow” is a claim people make without measuring.

Try this first

A Java class wants to count how many elements are ever added to a HashSet. It extends HashSet, adds a counter, and overrides both add and addAll to increase it: add by one, addAll by the size of the collection it receives. Then it calls addAll with three names.

What does the counter say? Write your answer down.

What inheritance couples together

Implementation inheritance does two jobs at once. It makes the subclass a subtype, usable wherever the parent is expected, and it reuses code: the subclass gets the parent’s fields and method bodies. Part 7 covered what goes wrong with the first job, when a subtype can’t keep its parent’s promises. This part is mostly about the second.

The problem has a name. The fragile base class problem was studied in detail by Leonid Mikhajlov and Emil Sekerinski, in a 1997 technical report and a 1998 ECOOP paper: a developer who can’t see the extensions of a class “may produce a seemingly acceptable revision of a base class which may damage its extensions.” They were precise about the cause. It isn’t recompilation, which they call “only a technical issue”. It’s “code inheritance as an implementation reuse mechanism along with self-recursion”: a base class whose methods call other methods that a subclass may have overridden.

Measured: counting what a HashSet adds

Here’s the class from the opening question, and a version that uses composition instead:

void main() {
    var names = new CountingSet<String>();
    names.addAll(List.of("Ada", "Grace", "Barbara"));
    IO.println("added: " + names.added() + ", size: " + names.size());

    var wrapped = new CountingSetWrapper<String>(new HashSet<>());
    wrapped.addAll(List.of("Ada", "Grace", "Barbara"));
    IO.println("added: " + wrapped.added() + ", size: " + wrapped.size());
}

// Inheritance: counts every element added, it thinks.
class CountingSet<E> extends HashSet<E> {
    // Inheriting HashSet also made this class Serializable, which javac -Xlint warns about.
    @Serial
    private static final long serialVersionUID = 1L;

    private int added;

    @Override
    public boolean add(E e) {
        added++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        added += c.size();
        return super.addAll(c); // HashSet's addAll calls add() for each element
    }

    int added() {
        return added;
    }
}

// Composition: holds a set and forwards to it, so no self-calls can reach back in.
class CountingSetWrapper<E> {
    private final Set<E> inner;
    private int added;

    CountingSetWrapper(Set<E> inner) {
        this.inner = inner;
    }

    boolean add(E e) {
        added++;
        return inner.add(e);
    }

    boolean addAll(Collection<? extends E> c) {
        added += c.size();
        return inner.addAll(c);
    }

    int added() {
        return added;
    }

    int size() {
        return inner.size();
    }
}

It prints:

added: 6, size: 3
added: 3, size: 3

So the answer is 6, not 3. The subclass added 3 for addAll, then called super.addAll, and Java’s HashSet inherits an addAll that calls add once per element, which the subclass also overrode. Each element was counted twice.

That isn’t a bug in the JDK. The documentation of AbstractCollection.addAll, which HashSet inherits, states it under “Implementation Requirements”: “This implementation iterates over the specified collection, and adds each object returned by the iterator to this collection, in turn.” The subclass is coupled to that detail, and so to every future change in it. Mikhajlov and Sekerinski’s example is the mirror image: a new version of a base class stops calling add from addAll, and a counting subclass silently undercounts.

The composition version has no such coupling. It holds a Set, forwards calls to it, and counts in its own methods. Whatever HashSet does inside, no call reaches back into the wrapper. The cost is that it must forward every method it wants to offer, and it’s no longer a Set unless it implements the interface.

Notice the other thing the compiler’s lint warnings pointed out: extending HashSet also made CountingSet serializable, because HashSet is. Inheritance brings everything the parent is, not just the methods you wanted.

Designing a class to be extended

If a class is meant to be subclassed, its self-use must be designed and documented. .NET’s Collection<T> is an example. Its public Add and Insert aren’t virtual. Both go through one protected method, InsertItem, whose documentation says it “is meant to be overridden in a derived class”:

using System.Collections.ObjectModel;

var names = new CountingCollection<string>();
names.Add("Ada");
names.Insert(0, "Grace");
Console.WriteLine($"added: {names.Added}, count: {names.Count}");

// Collection<T> is designed to be extended: Add and Insert both go through one protected virtual hook.
sealed class CountingCollection<T> : Collection<T>
{
    public int Added { get; private set; }

    protected override void InsertItem(int index, T item)
    {
        Added++;
        base.InsertItem(index, item);
    }
}

It prints:

added: 2, count: 2

Two adds, counted once each, because there’s exactly one place to hook. .NET’s collection guidelines suggest considering Collection<T> as a base for new collections, and say not to expose List<T> in public APIs. List<T>.Add isn’t virtual at all.

The languages start from different defaults:

  • C# methods are non-virtual by default: “By default, methods are non-virtual. You can’t override a non-virtual method.” Microsoft’s framework design guidelines add: “DO NOT make members virtual unless you have a good reason to do so”.
  • Java methods are overridable by default, and classes are open by default in both languages. Java 17 added sealed classes and interfaces, which “restrict which other classes or interfaces may extend or implement them.”
  • Go and Rust have no implementation inheritance at all.

Interfaces and abstract classes

An interface describes what a type can do, without saying how. An abstract class can do that too, and can also hold state, constructors and code.

Interface Abstract class
Instance fields No Yes
Constructors No Yes
Method bodies Default methods (Java 8), default interface members (C# 8, on .NET Core 3.0 or later) Yes
A class can have many one
Adding a method later usually safe with a default body; can clash with another interface’s default or an existing method usually safe; can clash with a same-named method in a subclass

Since default methods, the remaining differences are state, constructors, and access control. The C# documentation: “instance fields aren’t permitted in interfaces.” And a Java interface’s methods can’t be protected or final, so an interface can’t enforce a template, a fixed public method that calls protected hooks, which is exactly how Collection<T> above is built.

Default methods were added for evolving published interfaces, not as a back door to inheritance. The Java tutorial: “Default methods enable you to add new functionality to the interfaces of your libraries and ensure binary compatibility with code written for older versions of those interfaces.” Both languages refuse to guess when two interfaces supply the same default. The Java Language Specification makes it a compile-time error, and the C# design says a conflict “is resolved explicitly by the programmer at the point where the conflict arises.”

Older .NET advice pointed the other way. The .NET Framework 4.0 guidelines said “Do favor defining classes over interfaces”, because an interface couldn’t gain members without breaking its implementers. That reason was about library versioning, it predates default interface members, and the current guidelines carry a note that they come from a 2008 edition and “may be out-of-date”. Their other advice has aged better: “DO NOT provide abstractions unless they are tested by developing several concrete implementations and APIs consuming the abstractions.”

Composition, and what the Gang of Four meant. “Favor object composition over class inheritance” comes from the introduction of Design Patterns (1994). Erich Gamma, one of its authors, explained it in a 2005 interview. On inheritance: “we know that it’s brittle, because the subclass can easily make assumptions about the context in which a method it overrides is getting called.” And on what composition is: “A common misunderstanding is that composition doesn’t use inheritance at all. Composition is using inheritance, but typically you just implement a small interface and you do not inherit from a big class.” He named the Strategy pattern as his “prototypical example”. Part 12 covers the patterns.

Go: interfaces without declarations, embedding without inheritance

A Go type satisfies an interface by having its methods. There’s no implements. The Go FAQ explains the design: “Rather than requiring the programmer to declare ahead of time that two types are related, in Go a type automatically satisfies any interface that specifies a subset of its methods.” So an interface can be written later, by the code that needs it, as Part 7 showed.

Dynamically dispatched methods exist only through interfaces. The FAQ again: “The only way to have dynamically dispatched methods is through an interface. Methods on a struct or any other concrete type are always resolved statically.”

Embedding puts one type inside another and promotes its methods, which looks like inheritance. Effective Go explains the difference: “When we embed a type, the methods of that type become methods of the outer type, but when they are invoked the receiver of the method is the inner type, not the outer one.” That removes the mechanism behind fragile base classes, because the inner type’s methods can never call back into the outer type:

package main

import "fmt"

type Logger struct{}

func (Logger) Prefix() string { return "log" }

// Log calls Prefix on its own receiver, which is always a Logger.
func (l Logger) Log(msg string) { fmt.Println(l.Prefix() + ": " + msg) }

// Audit embeds Logger, so Log and Prefix are promoted to it.
type Audit struct {
	Logger
}

// This Prefix hides Logger's for callers of Audit, but Logger.Log never sees it.
func (Audit) Prefix() string { return "audit" }

func main() {
	a := Audit{}
	fmt.Println(a.Prefix())
	a.Log("user signed in")
}

It prints:

audit
log: user signed in

Audit has its own Prefix, and callers of Audit get it. But Log belongs to Logger, and inside it l is a Logger, so it calls Logger.Prefix. There’s no override to break. Embedding has its own fragility, though. As Part 7 noted, it promotes every method of the inner type to the outer one, and a method added to the inner type later can change which interfaces the outer type satisfies, or clash with another embedded type’s method. Embed only what the outer type should offer.

One Go gotcha with interfaces: an interface value holding a nil pointer isn’t nil. A function that returns a nil *MyError as an error returns a non-nil error, which is why functions return the error interface, not a concrete error type.

“Accept interfaces, return structs” is a well-known Go rule of thumb, popularised by Jack Lindamood’s 2016 blog post. The Go project’s own review guidance says something close: interfaces “generally belong in the package that uses values of the interface type”, and “The implementing package should return concrete (usually pointer or struct) types”.

Rust: traits, generics and trait objects

Rust’s book is direct: “If a language must have inheritance to be object oriented, then Rust is not such a language.” A trait defines behaviour a type can have, and can supply default methods. Code that uses a trait chooses how to call it:

  • Generics (fn total_area<T: Shape>(shapes: &[T]), or impl Shape) are compiled into a separate copy for each concrete type, called monomorphization. The book: “we pay no runtime cost for using generics.” Each copy can inline the method.
  • Trait objects (&dyn Shape, Box<dyn Shape>) let one function work with many types at run time. The Reference: “a function pointer is loaded from the trait object vtable and invoked indirectly.”
trait Shape {
    fn area(&self) -> f64;

    // A default method: every Shape gets it, and any type can override it.
    fn describe(&self) -> String {
        format!("a shape of area {:.1}", self.area())
    }
}

struct Circle {
    r: f64,
}

struct Rect {
    w: f64,
    h: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.r * self.r
    }
}

impl Shape for Rect {
    fn area(&self) -> f64 {
        self.w * self.h
    }

    fn describe(&self) -> String {
        format!("a {} by {} rectangle", self.w, self.h)
    }
}

// Static dispatch: the compiler makes a copy of this function for each type it's used with.
fn total_area<T: Shape>(shapes: &[T]) -> f64 {
    shapes.iter().map(Shape::area).sum()
}

// Dynamic dispatch: one function, calling through a table of methods at run time.
fn describe_all(shapes: &[Box<dyn Shape>]) {
    for s in shapes {
        println!("{}", s.describe());
    }
}

// A function is an abstraction too: anything callable with an f64 that returns a String.
fn report(label: &str, value: f64, format: impl Fn(f64) -> String) {
    println!("{label}: {}", format(value));
}

fn main() {
    let circles = [Circle { r: 1.0 }, Circle { r: 2.0 }];
    println!("{:.2}", total_area(&circles));

    let mixed: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { r: 1.0 }),
        Box::new(Rect { w: 2.0, h: 3.0 }),
    ];
    describe_all(&mixed);

    report("area", total_area(&circles), |v| {
        format!("{v:.0} square units")
    });
}

It prints:

15.71
a shape of area 3.1
a 2 by 3 rectangle
area: 16 square units

total_area gets one compiled copy for Circle. Note describe: it’s a default method that calls self.area(), and a type can override either. That’s the same self-call hook that makes base classes fragile, without the shared state, so a trait’s documentation should say which default methods call which, just as a base class should. describe_all is one function that handles a circle and a rectangle in the same list, which generics can’t do without a common type. And report takes a closure, which leads to the next section.

Where traits can be implemented. Rust’s orphan rule, in the book’s words: “we can implement a trait on a type only if either the trait or the type, or both, are local to our crate.” Blanket implementations go the other way: the standard library implements ToString for every type that implements Display.

Functions are abstractions too

A one-method interface and a function type describe the same thing: “something I can call with these arguments”. Every language here lets you pass the function directly:

Language Function as a value Adapting a function to an interface
C# delegates, Func<T, TResult>, lambdas a lambda converts to a delegate type, not to an interface; wrap it in a small class for that
Java lambdas and method references, targeting a functional interface an interface with one abstract method, not sealed, accepts a lambda
Go function types, closures http.HandlerFunc: “an adapter to allow the use of ordinary functions as HTTP handlers”
Rust closures implementing Fn, FnMut or FnOnce a closure satisfies an impl Fn(...) or generic bound

Reach for a function when the abstraction is one operation with no state of its own to manage, such as a comparison, a retry policy or a formatting rule. Reach for an interface or trait when there are several related operations, when implementations need names, or when you want to find all implementations in a codebase.

Static and dynamic dispatch

Every abstraction above ends in a call, and there are two ways a call can find its code. With static dispatch the compiler knows the concrete type, so it calls the method directly, or copies the method’s body into the caller, called inlining. With dynamic dispatch the type is only known at run time, so the call goes through a table of methods. Step through the three shapes this takes:

shape.area(): how the program finds the code to run static dispatch virtual call (C#, Java) interface value (Go), trait object (Rust) call sitetype known: Circle Circle.areacan be inlined referenceto an object objectheader: its type method tableslot for area Circle.areafound at run time (table, data)two words itab / vtableper type pair Circle.areafound at run time 1. Static dispatch: the compiler knows the type, so it calls, or inlines, the method directly 2. C# and Java: the object's header points to its type's method table, which holds area 3. Go and Rust: the interface value carries the table pointer beside the data pointer 4. A JIT, or an AOT compiler that sees every type, can turn 2 back into 1 behind a type check

After the Rust Reference on trait objects, Russ Cox on Go interface values (2009), and the HotSpot and .NET documentation on devirtualization. C# and Java interface calls use separate interface tables or dispatch stubs, and real runtimes add caches and details this leaves out.

  • C# and Java put a pointer to the object’s type in every object’s header, and a virtual call looks the method up through it.
  • Go and Rust keep the objects plain, and put the table pointer beside the data pointer in the interface value or trait object. Russ Cox described Go’s in 2009: “Interface values are represented as a two-word pair giving a pointer to information about the type stored in the interface and a pointer to the associated data.”
  • A compiler that knows or guesses the type can undo the cost. HotSpot’s performance notes: “The best case is a de facto monomorphic call which is inlined.” .NET 8 turned dynamic profile-guided optimisation on by default, and one of its main uses is to “devirtualize virtual and interface calls per call site.” Go 1.21 and later can do the same for hot interface calls in a build with profile-guided optimisation.

The Rust book states the usual cost: dynamic dispatch “prevents the compiler from choosing to inline a method’s code, which in turn prevents some optimizations”. How much does that matter?

Explain it like I’m ten

You need to phone a friend. If you know their number by heart, you just dial it. That’s static dispatch: quick, and nothing to look up.

If all you know is “call whoever is on duty today”, you first look at the rota, find today’s name, look up that person’s number in the phone book, and then dial. That’s dynamic dispatch. It lets anyone be on duty, but every call takes two extra look-ups. And if the same person is on duty every day, a clever secretary just remembers the number, and checks the rota only to make sure nothing changed. That’s what a JIT compiler can do.

The precise version

  • Knowing the number by heart is a direct call, which the compiler may also inline, copying the method’s body into the caller.
  • The rota and the phone book are the method table (vtable or itab): a per-type table of function pointers.
  • The clever secretary is guarded devirtualization: the compiler checks for the type it usually sees and calls that method directly, falling back to the table otherwise.
  • Where the analogy breaks: the look-ups themselves are cheap. The bigger cost is usually what they prevent, such as inlining and optimising the loop around the call.

Measured: a million shapes in Rust, Go and C

We summed the area of 1,000,000 shapes in each language, several ways, with each program pinned to one CPU core. Each variant ran 250 times across 5 processes, after 30 warm-up rounds per process were dropped. The numbers are medians. The ratios compare each round with the direct call in the same round, and their 95% intervals come from a bootstrap that resamples processes, then rounds.

C# ran twice: once on the JIT, and once compiled ahead of time with Native AOT, which is what dotnet publish produces for a file-based app (dotnet run app.cs) by default. Choose a language:

sum the area of 1,000,000 shapes: median milliseconds per pass one CPU core; 5 processes × 50 kept rounds each 0 ms 2 ms 4 ms 6 ms

Measured by checks/part08_dispatch/run.py on one machine (12th Gen Intel(R) Core(TM) i5-1235U), each program pinned to one core; intervals are bootstrap 95% intervals of per-round ratios. A tight loop with a one-line method is the worst case for dispatch: in real code the method body usually dominates.

Language How area is called Median per pass Compared with the first row, unless marked (95% interval)
Rust generic function, inlined 0.89 ms
Rust direct call, not inlined 6.31 ms 7.05× (6.74–7.37)
Rust &dyn Shape, all circles 6.40 ms 7.02× (6.77–7.32)
Rust &dyn Shape, circles and squares 6.58 ms 7.28× (6.88–7.92)
Rust enum and match, circles and squares 1.22 ms 1.4× (1.32–1.52)
Go concrete type, inlined 0.89 ms
Go direct call, not inlined 3.52 ms 4.03× (3.8–4.25)
Go interface, all circles 3.63 ms 3.98× (3.88–4.24)
Go interface, circles and squares 3.69 ms 4.12× (3.85–4.37)
Go generic function 3.46 ms 3.93× (3.74–4.19)
C#, JIT array of structs, direct call 0.88 ms
C#, JIT array of objects, direct call 1.64 ms 1.88× (1.81–1.93) vs structs
C#, JIT interface, all circles 3.14 ms 1.92× (1.86–1.99) vs objects
C#, JIT interface, circles and squares 3.34 ms 2.02× (1.96–2.09) vs objects
C#, Native AOT array of structs, direct call 0.84 ms
C#, Native AOT array of objects, direct call 1.66 ms 1.96× (1.9–2.01) vs structs
C#, Native AOT interface, all circles 1.78 ms 1.06× (1.02–1.11) vs objects
C#, Native AOT interface, circles and squares 2.40 ms 1.49× (1.43–1.54) vs objects

What the run shows, and what it doesn’t:

  • In Rust and Go, the cost is the lost inlining, not the lookup. A trait object cost about 7.0 times the inlined generic loop in Rust, and an interface about 4.0 times the concrete loop in Go. But a direct call to a copy of the method marked not to be inlined cost the same: 7.0 times in Rust and 4.0 times in Go. Once the method isn’t copied into the loop, the running total, kept in a register that a call is allowed to overwrite, likely has to be saved and reloaded around every call. With the types in a predictable order, as here, the table lookup itself added little. With types in a random order the processor would mispredict the jump more often, and this run didn’t test that.
  • Don’t compare the Rust and Go ratios with each other. Each compiler handles a call inside a loop differently, and each “inlined” baseline is a different piece of machine code.
  • A Rust enum stayed close to the inlined loop, at 1.4 times: with a closed set of cases, the compiler sees all the code and inlines both. Part of that gap is layout, too: the enum stores each shape in place in one array, while the trait objects point into two separate arrays.
  • Go generics weren’t faster than interfaces here, at 3.9 times. Go compiles one copy of generic code per GC shape (all pointer types share one) and calls methods through a dictionary, so generics aren’t static dispatch in Go the way they are in Rust.
  • In C# on the JIT, the interface cost stayed: 1.9 times the direct call on the same objects. .NET 10’s JIT didn’t devirtualize this loop. Its log (DOTNET_JitDisasmSummary=1) shows each method replaced mid-loop by on-stack replacement using synthesized rather than measured profile data, so the guarded devirtualization described above never applied. Separately, an array of objects took 1.9 times an array of structs, because each element is a separate object elsewhere in memory.
  • Native AOT removed most of it, at 1.1 times for a single type, and 1.5 times for two. Compiling the whole program ahead of time, it could see every class implementing the interface. The disassembly shows the interface call replaced by a check of whether each element is a Square, with both bodies inlined, like an enum match. That’s whole-program devirtualization. Both methods compiled to the same code, so the gap between 1.1 and 1.5 times comes from the data, not from dispatch: with two types, the loop reads objects from two arrays and the branch alternates. It stops being possible when code can be loaded at run time.
  • This is the worst case for dispatch. The method is one line, so the call is a large share of the work. When each call does real work, such as a database query or a JSON encode, the dispatch cost disappears into it.
  • One machine, one workload. A 12th Gen Intel(R) Core(TM) i5-1235U, one core, and one loop shape. We didn’t measure Java: fair Java measurements need the JMH benchmarking harness, which this lab doesn’t use.

The design lesson: choose between static and dynamic dispatch for the design’s sake, whether the set of types is open or closed, and only change it for speed where a profile shows a tight loop that matters.

Open or closed: the expression problem

Choosing between interfaces and enums involves a deeper trade-off than speed. Philip Wadler named it in 1998, the expression problem:

“One can think of cases as rows and functions as columns in a table. In a functional language, the rows are fixed (cases in a datatype declaration) but it is easy to add new columns (functions). In an object-oriented language, the columns are fixed (methods in a class declaration) but it is easy to add new rows (subclasses).”

  • An interface or trait makes it easy to add a new type: write a new implementation, and no existing code changes. Adding a new operation means changing the interface and every implementation.
  • An enum with match, or a Java sealed interface with pattern matching, makes it easy to add a new operation: write a new function over the cases. Adding a new case means changing every match, and the compiler lists each one that needs it, as long as no match has a catch-all arm.

Neither is better. Payment providers, storage backends and plug-ins are open sets: use interfaces. Order states, shapes in a closed geometry kernel and parsed syntax trees are closed sets: use sum types. Part 9 covers sum types.

Across languages

C# Java Go Rust
Implementation inheritance classes, single classes, single none; embedding promotes methods none
Methods overridable by default no (virtual needed) yes (final to stop) n/a n/a
Stop or limit subclassing sealed final stops it; sealed (Java 17) limits it to named classes n/a n/a
Interfaces explicit, default members (C# 8), static abstract members (C# 11) explicit, default methods (Java 8) implicit traits, explicit impl
Static dispatch over an abstraction generics with struct type arguments, static abstract members; JIT or AOT devirtualization none in the language (generics are erased); the JIT may devirtualize concrete types only; generic code is shared per GC shape; PGO builds can devirtualize generics, impl Trait
Dynamic dispatch virtual and interface calls virtual and interface calls interface values dyn Trait
Functions as values delegates, lambdas lambdas, method references function types, closures closures, Fn traits

Trade-offs

Inheritance versus composition. Inheritance gives reuse with almost no code, and couples the subclass to the parent’s implementation, including its self-calls. Composition costs forwarding code, and depends only on the interface it forwards to.

Interfaces versus abstract classes. An interface can be implemented by any type, alongside others. An abstract class can share state and constructor logic, and uses up the single base class.

Open versus closed sets. Interfaces make new types cheap and new operations expensive. Enums and sealed hierarchies do the reverse.

Static versus dynamic dispatch. In Rust, and in C# with struct type arguments, generics give inlining and speed, at the cost of more compiled code and longer builds. Go’s generics don’t, and Java’s are erased. Dynamic dispatch gives one compiled function and mixed collections, and costs inlining, which matters in a tight loop and rarely elsewhere. Compilers that see or guess the types can remove it.

Functions versus interfaces. A function is the lightest abstraction for one operation. An interface names a role, groups related operations, and makes implementations easy to find.

Common mistakes

Extending a class to reuse a few of its methods. You inherit all of it, including self-calls you don’t know about and interfaces you didn’t want, such as Serializable.

Overriding a method without knowing who calls it. The CountingSet bug came from not knowing that addAll calls add. If a base class doesn’t document its self-use, don’t override its methods.

Making every method virtual “for flexibility”. Each one is a promise to support overriding forever. Microsoft’s guidelines: “DO NOT make members virtual unless you have a good reason to do so”.

Deep inheritance hierarchies. Every level adds assumptions about the levels above. Keep hierarchies shallow, and prefer interfaces plus small shared helpers.

Using a trait object or interface where the set of types is closed. An enum or sealed type lets the compiler check every case, and in Rust it’s faster.

Switching everything to generics for speed. Build times and binary size grow, and the gain only shows in tight loops. Measure first.

Treating Go embedding as inheritance. The inner type’s methods don’t call the outer type’s methods. Code that relies on that “override” silently does nothing.

Interview questions

Try to answer each one before opening the model answer.

1. What is the fragile base class problem? Give an example.

Show a strong answer
  • Definition: a change to a base class that looks safe breaks subclasses, because they depend on how the base class calls its own methods. Mikhajlov and Sekerinski traced it to “code inheritance as an implementation reuse mechanism along with self-recursion”.
  • Example: a counting subclass of HashSet overrides add and addAll. HashSet inherits an addAll that calls add, so adding three elements counts six. A developer who “fixes” that by removing the addAll override gets 3, until a future HashSet stops calling add from addAll, when the count silently drops to 0 for bulk adds.
  • Not about recompilation: that meaning exists, and the authors call it “only a technical issue”.
  • Fixes: composition with forwarding; designing for extension with one documented protected hook (like .NET’s Collection<T>.InsertItem); or preventing subclassing (sealed, final).

Likely follow-up: “How do you design a class that’s safe to extend?” Document every self-use of overridable methods, keep public methods non-virtual and route them through a few protected virtual hooks, and test the class with subclasses. Or prohibit subclassing.

2. When would you use an abstract class instead of an interface?

Show a strong answer
  • Abstract class: when implementations share state or constructor logic, such as a base class holding a connection and a logger, with a template of steps.
  • Interface: when unrelated types should be usable in the same role, when a type needs several roles, or when you want to keep the one base class free.
  • Since default methods, both can supply method bodies. The remaining difference is instance state, constructors, and single versus multiple.
  • Default: interfaces for roles, plus composition for shared code. Use an abstract class when shared state makes it clearly simpler.

Likely follow-up: “Can you add a method to a published interface?” Yes, with a default implementation in C# 8+ and Java 8+, which keeps existing implementations compiling. Without a sensible default, it’s still a breaking change.

3. How does Go achieve polymorphism without inheritance?

Show a strong answer
  • Interfaces, satisfied implicitly: any type with the methods satisfies the interface, with no declaration. Interfaces can be defined later, by the consumer.
  • Dynamic dispatch only through interfaces: the FAQ says methods on concrete types “are always resolved statically”.
  • Embedding for reuse: an embedded type’s methods are promoted to the outer type, but the receiver stays the inner type, so there’s no overriding and no fragile base class.
  • Generics (Go 1.18+) for type-safe code over many types, such as containers and algorithms.

Likely follow-up: “What’s a gotcha with embedding?” The inner type’s methods can’t call the outer type’s “overrides”, and every promoted method becomes part of the outer type’s API and interface satisfaction.

4. In Rust, when do you use generics and when dyn Trait?

Show a strong answer
  • Generics (T: Trait, impl Trait): static dispatch, monomorphized per type, inlinable. Use them by default, and whenever a collection or call site has one concrete type.
  • dyn Trait: dynamic dispatch through a vtable. Use it for mixed collections, plug-ins chosen at run time, or to avoid many compiled copies of a large generic function.
  • Constraints: a trait must be dyn compatible to be used as dyn: among other rules, no generic methods or methods returning Self that are callable through it, no associated constants, and no Self: Sized requirement.
  • Cost, measured: summing a million shapes through &dyn Shape took about 7.0 times as long as the inlined generic version, in a one-line method. A direct call that wasn’t inlined cost the same, so the loss is inlining, not the vtable lookup.
  • Closed sets: an enum with match took 1.4 times the generic version, and the compiler checks every case.

Likely follow-up: “Why can’t you return impl Trait that’s sometimes one type and sometimes another?” Return-position impl Trait is one concrete type chosen by the function. For several, return Box<dyn Trait> or an enum.

5. Are virtual or interface calls slow?

Show a strong answer
  • The call is cheap; the lost inlining isn’t. In a tight loop with a one-line method, Rust’s trait objects took about 7.0 times the inlined loop in our test, and Go’s interfaces about 4.0 times. A direct call that couldn’t be inlined cost the same in both.
  • Compilers often remove it: HotSpot inlines de facto monomorphic calls, and .NET 8+ uses dynamic PGO to devirtualize, but only where that tier kicks in. In our C# test, the interface took 1.9 times the direct call under the JIT, which didn’t devirtualize this loop, and 1.06 times for one type (1.49 for two) under Native AOT, which could see every implementation.
  • Memory layout matters too: in C#, an array of objects took about 1.9 times an array of structs, before any interface was involved.
  • In real services the method bodies (I/O, allocation, serialization) usually dwarf dispatch. Profile before designing around it.

Likely follow-up: “How would you speed up a hot loop over mixed types?” Group elements by type and call statically per group, use an enum or sealed type with pattern matching, or use generics per homogeneous batch.

6. Explain “favor composition over inheritance” with a concrete design.

Show a strong answer
  • Inheritance version: class CachedRepository extends SqlRepository overriding find to check a cache. It’s tied to SqlRepository‘s internals and can’t wrap any other repository.
  • Composition version: CachedRepository implements the Repository interface and holds any Repository, checking the cache before delegating. It works with SQL, HTTP or in-memory repositories, and its behaviour can be chosen at run time.
  • Gamma’s point: composition still uses inheritance, of a small interface. The advice is against inheriting implementation from big classes.
  • Costs: forwarding code for every method, and one more object.

Likely follow-up: “When is inheritance the right choice?” When a class is designed and documented for extension (a framework base class with explicit hooks), when the subclass truly is a subtype, and when you control both sides.

7. What is the expression problem, and how does it affect choosing interfaces versus enums?

Show a strong answer
  • Wadler, 1998: with a table of cases (rows) and operations (columns), object-oriented designs make new rows easy and new columns hard; functional designs, with sum types, the reverse.
  • Interfaces or traits: open set of types. New implementations need no changes elsewhere; a new operation changes every implementation.
  • Enums or sealed hierarchies with pattern matching: closed set of cases. New operations are new functions; a new case changes every match, and the compiler finds them.
  • Choose by which will grow: plug-in providers grow in types; a document model or an order’s states grow in operations.

Likely follow-up: “Can you have both?” Partly. The visitor pattern, type classes, or combining a sealed core with an interface for extensions each trade one kind of complexity for another. No mainstream approach makes both free.

8. In C# and Java, which defaults make classes safer to design, and which make them riskier?

Show a strong answer
  • C#: methods are non-virtual by default, so a subclass can only override what the author chose. Classes are unsealed by default. sealed closes a class, and the CA1852 analyzer suggests sealing internal types that have no subclasses, for performance.
  • Java: methods are overridable by default, so every public or protected method of a non-final class is an extension point unless marked final. sealed (Java 17) limits which classes may extend a type.
  • Safer design in both: seal or finalize by default; open specific, documented extension points; prefer interfaces plus composition.

Likely follow-up: “Doesn’t sealing everything make testing harder?” Mocking concrete sealed classes is harder, which is a push towards depending on interfaces at the boundaries that need test doubles, the dependency inversion from Part 7.

Sources

What to remember

  • Implementation inheritance couples a subclass to the parent’s internals, including which methods call which. A counting HashSet subclass counted 6 for 3 elements.
  • Prefer composition: implement a small interface and hold what you reuse. Inherit only from classes designed and documented for it.
  • Since default methods, what an abstract class adds over an interface is state, constructors, and protected or non-overridable members.
  • Go’s embedding never calls back into the outer type, so it has no fragile base class. Rust traits and Java and C# default methods can: a default method that calls another trait method is a self-call hook, so document it like one.
  • A function is often the simplest abstraction for a single operation.
  • Dynamic dispatch costs mainly the inlining it prevents. In a tight loop that was several times the inlined loop in Rust and Go, about double under .NET’s JIT in our loop, and far less where Native AOT could see every type. Measure before choosing for speed.
  • Interfaces keep the set of types open; enums and sealed types keep the set of operations open. Choose by which one will grow.

Inherit behaviour you’re promised, never behaviour you happen to observe. Everything else, compose.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.