skip to content

Fragile Base Class Problem

Why changing a base class can silently break subclasses that leaned on its internals and self-call structure. Interviewers ask it as the technical core of the case against implementation inheritance.

on this pageshow

questions

2

In Go, a struct that embeds another struct gets that struct's methods, yet a promoted method never calls back into a method the outer struct redefines. Name the mechanism Go is missing here, explain why that mechanism is what makes base classes fragile in other languages, and say what Go gives up by not having it.

level: middleimportance: should knowfreq 30%

answer

  1. open recursion = late-bound self-call
  2. the base's internal call graph becomes contract
  3. Go embedding = delegation, inner has no pointer to outer
  4. no open recursion means no template method
  5. CRTP: open recursion resolved at compile time

basics

~20 s

Go embedding has no open recursion: inside a promoted method, the receiver is still the embedded value, so redefining a method in the outer struct shadows rather than overrides. Open recursion — late-bound self-calls — is what turns a base class's internal call graph into part of its contract. Go gives up the template method pattern.

solid answer

~60 s

**Open recursion** means a self-call inside a method is dispatched on the object's runtime type, so the base's private decision about which of its own methods to call becomes an observable contract. - **Java, C#, Kotlin, Python, and C++ virtual methods** have it. Rewriting one base method to route through another silently changes behaviour in every subclass that overrode the second — no signature changed, so nothing warns. - **Go** does not. Promotion is delegation to a named field, and the inner value holds no pointer back to the outer one, so the hazard cannot arise — and neither can template method. You must inject the hook explicitly, as an interface-typed field or a function parameter. - **Rust** has no inherited state, but trait *default* methods do self-call the required methods, so rewriting a default body has the same fragility with a smaller blast radius. - **C++** can rebuild open recursion statically with CRTP: the base is templated on the derived type and casts `this`, so the self-call binds at compile time.

code

go · 10 lines
go
type Inner struct{}

func (Inner) Name() string    { return "inner" }
func (i Inner) Greet() string { return "hi " + i.Name() }

type Outer struct{ Inner }

func (Outer) Name() string { return "outer" }

// Outer{}.Greet() == "hi inner"

go deeper

for a junior

Be able to state that a base method calling another method of the same object may land in a subclass override, and that this is what makes base changes risky.

for a middle

Name open recursion, explain the shadowing-versus-overriding difference in Go embedding, and give one consequence of each.

for a senior

Discuss what it means to publish a self-use contract, and how you would design an extension point that does not depend on one.

for a principal

Position it as a library-evolution policy question: which types may be extended at all, what the documented hooks are, and how you keep internal refactoring from becoming an API change.

