skip to content

When an override calls "the parent version" of a method, languages disagree about which method that actually is. Compare how Python's super(), Ruby's super, Scala's stackable traits and C++'s explicit base qualification pick the target, and what breaks if you assume it is always the lexically named base.

level: middleimportance: should knowfreq 38%

answer

  1. super = next in MRO, not lexical base
  2. C3 linearization; D(B,C) -> B's super hits C
  3. Ruby prepend inserts before the class
  4. Scala abstract override = stackable traits
  5. C++ has no super: Base::f()

basics

~20 s

Python, Ruby and Scala resolve the parent call dynamically along the receiver's linearized ancestor list, so the target can be a class the caller never mentions. C++ has no super and forces a statically named base. Java, C# and Kotlin bind super to one fixed base.

solid answer

~50 s

Two families: - **Linearized (dynamic) super.** Python's `super()` means "the next class after mine in `type(self).__mro__`", computed by C3 linearization. Inside `B.go`, `super()` can land on `C` when the instance is a `D(B, C)` -- a class `B` never imports. Ruby is the same over `ancestors`, and `Module#prepend` inserts a module *before* the class, so a third-party gem can put itself in your super chain. Scala linearizes traits right-to-left and `abstract override` lets a trait modify a method it does not implement; its `super` target is unknown until mix-in. - **Statically named.** C++ has no `super` at all: you write `Base::f()`, fixed at compile time. Java, C# and Kotlin bind `super.f()` to the single declared base (Java needs `Interface.super.f()` to disambiguate default methods). The first family buys cooperative multiple inheritance and stackable decorators; it pays with an argument protocol every participant must honour and an ancestor order that is part of your public API.

code

python · 13 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
print([c.__name__ for c in D.__mro__])   # D B C A object

go deeper

for a junior

Know that some languages compute the parent call from a linearized ancestor list rather than the class you wrote, and that C++ makes you name the base yourself.

for a middle

Be able to trace the MRO of a small diamond and predict which method a super() call reaches, and explain why arguments must be forwarded in a cooperative chain.

for a senior

Discuss the failure modes -- chain truncation, order-as-API, third-party interposition via Ruby's prepend -- and set a convention for cooperative versus terminal classes in a codebase.

for a principal

Weigh whether a codebase should use stacking at all: linearized super is powerful for cross-cutting composition but makes ancestor order a versioned public contract, which is a serious constraint on a library published to strangers.

