skip to content

When two inherited paths supply a body for the same operation, some languages compute a total order over all ancestors and silently pick a winner, while others refuse to compile until the programmer names one. Compare the two resolution schemes and say what each one costs.

level: seniorimportance: should knowfreq 38%

answer

  1. computed order vs named winner
  2. super() = next in MRO, not my parent
  3. C3: local order + monotonic, fails at class creation
  4. Scala linearizes only overriding members
  5. Rust: ambiguity at the call site, not the impl

basics

~20 s

Computed orders (Python's C3, Scala's trait linearization) always produce an answer and let a call chain cooperatively through ancestors the author never named - at the cost of non-local reasoning. Explicit disambiguation (C++, Java, Rust) never surprises you but cannot express stacking and adds boilerplate.

solid answer

~60 s

- **Python** computes a C3 order at class creation. Inside a method, `super()` means *next in the instance class's order*, not "my parent", so a method in one branch can dispatch into a sibling branch it has never heard of. That is what makes cooperative mixins work; it also means a class's behaviour is only readable with the whole ancestry in hand. - **Scala** linearizes traits right-to-left and lets `super` walk that chain, which is exactly the stackable-modification idiom. But Scala only linearizes members related by `override`: two unrelated concrete members with one signature is a compile error, not a silent pick. - **C++** has no chain at all. An ambiguous member access is an error; you qualify (`L::f()`) or make the base virtual, and the target is knowable from the static types alone. - **Java** reports unrelated conflicting interface defaults as an error; the class overrides and may delegate to exactly one named parent with `Loud.super.say()`. - **Rust** allows both trait impls and errors only at the ambiguous call, fixed with `<T as Trait>::m(x)`. Linearization trades locality for composability.

code

python · 9 lines
python
class A:
    def go(self): print("A")
class B(A):
    def go(self): print("B"); super().go()
class C(A):
    def go(self): print("C"); super().go()
class D(B, C): pass

D().go()      # B, C, A  -- one A, chained through the MRO

go deeper

for a junior

Know that a computed order exists in some languages and that others simply refuse to compile, and be able to name one of each.

for a middle

Explain that lookup order is total and deterministic, and that a forwarding call in such a language targets the next entry in that order rather than a fixed parent.

for a senior

Compare on locality versus composability, describe the cooperative-super contract and its silent failure mode, and name the hybrid designs (call-site-only ambiguity, linearize-only-overrides).

for a principal

Reason about the effect on independently evolving libraries: whether adding a mixin can change a superclass's behaviour, whether a conflict is a compile-time or use-site cost, and what each choice implies for API stability.

