skip to content

Composition vs Inheritance Mechanics

What changes when you hold a collaborator instead of extending a base: explicit forwarding, no inherited surface, parts swappable at runtime. Interviewers want this contrast before the advice.

on this pageshow

questions

2

The usual textbook claim is that an inheritance relationship is fixed when the class is written, while a contained part can be swapped at run time. In which languages is the first half of that claim false, and what does changing the relationship at run time actually cost?

level: middleimportance: should knowfreq 34%

answer

  1. true in C++/Rust/Go, false in JS/Python/Ruby
  2. Object.setPrototypeOf: shape change, deopt, MDN warns
  3. Python: obj.__class__ per instance, Cls.__bases__ for all
  4. Ruby extend = per-object singleton class
  5. real axis: blast radius and seams, not time

basics

~20 s

In prototype and dynamic-class languages the link itself is mutable: JavaScript's Object.setPrototypeOf, Python reassigning obj.class or Cls.bases, Ruby's per-object extend and reopened classes. The cost is deoptimization and cache invalidation — and, unlike swapping a part, it changes dispatch for everything reaching that class.

solid answer

~50 s

The claim holds in C++, Rust and Go, where the relationship is compiled in; it is simply false elsewhere. - **JavaScript**: every object's prototype link is mutable. `Object.setPrototypeOf` re-parents a live object, but V8 treats it as a shape change, drops inline caches and can put the object in a slow representation — MDN warns against it in hot paths. - **Python**: `obj.__class__ = Other` re-parents one instance (used for lazy-loading proxies and state machines), while `Cls.__bases__ = (...)` changes every instance at once and invalidates the interpreter's method cache. - **Ruby**: `obj.extend(M)` builds a per-object singleton class, so one object gains new ancestors; reopening a class changes all existing instances retroactively. What survives everywhere is the *scope* difference: swapping a composed field changes one object through one named seam, while re-parenting changes dispatch for every call that reaches the type — and other parts' self-calls do not follow the swap.

code

javascript · 8 lines
javascript
class A { who() { return "A"; } }
class B { who() { return "B"; } }

const x = new A();
x.who();                              // "A"
Object.setPrototypeOf(x, B.prototype);
x.who();                              // "B" - re-parented in place
// engines treat this as a shape change: inline caches for x are dropped

go deeper

for a junior

Know that a contained part can be replaced while the program runs, and that in some languages even the parent link can be changed.

for a middle

Name the concrete APIs — Object.setPrototypeOf, obj.class, Cls.bases, obj.extend — and contrast them with Go, Rust and C++ where the relationship is compiled in.

for a senior

Argue the real axis: blast radius, number of independent seams, reachability of self-calls, and the engine costs of invalidating shapes and method caches.

for a principal

Set policy: allow run-time re-parenting only at controlled boundaries such as test doubles or lazy-loading proxies, and require behavioural variation in production code to travel through declared, typed seams.

