Reusing behaviour through inheritance
Open in the interactive app: practice, quiz, mock interview →In one minute
- Inheritance lets a class start from another class's fields and methods and add or replace some. It bundles two things: code reuse and a subtype relationship.
- The reuse is the risky half. A subclass depends on how its parent calls its own methods, so a harmless refactor of the parent can break children it has never seen: the fragile base class problem.
- Inherit when the child is a substitutable kind of the parent and the parent was designed for extension. Otherwise compose: hold the other object and forward to it.
- Single inheritance is cheap at runtime: parent fields come first so offsets never change, and dispatch is a table index or a cached lookup. Multiple inheritance needs a linearisation rule (C3 in Python) or pointer adjustment (C++).
- Agents reach for a
BaseHandlerto share three lines. Ask what the child must not override and who owns the base before accepting it.
How it works
Two things in one keyword
class Savings(Account) says two things. Reuse: Savings gets Account's fields and method bodies. Subtyping: code that accepts an Account now accepts a Savings. You can want one without the other, but most languages bundle them. The subtype half is governed by the Liskov Substitution Principle (its own lesson); this lesson focuses on reuse, where most inheritance bugs come from.
Overriding and open recursion
When a parent calls one of its own overridable methods, the call dispatches to the child's version if one exists (Java, C#, Python, JavaScript, C++ virtual). This is open recursion; it makes the Template Method pattern work, and it makes the parent's internal call graph part of its contract. Consider a key-value store and a subclass that records every write for compliance:
class Store:
def __init__(self):
self._data = {}
def put(self, key, value):
self._data[key] = value
def put_many(self, pairs):
for k, v in pairs:
self.put(k, v) # self-call: dispatches to subclass
class AuditedStore(Store):
def __init__(self, log):
super().__init__()
self.log = log
def put(self, key, value):
self.log.append(key)
super().put(key, value)AuditedStore overrides only put and still audits put_many, because the parent happens to loop over put. No signature says so.
The fragile base class
Months later the storage team speeds up put_many with self._data.update(pairs). Every Store test passes, and AuditedStore silently stops auditing bulk writes. An implementation detail changed and a subclass in another repository broke. That is the fragile base class problem, and the main reason "favour composition over inheritance" became standard advice. A wrapper that holds a Store and implements both methods cannot be broken this way.
Designing a class to be extended
If you do want subclasses, design for them on purpose:
- Declare the extension points. Make most methods
final(Kotlin does this by default) and expose a few hooks with documented call timing. - Document self-use. If
put_manycallsput, say so, and treat changing it as breaking. - No overridable calls from constructors. In Java and C# the override runs before the child's fields are initialised.
- Keep it shallow. Five levels means reading five files to know what one method does.
Multiple inheritance and method order
With two parents that both define save, the language needs a rule. C++ makes you qualify the call (A::save) and, for a shared grandparent, choose virtual inheritance or get two copies. Python computes a method resolution order with C3: one list in which every class precedes its parents and each declared base order is kept. If no such list exists, Python refuses to create the class. The practice task implements C3.
Go has no inheritance. Embedding promotes a struct's methods to the outer type, but the embedded type's self-calls always reach its own methods. Go keeps reuse and drops open recursion, removing the fragile base class problem by construction.
Reviewing agent-written code
Asked to remove duplication, agents produce a BaseHandler full of if self.kind == ... branches, or chains like AuthedRetryingClient. Flag overrides that only disable a method, isinstance checks in the base, and depth beyond two. Ask for a helper function or a composed collaborator.
The same shape elsewhere
A fragile base class is the trust but verify shape: a consumer relies on behaviour the provider never promised. Configuration inheritance fails the same way, when a base Helm values file or Terraform module changes a default under every environment that extends it. The fix is the same: narrow, documented extension points and tests at the consumer.
Down the stack
Layer 1The contract
A subclass instance is accepted wherever the parent type is, with every inherited member reachable. Overrides are chosen by the receiver's runtime type, including on the parent's self-calls. Nothing guarantees the parent's internal call graph between versions; it is only as stable as its documentation.
Layer 2The mechanism
C++ and Java. A class's fields follow its parent's, so a parent field has the same offset in every subclass and a child pointer is a valid parent pointer. The method table keeps inherited methods in their parent's slots and appends new ones. Under the Itanium C++ ABI the vtable pointer sits at offset 0. With multiple inheritance the second base lives at a non-zero offset, so converting Child* to Base2* adds a constant, and calls through Base2 go via "thunks" that adjust this back.
Python. At class creation type computes __mro__ with C3 and stores it as a tuple. Attribute lookup walks it, checking each class's __dict__, and CPython caches results per (type version tag, name); assigning a class attribute bumps the tag so stale entries miss. Zero-argument super() continues the search in type(self).__mro__ after the current class, which is why super() in a mixin can reach a sibling rather than the parent.
JavaScript. class Child extends Parent links Child.prototype to Parent.prototype, and Child to Parent so statics are inherited. A derived constructor must call super() before using this, because the base constructor allocates the object. V8 caches lookups per call site by hidden class, with "validity cells" that invalidate the cache when anything on the prototype chain changes.
Go. An embedded struct is a field stored inline, plus compiler-generated forwarding methods on the outer type. There is no override table because nothing can be overridden.
Layer 3The machine
Inherited field access costs nothing extra in C++, Java or Go: one load at a fixed offset regardless of depth. Size is every level's fields plus padding. On 64-bit HotSpot with compressed class pointers the header is 12 bytes and objects round up to 8, so five levels adding one int each make a 32-byte object. A polymorphic C++ object pays 8 bytes for one vtable pointer. A CPython method-cache miss probes one dictionary per class in the MRO, roughly tens of nanoseconds for a deep hierarchy, and monkey patching classes at runtime keeps invalidating the cache. In V8, call sites that see many hidden classes fall back to generic lookups, often several times slower than a cached load.
Why it's built this way
- Code compiled against the parent must work on unseen children, so parent fields go first and keep their offsets.
- Children must change behaviour, so calls go through a per-class table or lookup, not a direct jump.
- Self-calls use the same table so hooks work, which exposes the parent's call graph and makes base classes fragile.
- Multiple parents make "which method wins" ambiguous, so languages forbid it (Java classes, Go), push it to the programmer (C++), or define a total order (C3).
Diagram
flowchart TD C["obj.save() on AuditedStore"] --> K["read type(obj)"] K --> M["method cache: version tag + 'save'"] M -- "hit" --> F["call cached function"] M -- "miss" --> W["walk __mro__"] W --> A1["AuditedStore.__dict__"] A1 -- "not found" --> A2["Store.__dict__"] A2 -- "not found" --> A3["object.__dict__"] A1 -- "found" --> S["store in cache, call"] A2 -- "found" --> S
Worked example
A payments platform runs AuditedStore at 40,000 writes per second, 30 percent of them through put_many from a reconciliation job. The storage team rewrites put_many to call dict.update directly, cutting bulk inserts from 1.8 to 0.4 microseconds per pair. Their tests pass.
Audit volume falls 30 percent overnight. Nothing alerts on it, so the gap surfaces 19 days later in a control review: 12,000 writes per second for 19 days, about 20 billion unaudited writes to reconstruct from database change logs.
The fix: AuditedStore became a wrapper implementing both methods, plus a compliance-owned contract test asserting that writes through every public method reach the audit log.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Implementation inheritance | Little code, natural for true "kind of" | Fragile base class, hidden behaviour | Parent designed for extension, one owning team |
| Composition and forwarding | Robust to inner changes, swappable | Boilerplate forwarding | Reusing a class you do not own |
| Interface plus shared helper functions | Subtyping without inherited state | Helpers must be called explicitly | Several types share behaviour but no data |
| Mixins (multiple inheritance) | Small reusable behaviour slices | Order-dependent, super() surprises | A few well-documented Python or Scala mixins |
Pitfalls in production
- A subclass relies on a self-call the parent later removes. Fix: compose, or have the parent document and test self-use.
- An overridable method called from a constructor reads child fields that are still null. Fix: no overridable calls during construction; use a factory.
- A subclass overrides a method to throw "not supported", crashing code that holds the parent type. Fix: it is not a subtype; split the interface or compose.
- A shared base class gains a method whose name collides with one in a team's subclass, silently overriding it. Fix: mark overrides explicitly (
@Override, TypeScriptoverridewithnoImplicitOverride,typing.override) so tools flag accidental ones. - Python mixins listed in the wrong order, so
LoggingMixin.savenever runs. Fix: assert the expected__mro__in a test.
Leadership lens
- Treat a base class used by other teams as a public API, self-calls included. Changing it needs the review and release notes of an HTTP endpoint change.
- In design review ask: is the child substitutable, who owns the parent, and which methods are meant to be overridden? If nobody can answer the last, compose.
- Set a depth budget, such as two levels of implementation inheritance in application code, enforced by a lint rule rather than taste.
- Make classes final or sealed by default. Opening a class later is cheap; closing one others extend is a migration.
Check yourself
Why can a performance change to a parent class break a subclass even though no public signature changed?
Show answer
The subclass relied on the parent calling an overridable method internally. Self-calls dispatch to the child, so the change removed a call the child depended on.
Why does a field inherited through five levels cost no more to read in Java than a field declared on the class itself?
Show answer
Parent fields come first and keep a fixed offset in every subclass, so the JIT emits one load at a constant offset.
Python refuses to create
class Z(X, Y)whenX(A, B)andY(B, A). Why?Show answer
C3 keeps each declared base order.
Xneeds A before B andYneeds B before A, so no order satisfies both.Why does Go struct embedding not suffer from the fragile base class problem?
Show answer
An embedded type's self-calls always reach its own methods, never the outer type's. The outer type cannot hook into the inner type's internals, so the inner type's refactors cannot break it that way.
Watch
- The Flaws of Inheritance (CodeAesthetic): a short, concrete case for composition over inherited code.
- Inheritance Is the Base Class of Evil (Sean Parent, GoingNative 2013): polymorphic behaviour in C++ without exposing a hierarchy to users.
Free resources
- The Python 2.3 Method Resolution Order (Michele Simionato): the original C3 explanation, including hierarchies Python rejects.
- Inheritance and the prototype chain (MDN): how
extendslinks prototypes and lookup walks the chain. - Effective Go: Embedding: reuse without subclassing or overriding.
- Fragile base class (Wikipedia): background and the language features that reduce it.
- Itanium C++ ABI: the object layout rules GCC and Clang use for every kind of inheritance.