## The choice a language faces Once two supertypes can each supply a body for the same operation, a language must adopt a policy. There are only three families: (a) refuse, and make the programmer name a winner; (b) compute a deterministic order over all ancestors and take the first match; (c) pick by an ad-hoc rule such as "most recently mixed in". The interesting comparison is between (a) and (b), because they differ not just in ergonomics but in what programs are *expressible*. ## Scheme A - explicit disambiguation **C++** is the canonical example. If `D` inherits `f` through two paths, writing `d.f()` is simply ill-formed; you write `d.L::f()`, or you introduce a using-declaration, or you restructure with a virtual base so only one `f` is inherited. There is no notion of "the next implementation after mine": a class that wants both behaviours calls `L::f(); R::f();` explicitly. The payoff is that you can read a call and know its target from the static type and the class's own declaration - the answer does not depend on some other class that will later inherit from you. **Java** applies the same philosophy to interface defaults, softened by one specificity rule: a sub-interface's default beats its super-interface's, but two unrelated defaults are a compile error. The class must override, and inside the override it can delegate to one named parent via `Interface.super.method()`. Note what is missing - there is no way to say "run whichever default comes after me"; you name a specific interface. **Rust** delays the decision to the point of use. Two traits both providing `m` for a type is perfectly legal and often intentional; only a call site where both are in scope is ambiguous, and it is fixed with fully-qualified syntax `<T as Trait>::m(x)`. This is a nice middle ground: the conflict costs nothing until someone actually writes an ambiguous call. ## Scheme B - computed linearization **Python** computes a C3 linearization at class-creation time from three constraints: a class precedes its ancestors, the declared order of bases is preserved, and the order is monotonic (a superclass's linearization is a subsequence of the subclass's). If no order satisfies all three, `class` statement execution itself raises `TypeError`. Method lookup then walks that single list. The consequential part is `super()`. It does not mean "my parent"; it means "the entry after *me* in the MRO of `type(self)`". So a method in class `B` that calls `super().go()` may land in class `C`, which is not an ancestor of `B` at all - it happens to sit after `B` in the concrete class's order. This is what makes cooperative multiple inheritance work: each mixin does its bit and forwards, and the shared ancestor runs exactly once no matter how many branches reach it. The corresponding obligations are real, though: every participant must accept and forward compatible arguments, and every participant must call `super()` or the chain silently truncates. **Scala** linearizes traits for the same reason and with the same effect: `class C extends A with B` puts `B` before `A`, and `super` inside a trait targets the next trait in the concrete class's linearization, giving the stackable-modification idiom (each trait wraps the previous one). Scala differs from Python in an important safety detail: it only resolves by linearization when the members are related by overriding. Inheriting two *unrelated* concrete members with the same signature is a compile error telling you to write an explicit `override`. So Scala is silent-ordering exactly where the author opted into stacking, and loud everywhere else. ## What each scheme costs Linearization buys **composability**: independently written mixins compose into a pipeline, and the diamond's shared ancestor executes once. It costs **locality**: to know what a call does you need the linearization of the concrete class, which is decided far from the code you are reading, and adding a mixin to a subclass can change the behaviour of a method defined in a superclass. It also produces spooky failure modes - a chain that stops early because one participant forgot to forward. Explicit disambiguation buys **local reasoning** and predictable failure (a compile error rather than a surprising winner). It costs expressiveness: there is no way to write "decorate whatever comes next" without an explicit decorator object, and every conflict, including trivially identical ones, becomes boilerplate in every composing class. ## Answering well The strong answer states that these are not better/worse but different points on a locality-versus-composability axis, gives one language per family with its exact mechanism, and notes the hybrid designs: Rust's call-site-only ambiguity, and Scala's rule of linearizing only what was declared as an override.

  • What obligations does a cooperative-super scheme place on every participating class?
    Each participant must forward with `super()` or the chain stops early and later mixins never run. Each must accept and pass along argument shapes compatible with classes it has never seen, which is why such hierarchies often standardise on keyword arguments. And the root of the chain must not forward past the end. None of this is checked by the compiler, so the contract is social.
  • Scala inherits two traits that each define the same concrete method. What happens, and why is that different from Python?
    It is a compile error about conflicting members unless the members are related by overriding a common declaration, in which case linearization decides and `super` chains. Python has no such check - the MRO always yields a winner, so an accidental name collision between two unrelated mixins silently resolves instead of being reported.
  • Rust lets a type implement two traits that both define a method with the same name. Where does the problem surface?
    Only at a call site where both traits are in scope and the receiver expression cannot disambiguate; the compiler reports multiple applicable items and you write `<T as Trait>::m(x)` or `Trait::m(&x)`. Declaring both impls is not itself an error, which keeps the cost of a name collision proportional to how often it actually bites.

saying these in an interview costs you the question

  • Describing `super()` in a linearizing language as "call my parent's version" - it is the next entry in the concrete class's order, which may be an unrelated branch.
  • Claiming a computed order removes the diamond problem; it only decides method lookup, and it says nothing about duplicated state.
  • Assuming Scala silently picks a winner for any two same-signature members - unrelated concrete members are a compile error.
  • Treating explicit disambiguation as strictly safer while ignoring that it makes stackable decoration impossible without extra objects.
  • Believing a linearization is a property of the class you are reading rather than of the concrete class being instantiated.

context