## The question hidden inside "call the parent" Every language with overriding needs a way for an override to reuse the implementation it replaced. The syntax looks the same everywhere (`super`, `base`, `Base::f`), but underneath there are two genuinely different designs, and the difference is invisible until a hierarchy stops being a straight line. ## Family 1: the target is fixed at compile time In C++ there is no `super` keyword. You write the base name yourself: `Shape::draw()`. Because you named it, the target cannot change, and if the hierarchy is later reshaped the code either still compiles against the same base or fails loudly. The cost is textual duplication: rename or re-parent a class and every qualified call must be edited. C++ adds one twist of its own -- with *virtual inheritance* a shared base is initialized by the **most-derived** class, not by the intermediate class that lexically inherits it, so even in C++ "who runs the base constructor" is not the lexical parent. Java, C# and Kotlin sit in the same family: `super.f()` / `base.F()` denotes exactly one method, chosen by the compiler from the single declared superclass. Java needed a special form, `Interface.super.f()`, the moment interfaces gained default method bodies, precisely because with more than one candidate the language refuses to guess. ## Family 2: the target is the next link in a runtime chain Python computes, for every class, a **method resolution order** (MRO): a single list of ancestors produced by C3 linearization. C3 guarantees that a class precedes its own bases and that the relative order of bases is preserved; when no such order exists the class definition itself fails at import time. A bare `super()` inside a method of class `B` does not mean "my base" -- it means "the class that follows `B` in `type(self).__mro__`". Given `A`, then `B(A)` and `C(A)`, then `D(B, C)`, the MRO of `D` is `D, B, C, A, object`. Calling `D().go()` enters `B.go`, whose `super().go()` reaches **`C.go`**, not `A.go`. `B` and `C` may live in different libraries and know nothing about each other. This is the intended feature, called *cooperative multiple inheritance*: each class does its part and delegates onward, and the concrete class at the bottom decides the composition order. Ruby works the same way over `ancestors`, with the extra wrinkle that modules can be `include`d (inserted after the class) or `prepend`ed (inserted **before** it). A prepended module's method runs first and its `super` reaches the class's own definition -- the standard Ruby technique for wrapping, instrumentation and monkey-patched aspects. It also means an unrelated gem can silently interpose itself in your call chain. Scala linearizes traits right-to-left, so `class X extends A with T1 with T2` resolves `super` inside `T2` before `T1`. With `abstract override`, a trait may call `super.f()` for a method it does not itself implement, which is how *stackable modifications* are built; the trait cannot be mixed into anything that does not eventually supply a concrete `f`. ## What actually breaks - **Argument protocol.** In a cooperative chain every participant must accept and forward compatible arguments, because it may be called by a class it has never heard of. Python's convention is `*args, **kwargs` plus "always call super", including in `__init__`. - **Silent chain truncation.** One class that overrides without calling `super` cuts off everything after it in the MRO. In a linear hierarchy that means "the base did not run"; in a linearized one it can mean an unrelated sibling's contribution vanished. - **Order is API.** Swapping `D(B, C)` to `D(C, B)` changes behaviour without changing any method body. Ancestor order is a real, breakable part of the contract. - **False intuition transfer.** Developers arriving from the statically-bound family read `super()` as "my base" and write chains that double-apply or skip work when a second mixin appears. ## Choosing If you are designing in a linearized language, decide explicitly whether a class is *cooperative* (accepts arbitrary neighbours, forwards everything) or *terminal* (deliberately stops the chain), and document which. If you are in the statically-bound family, do not try to emulate stacking through deep inheritance -- the language will not thread the chain for you; wrap instead. And in any language, treat the ancestor list as something users can observe: in Python it is literally a public attribute, `__mro__`.

  • Why does Python's super() take no arguments in modern code, and what is it doing behind the scenes?
    The zero-argument form is compiler sugar: it captures the enclosing class and the instance and looks up the class that follows the enclosing class in type(self).__mro__. The explicit form super(B, self) says the same thing manually, which is why it is still needed outside a class body or when you deliberately want to start the search from somewhere else.
  • In a linearized language, how would you write a mixin that is safe for others to compose with?
    Accept and forward arguments transparently (*args/**kwargs in Python), always call the next implementation rather than returning early, do not assume what comes after you, and make the mixin's contribution order-independent where possible. Document explicitly whether the mixin is terminal -- a class that deliberately does not forward should say so, because it silently deletes every later contributor.
  • What does Java's Interface.super.f() syntax tell you about the language's stance?
    It tells you Java refuses to pick a winner when two inherited default bodies are candidates: the class must resolve the ambiguity by naming the interface explicitly. Java deliberately avoided adopting a linearization rule when default methods arrived, keeping super a single statically known target and pushing the decision to the author.

Statically named super is a phone number you dialled yourself; linearized super is a call queue -- you press transfer and whoever the receptionist put next in line picks up, possibly someone you have never met.

saying these in an interview costs you the question

  • Assuming super() in Python always reaches the class written in the parentheses of the class statement
  • Believing multiple inheritance is resolved left-to-right depth-first everywhere (that was old Python, replaced by C3)
  • Claiming C++ has a super keyword, or that its virtual base is constructed by the intermediate class
  • Thinking mixin order is cosmetic rather than a behavioural contract
  • Writing a cooperative chain where one class swallows the call and not realising later contributors are skipped

context