## The claim, and where it is true 'Inheritance is decided when you write the class; composition is decided when you build the object' is the standard textbook contrast. It is exactly true in statically compiled languages: - **C++**: base classes are part of the object layout; the vptr is set during construction and the class hierarchy cannot change afterwards. - **Rust**: there is no inheritance at all, and trait impls are resolved at compile time (or through a vtable fixed when the trait object is created). - **Go**: embedding is name promotion done by the compiler; there is no run-time API to change what a struct embeds. In these languages, run-time variation of behaviour has exactly one shape: a field of interface, trait-object or pointer type that you assign. That *is* composition, and swapping it is a plain assignment. ## Where the claim is false **JavaScript.** Objects do not have classes in the relevant sense; they have a mutable internal prototype link. `Object.setPrototypeOf(obj, other)` and the legacy `__proto__` setter re-parent a live object, and `class` syntax is sugar over the same machinery, so even class-created objects can be re-parented. Cost: engines optimise property access with hidden shapes and inline caches keyed on those shapes. Re-parenting invalidates them; V8 documents `setPrototypeOf` on an existing object as a deoptimising operation, and MDN advises creating a new object with the desired prototype instead. **Python.** `instance.__class__ = OtherClass` rebinds a single instance to a different class, provided the layouts are compatible. It is not exotic: ORM lazy-loading proxies and state-machine implementations use it to change an object's behaviour without changing its identity. More drastically, `SomeClass.__bases__ = (NewBase,)` alters the hierarchy for every existing and future instance at once. CPython keeps a per-type method cache, and mutating a type invalidates it, so heavily patched code loses cache hits. Class creation also re-runs C3 linearization, which can fail with a `TypeError` if the new bases are inconsistent. **Ruby.** `obj.extend(M)` creates (or reuses) that object's singleton class and inserts `M` into *its* ancestor chain, so exactly one object gains new ancestors while its class is untouched. Reopening a class or `prepend`ing a module changes the ancestor chain for all existing instances retroactively. Ruby invalidates method caches on such changes, which is why hot-path monkey patching is discouraged. **Objective-C and Smalltalk** go further still, allowing an object's class pointer to be swapped (isa-swizzling) — the same idea with even less ceremony. ## What the real, portable distinction is Once you drop the false half, the mechanical differences that survive in every language are about **scope and reachability**, not about time: 1. **Blast radius.** Assigning a composed field changes exactly one object. Changing a class's bases or a prototype changes every object that reaches through it. Per-object mechanisms (Ruby's `extend`, JavaScript's per-object prototypes, Python's `__class__` on one instance) sit in between. 2. **Number of seams.** An object may hold several parts and swap them independently; it has one class or one prototype chain. A strategy field and a formatter field vary orthogonally in a way a single hierarchy cannot express without a combinatorial explosion of subclasses. 3. **Reach.** After swapping a part, self-calls inside the object's *other* parts still go to those parts; they do not route through the new one. After re-parenting, every lookup that misses locally follows the new chain. That is the open-recursion difference showing up again as a run-time property. 4. **Auditability.** A swapped field is a named member with a declared type — you can find its assignments. A mutated class or prototype is action at a distance: the change may be made in an unrelated file, and the object's declared type still says the old thing. ## How to say it in an interview Start by accepting the second half (composed parts are per-object and swappable everywhere) and then correct the first half with two named languages and their concrete APIs. Then move the discussion to the difference that actually holds: composition changes one object through one named seam, re-parenting changes dispatch for everything that reaches the type, and the dynamic languages that allow it charge for it in de-optimised property access and invalidated method caches.

  • If a class's bases or an object's prototype can be changed at run time, why is swapping a composed field still the safer way to vary behaviour?
    Because its blast radius and its location are both bounded. The field is a declared member with a type, so every assignment is greppable and type-checked, and the change affects exactly one object. Re-parenting affects everything that reaches the mutated class or prototype, can be performed from an unrelated module, and leaves the object's declared type describing the old behaviour. The dynamic languages also pay for it in invalidated inline caches or method caches.
  • Why does swapping a part fail to change behaviour that other parts of the same object already trigger internally?
    Because each part's own methods call themselves, not the containing object. If part A's method invokes another of A's methods, replacing part B has no effect on that path, and neither does anything the container added. That is the same lack of open recursion that makes forwarding safe, seen at run time: swapping is per-seam, and only calls that actually route through that seam observe the change.

saying these in an interview costs you the question

  • Asserting flatly that 'inheritance is static' without exceptions, in a discussion that includes JavaScript, Python or Ruby.
  • Treating Object.setPrototypeOf as a free re-parenting operation rather than a de-optimising shape change.
  • Confusing Ruby's per-object `extend` with reopening the class — one affects a single object, the other every instance.
  • Expecting a swapped part to change behaviour that another part triggers through its own internal calls.

context

open as a page

Extending a type takes its entire public surface, including members its author adds in a later release, whereas containing it means you list what you re-expose. What are the mechanical consequences of taking the whole surface, and how do different languages guard that seam when the parent grows a new member?

level: principalimportance: should knowfreq 30%

basics

~20 s

Subtyping copies every current and future parent member into your surface, so a later addition can collide with a member you already had. Kotlin requires the override keyword and reports an accidental override; C# demands new and warns otherwise; Go turns depth-equal promotions into a call-site error; Rust has no promotion at all.

open as a page