Inheritance and composition6 min · 8 of 16

The fragile base class

See why a base class that calls its own overridable methods breaks correct subclasses, and how protected members and the diamond widen the damage.

A base class that calls its own overridable method has signed a contract with every subclass, and that contract is written nowhere. Change the base — legally, without touching a single signature — and subclasses that were correct yesterday are wrong today. Substitutability, from the previous lesson, asks whether a subclass keeps the parent's promises to callers. This is the other direction: what the parent promises to its children.

The self-call is the contract

A set that counts how many elements were ever offered to it. Both overrides are obviously correct in isolation:

class CountingSet<E> extends HashSet<E> {
    private int added = 0;

    @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);
    }

    int added() { return added; }
}
CountingSet<String> s = new CountingSet<>();
s.addAll(List.of("a", "b", "c"));
s.added();   // 6, not 3

HashSet.addAll is a loop that calls this.add on each element, and this is a CountingSet, so the override runs three more times. The subclass author cannot see that loop; it is an implementation detail of a class they only read the Javadoc for.

The base calling its own overridable method is a contract with the subclass — and it appears in no signature.
A counting set double-counts because the base addAll calls the subclass add, an internal call the subclass author cannot seeaddAll(a, b, c)count += 3super.addAll(...)this.add(x) · 3 timescount += 1,three timesthe callback is the contract, andit is undocumentedsize 3 · count 6CallerCountingSetHashSet · base

Scroll to zoom · drag to pan · 0 fits · Esc closes

Python has the same disease with the opposite symptom:

class Loud(dict):
    def __setitem__(self, k, v):
        print("set", k)
        super().__setitem__(k, v)

Loud().update(a=1)      # prints nothing

CPython's dict.update is C code that writes the hash table directly and never routes back through __setitem__. Java's collection calls back too much; CPython's calls back too little. One cause — whether a base method re-enters itself through dynamic dispatch is unspecified, invisible at the call site, and free to change between versions. collections.UserDict exists because it is written in Python and does route everything through __setitem__.

There are three ways out, and none is free:

  • Document the self-calls. Java's AbstractList does exactly this — "this implementation calls add(size(), e)". It works, and it converts an internal detail into public API you can never change again.
  • Forbid extension. Mark the class final, or sealed with a listed set of permitted subclasses. Now nobody can be broken by a change you make.
  • Forward instead of extend. Hold a Set in a field, implement Set, pass every call through, and count in your own add. addAll on your wrapper calls your add, and the base has no opinion. This is composition, and it costs one forwarding method per interface method.

Protected is a second public API

private is invisible to subclasses. public is visible to everyone. protected is the awkward middle, and it is wider than most people assume: in Java a protected member is visible to every subclass and to every class in the same package.

The arithmetic matters. A class with 6 public methods and 5 protected members has an 11-item contract, not 6. You may not change a protected field's type, when it is written, or its invariant — some subclass depends on all three. The 5 you did not count are also the 5 with no tests, because tests get written against the public surface.

Default every member to private and promote deliberately, with the same seriousness as making something public. In C++ the same rule applies with an extra wrinkle: protected grants access through an object of your own derived type only, not through a sibling's, which surprises people writing binary operators.

The diamond

Class D inherits from B and C, both of which inherit from A. Two questions follow immediately: if B and C both override greet, which one does D get, and if A holds state, does D have one copy of it or two?

C++ answers both, badly and then well. By default D contains two complete A subobjects, so A::x is ambiguous and you write B::x or C::x. Declaring both class B : virtual public A and class C : virtual public A gives one shared A instead, at the price of an indirection to reach it and a constructor rule that catches everyone: the most-derived class D constructs the virtual base, not B or C. Every branch has to opt in: make only B virtual and D still holds two A subobjects, one per branch, with A::x as ambiguous as before.

Python answers with a linearization. Every class has one method resolution order computed by the C3 algorithm, and attribute lookup walks it in order:

class A: pass
class B(A): pass
class C(A): pass
class D(B, C): pass

D.__mro__   # D, B, C, A, object

C3 keeps two guarantees: a class always precedes its parents, and the order parents were listed is preserved. When no order satisfies both, the class definition raises TypeError at definition time rather than picking something arbitrary.

The consequence people get wrong is what super() means. It is not "my parent". It is "the next class in the MRO of the object's actual runtime type", which for B inside a D instance is C, not A. Cooperative multiple inheritance only works if every class in the chain calls super() and accepts and forwards **kwargs. Miss one super() call in a six-class mixin stack — a seven-entry MRO — and everything after it in the chain is silently skipped, with no error anywhere.

Java refuses the question. One superclass, any number of interfaces, so state is never inherited twice. Default methods reopened a narrow diamond: if two interfaces supply the same default method, the implementing class must override it and choose explicitly with Interface.super.method(), or the code does not compile. The ambiguity is pushed to the person who created it, at the moment they create it.

One quieter fragility has no language answer. Add a method to a base class with 12 subclasses. If a subclass already had a method with that name for its own purposes, it is now an override, and the base will call it expecting its own semantics. Java's @Override catches the case where you meant to override and did not; nothing catches the case where you did not mean to and did.

In an interview

Say "fragile base class" and then say the mechanism, because the phrase alone sounds memorised: "the base calls its own overridable method, so the subclass is coupled to the base's internal call sequence, which is not in the signature and can change in a patch release."

Then give the fix in one line — forward to a held instance instead of extending it — and name its cost, one delegating method per interface method. Naming the cost is what separates a considered answer from a slogan.

The mistake that loses points is answering the diamond question with the picture instead of the semantics. Drawing the diamond is not an answer. The answer is: does the language duplicate the base's state or share it, and what rule resolves the method — two subobjects unless virtual in C++, one C3 order in Python, no inherited state and a forced explicit choice in Java.

Check yourself

InstrumentedList extends ArrayList and overrides add to log. A caller uses addAll. What could go wrong, and what does it depend on?

Nothing is logged. ArrayList.addAll never reaches this.add — it System.arraycopys c.toArray() into the backing array — so ten elements land in the list and the log records zero, the CPython dict.update failure mode in Java. The same override on HashSet works, because HashSet inherits the looping AbstractCollection.addAll. Two collections in one JDK, opposite behaviour, neither Javadoc promising either way: whether a base re-enters its own overridable method is undocumented and may change in a patch release, so the subclass is correct only by luck.

A base class exposes a protected field. Name two changes it now blocks.

Changing its type or its representation, and changing the invariant that says when it is valid — every subclass reads and writes it directly, so both are breaking changes. In Java it also blocks changes visible to any class in the same package.

In Python, class D(B, C) where both B and C extend A. B.greet calls super().greet(). Which implementation runs?

C.greet. super() follows the MRO of the actual object's type, which is D, B, C, A, object — so from B, the next entry is C, not A. That is why a class written to be mixed in must call super() even when it looks like it has nothing above it.