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.
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
AbstractListdoes exactly this — "this implementation callsadd(size(), e)". It works, and it converts an internal detail into public API you can never change again. - Forbid extension. Mark the class
final, orsealedwith a listed set of permitted subclasses. Now nobody can be broken by a change you make. - Forward instead of extend. Hold a
Setin a field, implementSet, pass every call through, and count in your ownadd.addAllon your wrapper calls youradd, 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.addAllnever reachesthis.add— itSystem.arraycopysc.toArray()into the backing array — so ten elements land in the list and the log records zero, the CPythondict.updatefailure mode in Java. The same override onHashSetworks, becauseHashSetinherits the loopingAbstractCollection.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 fromB, the next entry isC, notA. That is why a class written to be mixed in must callsuper()even when it looks like it has nothing above it.