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.

The test

QuestionIf 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 meaningless
class 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 be

The 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 for

Mathematically 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 forUse composition for
Exception hierarchiesReusing a capability
Abstract base classes and template methodsBehaviour that can change at runtime
Framework extension pointsWrapping an existing type
A genuine, substitutable "is a"Anything that is really "has a"

Common mistakes

  • Inheriting from list or dict to 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

  1. Rewrite a class that inherits from dict using composition, and list what improved.
  2. Explain the square and rectangle problem in your own words with a concrete failing call.
  3. Design a Notification class that composes a formatter and a delivery channel.
  4. Implement delegation twice: explicitly, then with __getattr__, and compare them.
  5. 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.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Python notes →

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.