Composition, Delegation and Design Choices
Inheritance says "is a"; composition says "has a". Most of the time the honest answer is "has a", and the resulting design is easier to change.
- Basics
- Data Types
- Operators
- Strings
- Control Flow
- Lists
- Tuples
- Sets
- Dictionaries
- Comprehensions
- Functions
- Advanced Functions
- Recursion
- Exception Handling
- File Handling
- Modules
- Standard Library
- OOP
- Advanced OOP
- Iterators and Generators
- Decorators
- Context Managers
- Descriptors and Dataclasses
- Python Internals
- Concurrency
- Regular Expressions
- Serialization
- Command Line Python
- Testing and Debugging
- Type Hints
- Performance
- Python Security
- DSA with Python
The test
| Question | If yes |
|---|---|
Is a Manager an Employee? | Inheritance |
Does a Car have an Engine? | Composition |
| Can the subclass be used anywhere the parent is expected? | Inheritance is safe |
| Do you only want to reuse some code? | Composition |
Where inheritance goes wrong
class Stack(list):
def push(self, item):
self.append(item)
s = Stack()
s.push(1)
s.push(2)
# Everything list can do is now part of the Stack interface
s.insert(0, 99) # jumped the queue
s.reverse() # reversed the whole stack
s[0] = 5 # arbitrary edit
print(s) # the stack rules are meaninglessclass Stack:
"""A stack that HAS a list, rather than IS one."""
def __init__(self):
self._items = []
def push(self, item):
self._items.append(item)
def pop(self):
if not self._items:
raise IndexError("pop from an empty stack")
return self._items.pop()
def peek(self):
if not self._items:
raise IndexError("peek at an empty stack")
return self._items[-1]
def __len__(self):
return len(self._items)
def __bool__(self):
return bool(self._items)
def __repr__(self):
return f"Stack({self._items!r})"
s = Stack()
s.push(1)
s.push(2)
print(s.pop(), len(s), s)
# s.insert(0, 99) # AttributeError - the interface is exactly what it should beThe composed version exposes four operations. There is no way to misuse it, and the internal list can be swapped for a deque without any caller noticing.
The classic square and rectangle
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Square(Rectangle): # a Square IS a Rectangle, mathematically
def __init__(self, side):
super().__init__(side, side)
def __setattr__(self, name, value):
"""A square must keep both sides equal."""
super().__setattr__(name, value)
if name == "width":
super().__setattr__("height", value)
elif name == "height":
super().__setattr__("width", value)
def stretch(rectangle):
"""Double the width and report the new area. Any Rectangle should survive this."""
rectangle.width = rectangle.width * 2
return rectangle.area()
r = Rectangle(3, 3)
print(stretch(r)) # 18 - width 6, height 3, exactly as promised
s = Square(3)
print(stretch(s)) # 36 - the height changed too, which the caller never asked forMathematically a square is a rectangle. In code, a Square cannot honour everything a Rectangle promises - two independently settable sides - so it is not a valid substitute. stretch was written against Rectangle and is silently wrong for Square. The relationship to check is not "is a" in English, but "can it be used everywhere the parent can".
Composition
class Engine:
def __init__(self, horsepower, fuel):
self.horsepower = horsepower
self.fuel = fuel
def start(self):
return f"{self.horsepower}hp {self.fuel} engine running"
class GPS:
def __init__(self):
self.destination = None
def navigate(self, to):
self.destination = to
return f"routing to {to}"
class Car:
def __init__(self, model, engine, gps=None):
self.model = model
self.engine = engine # HAS an engine
self.gps = gps # optionally HAS a GPS
def start(self):
return f"{self.model}: {self.engine.start()}"
def drive_to(self, place):
if self.gps is None:
return "no navigation fitted"
return self.gps.navigate(place)
basic = Car("Hatch", Engine(80, "petrol"))
premium = Car("Sedan", Engine(150, "diesel"), GPS())
print(basic.start())
print(basic.drive_to("Pune"))
print(premium.drive_to("Pune"))The engine can be replaced at runtime, tested on its own, and reused in a Boat. Inheritance could not have given any of that.
Delegation
class Logger:
def __init__(self):
self.entries = []
def log(self, message):
self.entries.append(message)
return message
class Service:
def __init__(self):
self._logger = Logger()
def log(self, message): # explicit delegation
return self._logger.log(message)
@property
def entries(self):
return self._logger.entries
def run(self):
self.log("service started")
return "done"
s = Service()
s.run()
print(s.entries)class Wrapper:
"""Forward anything unknown to the wrapped object."""
def __init__(self, wrapped):
self._wrapped = wrapped
def __getattr__(self, name):
return getattr(self._wrapped, name) # called only on failure
def extra(self):
return "added behaviour"
w = Wrapper([3, 1, 2])
print(w.extra())
print(w.count(1)) # delegated to the list
w.sort()
print(w._wrapped)Automatic delegation with __getattr__ is powerful and blunt: it forwards everything, including methods you would rather not expose. Prefer writing the handful of methods you actually mean to offer.
Strategy: composing behaviour
class Sorter:
"""Sorting behaviour is passed in rather than inherited."""
def __init__(self, strategy):
self.strategy = strategy
def sort(self, items):
return self.strategy(items)
by_length = lambda items: sorted(items, key=len)
alphabetical = lambda items: sorted(items)
reverse_alpha = lambda items: sorted(items, reverse=True)
words = ["banana", "fig", "apple"]
for strategy in [by_length, alphabetical, reverse_alpha]:
print(Sorter(strategy).sort(words))An inheritance based version would need one subclass per strategy, and could not change strategy at runtime.
A design worked through
from abc import ABC, abstractmethod
# --- small, focused pieces ---------------------------------------------------
class Formatter(ABC):
@abstractmethod
def format(self, records):
...
class PlainFormatter(Formatter):
def format(self, records):
return "\n".join(f"{r['name']}: {r['score']}" for r in records)
class CsvFormatter(Formatter):
def format(self, records):
header = "name,score"
rows = [f"{r['name']},{r['score']}" for r in records]
return "\n".join([header, *rows])
class Destination(ABC):
@abstractmethod
def write(self, text):
...
class ConsoleDestination(Destination):
def write(self, text):
print(text)
class MemoryDestination(Destination):
def __init__(self):
self.contents = []
def write(self, text):
self.contents.append(text)
# --- the class that composes them --------------------------------------------
class Report:
def __init__(self, records, formatter, destination):
self.records = records
self.formatter = formatter
self.destination = destination
def publish(self):
self.destination.write(self.formatter.format(self.records))
records = [{"name": "Meera", "score": 92}, {"name": "Arun", "score": 78}]
Report(records, PlainFormatter(), ConsoleDestination()).publish()
print("---")
Report(records, CsvFormatter(), ConsoleDestination()).publish()
store = MemoryDestination()
Report(records, CsvFormatter(), store).publish()
print(store.contents)Two formatters and two destinations give four combinations from four small classes. An inheritance hierarchy would have needed four classes for four combinations, and adding a third formatter would have meant three more.
When inheritance is the right answer
class HTTPError(Exception):
"""Base for every HTTP failure."""
status = 500
def __init__(self, message=None):
super().__init__(message or self.__doc__)
class NotFound(HTTPError):
"""The resource does not exist."""
status = 404
class Forbidden(HTTPError):
"""Access is not permitted."""
status = 403
for error in [NotFound(), Forbidden(), HTTPError()]:
print(error.status, error)
try:
raise NotFound()
except HTTPError as error:
print("caught as HTTPError:", error.status)Exception hierarchies are the clearest case for inheritance: the "is a" relationship is exact, substitutability is the whole point, and the shared behaviour is genuinely shared.
| Use inheritance for | Use composition for |
|---|---|
| Exception hierarchies | Reusing a capability |
| Abstract base classes and template methods | Behaviour that can change at runtime |
| Framework extension points | Wrapping an existing type |
| A genuine, substitutable "is a" | Anything that is really "has a" |
Common mistakes
- Inheriting from
listordictto get a few methods. - Building a hierarchy four or five levels deep.
- Subclassing to reuse one helper method.
- Overriding a method in a way that breaks the parent's promises.
- Using
__getattr__delegation and exposing everything by accident. - Adding an abstraction layer before there are two implementations.
Best practices
- Default to composition; reach for inheritance when substitutability genuinely holds.
- Keep hierarchies at most two or three levels deep.
- Delegate explicitly, one method at a time, so the interface is deliberate.
- Pass behaviour in as an object or a function when it varies.
- Wait for the second implementation before extracting an abstraction.
Practice
- Rewrite a class that inherits from
dictusing composition, and list what improved. - Explain the square and rectangle problem in your own words with a concrete failing call.
- Design a
Notificationclass that composes a formatter and a delivery channel. - Implement delegation twice: explicitly, then with
__getattr__, and compare them. - Take a three level hierarchy and flatten it to one class plus composed parts.
Conclusion
Ask whether the new class can be used everywhere the old one can. If yes, inheritance is safe. If you only want the code, compose. Small objects passed to each other bend where a hierarchy would have to be rebuilt.