PYTHON / OBJECT-ORIENTED PYTHON
Choosing composition over inheritance
Decide when subclassing is justified and when to hold an object instead, and build wrappers that expose only the API you intend to support.
What you will learn
- Judge inheritance by the parent's entire public API, not by one convenient method
- Store a collaborator in an attribute and forward only the operations you promise
- Inject collaborators through __init__ so they can be swapped or faked in tests
- Recognise that a composed wrapper fails isinstance checks against the wrapped type
Understanding Choosing composition over inheritance
Subclassing does two things at once: it reuses the parent's implementation and it publishes the parent's entire public interface as your own. The second part is what usually hurts. If you write `class Stack(list)`, you did not just borrow `append`; you also promised `insert`, `sort`, `__setitem__` and `pop(0)`, so any caller can reach past your `push` and destroy the last-in-first-out invariant the class exists to enforce.
Composition inverts the default. You keep the helper object in an attribute and hand-write the methods you want to expose, so the class surface is exactly the set of operations you are willing to support. The extra forwarding methods are not boilerplate for its own sake, they are the mechanism that gives you a chokepoint: every write goes through your code, so validation, logging or locking can never be bypassed.
Composition also decouples the choice of collaborator from the class definition. Because the helper arrives as a constructor argument rather than as a base class chosen at `class` statement time, you can swap it per instance, or even after construction, and pass a fake in tests. That is why three formatters and two data sources need five small classes under composition, but six subclasses if you try to encode every combination in the hierarchy. The cost is that your wrapper is a genuinely new type: `isinstance(wrapper, dict)` is False, and code that type-checks will not accept it.
class StackByInheritance(list):
def push(self, item):
self.append(item)
class StackByComposition:
def __init__(self):
self._items = []
def push(self, item):
self._items.append(item)
def pop(self):
return self._items.pop()
def __len__(self):
return len(self._items)
bad = StackByInheritance()
bad.push("a")
bad.push("b")
bad.insert(0, "cheat") # the whole list API came along for free
print("inherited:", list(bad), "pop ->", bad.pop(0))
good = StackByComposition()
good.push("a")
good.push("b")
print("composed:", len(good), "pop ->", good.pop())
print("has insert?", hasattr(good, "insert"))Inheritance inherits the parent's whole public interface along with its code, so when you only want some of that interface, hold the object instead of extending it.
Worked examples
Swapping a collaborator at runtime
Shows how holding the formatter as an attribute replaces a family of subclasses and can change behaviour after the object exists.
class PlainFormatter:
def render(self, rows):
return "\n".join(f"{name}: {value}" for name, value in rows)
class CsvFormatter:
def render(self, rows):
return "\n".join(f"{name},{value}" for name, value in rows)
class Report:
def __init__(self, rows, formatter):
self.rows = rows
self.formatter = formatter
def show(self):
return self.formatter.render(self.rows)
report = Report([("cpu", 91), ("mem", 40)], PlainFormatter())
print(report.show())
report.formatter = CsvFormatter()
print(report.show())Example explained
Line 1`Report` never names a concrete formatter class, it only calls `render`, so any object with that method fits.
Line 2`formatter` is an ordinary instance attribute, so reassigning it changes behaviour on that one report without touching the class.
Line 3With subclasses (`PlainReport`, `CsvReport`) the choice is fixed at construction, since you cannot change an object's base class per instance.
Line 4A test can pass a stub formatter that records the rows it received, which is impossible when rendering is baked into a base class.
Wrapping a dict instead of subclassing it
Demonstrates a counting wrapper where every read must go through your method, and shows the isinstance trade-off.
class CountingConfig:
def __init__(self, values):
self._values = values
self.reads = 0
def get(self, key):
self.reads += 1
return self._values[key]
def __getattr__(self, name):
return getattr(self._values, name)
config = CountingConfig({"host": "localhost", "port": 8080})
print(config.get("host"), config.get("port"), config.reads)
print(sorted(config.keys()))
print(isinstance(config, dict))Example explained
Line 1`get` is the only read path the wrapper advertises, so the `reads` counter cannot be skipped the way an inherited `dict.get` would skip it.
Line 2`config.keys` is not found on the instance or the class, so `__getattr__` runs and returns the bound `keys` method of the inner dict.
Line 3`config.reads` never reaches `__getattr__`, because `__getattr__` fires only after normal attribute lookup fails.
Line 4`isinstance(config, dict)` is False: composition gives you control over the interface but not subtype status.
Important notes
Use `__getattr__` forwarding sparingly: it also forwards typos and future parent methods, quietly widening the interface you were trying to narrow.
Inheritance is still the right tool when the subclass can genuinely stand in for the parent everywhere, for example subclassing `Exception` or an abstract base class that defines an interface you fully implement.
Common mistakes
Subclassing `list` or `dict` "to get the methods for free", then finding that callers use `insert` or `update` to write values that never pass the overridden `__setitem__`, so the validation the class was written for is silently bypassed.
Forgetting that a wrapper is a new type: code doing `isinstance(obj, dict)` or `json.dumps(obj)` rejects it, and the failure appears far away from the class you changed.
Writing `__getattr__` that reads `self._values` before `__init__` has assigned it, which makes the lookup fail, call `__getattr__` again, and raise `RecursionError` during unpickling or `copy.copy`.
Try it yourself
Change, predict, then run
Take `class Playlist(list)` with an `add` method and rewrite it with composition so that only `add`, `next_track` and `__len__` are public. Then print `hasattr(playlist, "sort")` to confirm the list API is no longer exposed.
Open the Python workspaceCheck your understanding
You subclass `dict` to build a `ConfigStore` that must validate every value written to it, and you override `__setitem__` to do the validation. Why is this still risky?
- Inherited methods such as `update` and `setdefault` can store values without routing through your `__setitem__`, so validation is bypassed
- `dict` cannot be subclassed in Python, so the class definition itself fails
- Overriding `__setitem__` removes the inherited `__getitem__`, so reads stop working
- Subclasses of built-in types are not allowed to define their own `__init__`
Show answer
Subclassing publishes every `dict` write method, and CPython's `update` and `setdefault` operate on the underlying storage rather than calling your `__setitem__`, so a single unguarded call defeats the validation. Option 3 is tempting because overriding feels like replacing, but overriding one method never removes sibling methods; `__getitem__` is still inherited and works normally. A composed store that keeps a private dict and exposes only its own `set` method has exactly one write path.