## Open recursion, defined "Open recursion" is the name for a small but consequential language feature: inside a method, the receiver (`this`, `self`) is bound to the *actual* object, and calls made through it are dispatched on that object's runtime type. It is what allows a method defined in a base class, when it calls another method of the same object, to end up in a subclass's implementation that the base has never heard of. The recursion is "open" because the set of methods that can participate is open-ended — new subclasses extend it after the base was written and compiled. This is the engine of the template method pattern, and it is also the entire mechanism of the fragile base class problem. ## Why it makes bases fragile Because self-calls are late-bound, a base class's *internal* call structure is observable from outside. Suppose a base offers two operations and, in its first version, each is implemented independently. A subclass overrides one of them, and everything works. Later, a maintainer refactors the base so that the second operation is implemented by calling the first — a change that is invisible in the public signature, has no effect on the base's own behaviour, and passes every test the base has. Every subclass that overrode the first operation now sees the second operation change behaviour too, and the classic version of this ends in double counting or infinite recursion. Nothing in the type system records the dependency. The subclass depended on which of the base's methods called which, and that fact was never part of the declared interface. This is why the standard advice is that a class intended for extension must *document its self-use patterns* — you are publishing an internal call graph as API — and that a class not so designed should forbid extension. ## Go: promotion is delegation Go's embedding looks like inheritance and is not. Writing `type Outer struct { Inner }` gives `Outer` the promoted methods of `Inner`, but the promotion is compiled as forwarding to the embedded field. Inside `Inner`'s method the receiver is the `Inner` value; it has no reference to the `Outer` that contains it. So if `Inner.Greet` calls `i.Name()` and `Outer` declares its own `Name()`, `Greet` still calls `Inner.Name`. `Outer.Name` shadows the promoted one for direct callers, and that is all. The consequence cuts both ways. Go structurally cannot suffer the fragile base class problem through embedding: a change to which of `Inner`'s methods calls which cannot be observed by anything an outer type declares. But Go also cannot express the template method pattern by embedding. If you want a reusable skeleton with a replaceable step, you make the step explicit — embed or hold an interface-typed field that the skeleton calls, or pass a function. The hook becomes a declared, visible part of the type instead of an accident of the implementation, which is the same trade the composition-over-inheritance argument recommends, enforced by the language. ## Rust and C++: two other points on the axis Rust has no implementation inheritance at all, so there is no base class to be fragile. It does have trait **default methods**, whose bodies may call the trait's required methods; those calls dispatch to the implementing type. That is open recursion, and it means changing a default method's body is a semantic change for every implementor that overrode one of the methods it now calls. The blast radius is smaller because traits carry no state and the set of overridable operations is exactly the trait's declared members, but the shape of the hazard is identical. C++ sits at both extremes. With `virtual`, it has ordinary open recursion and ordinary fragility. Without it, methods are statically bound and behave much like Go's promotion. And with CRTP — the curiously recurring template pattern, where the base is a template parameterised on the derived type and casts `this` to it — C++ recovers open recursion *at compile time*: the self-call is resolved statically, there is no virtual dispatch cost, and a mismatch becomes a compile error rather than a silent behaviour change. It is the same expressive power with the binding time moved. ## What to take from the comparison Open recursion is not a mistake; it is a deliberate expressive feature with a specific price. The price is that the base's self-call graph is part of its contract, and contracts that are not written down are broken by ordinary maintenance. Languages have taken three positions: keep it and demand documentation (Java, Python, C++ with virtual); keep it but shrink the surface (Rust trait defaults); or remove it and make every extension point explicit (Go embedding). Knowing which position your language takes tells you immediately whether "refactoring a base class's internals" is a safe local change or a change to published API.

  • How do you express the template method pattern in a language without open recursion?
    Make the hook a declared collaborator instead of an overridable method: the skeleton holds an interface-typed field or takes a function argument, and calls it. In Go that is the idiomatic form — the extension point is visible in the type, so nobody can add or remove one by refactoring an implementation.
  • Rust has no class inheritance, so can a Rust library still break its users the way a base class does?
    Yes, through trait default methods. A default body that calls the trait's required methods dispatches to the implementor, so changing which required methods a default calls changes behaviour for every type that overrode them. The surface is narrower — declared trait members only, and no shared state — but it is the same mechanism.
  • Does CRTP in C++ eliminate the fragile base class problem or only relocate it?
    It relocates it. The self-call still targets the derived type's implementation, so a change to which base methods call which still changes derived behaviour. What changes is the binding time: mismatches such as a missing or wrongly-signed hook become compile errors, and there is no virtual dispatch cost. Silent runtime surprises become loud build failures in some cases, not all.

Open recursion is a manager who delegates a step of their own procedure to whoever currently holds the role. Go embedding is a manager who wrote the step into their own notebook: rearranging their notebook cannot surprise anyone, and nobody can substitute a step either.

saying these in an interview costs you the question

  • Describing Go embedding as inheritance with method overriding
  • Claiming composition eliminates the problem while writing composition in which the delegate calls back into the wrapper
  • Believing the base is safe because its methods are private — private self-calls are exactly the hidden contract
  • Thinking Rust's lack of inheritance means no library can break its implementors
  • Assuming a signature-compatible refactor of a base class is behaviour-preserving for subclasses

context

open as a page

The phrase "fragile base class" is used for two different failures — one about behaviour and one about compiled binaries. Distinguish them, say which platforms suffer which, and explain why the mitigations do not transfer between the two.

level: principalimportance: nice to knowfreq 18%

basics

~20 s

Semantic fragility: a base changes its internal self-call structure and previously-correct subclasses misbehave. Binary fragility: adding a field or virtual to a C++ base shifts object layout and vtable offsets, invalidating already-compiled subclasses. Java, C# and the modern Objective-C runtime resolve layout at load time and avoid the second, not the first.

open as a page