Advanced OOP Design

This section documents the current Python release line as published at the official Python documentation, which is the reference these pages are written and verified against. No specific patch version is pinned.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Two objects can be related by composition ("has-a") or inheritance ("is-a"). Python also allows a class to inherit from several parents at once, which makes the order methods are looked up in — the Method Resolution Order — a first-class thing to understand, not an implementation detail.

Composition ("has-a") vs. Inheritance ("is-a")

Inheritance models "a Car is a Vehicle`"; composition models "a `Car has a `Engine`". Prefer composition whenever the relationship is really about reuse of behavior rather than a genuine sub-type — it keeps the two classes independently testable and avoids coupling them through a shared base. The tutorial’s classes chapter covers both attribute lookup and the inheritance mechanics this choice rests on.

# Inheritance ("is-a"): Car really is a kind of Vehicle -- it shares Vehicle's interface.
class Vehicle:
    def __init__(self, speed):
        self.speed = speed

    def describe(self):
        return f"moving at {self.speed} km/h"

class Car(Vehicle):
    pass

# Composition ("has-a"): Car has an Engine -- it delegates to it instead of extending it.
class Engine:
    def start(self):
        return "engine started"

class Car2:
    def __init__(self, engine):
        self.engine = engine   # composed, not inherited

    def start(self):
        return self.engine.start()

print(Car(80).describe())        # moving at 80 km/h
print(Car2(Engine()).start())    # engine started

If Car2 later needs a different engine (electric instead of combustion), swapping the composed object requires no change to Car2 itself — with inheritance, swapping a base class is far more invasive. This is the core argument for "favor composition over inheritance" as a default.

Mixins, Multiple Inheritance, and the MRO

Python lets a class list several bases: class D(B, C):. A mixin is a small base class meant only to be combined this way — it supplies one focused piece of behavior and is never instantiated on its own. Multiple inheritance is documented in the tutorial’s Multiple Inheritance section.

class JSONMixin:
    def to_json(self):
        import json
        return json.dumps(self.__dict__)

class LoggingMixin:
    def log(self, msg):
        print(f"[{type(self).__name__}] {msg}")

class User(JSONMixin, LoggingMixin):
    def __init__(self, name):
        self.name = name

u = User("ada")
u.log("created")          # [User] created
print(u.to_json())        # {"name": "ada"}

The Method Resolution Order (mro)

When several bases could supply the same attribute, Python resolves it using the Method Resolution Order — a single, deterministic ordering of the class and all its ancestors, computed by the C3 linearization algorithm. Inspect it with Cls.mro or Cls.mro(). The algorithm itself is explained in depth in The Python 2.3 Method Resolution Order, which still describes the C3 algorithm CPython uses today.

Take the classic diamond: B and C both extend A, and D extends both B and C.

Diamond inheritance graph where B and C both extend A and D extends both B and C, alongside the resulting D.__mro__ order: D, B, C, A, object
class A:
    def who(self):
        return "A"

class B(A):
    def who(self):
        return "B"

class C(A):
    def who(self):
        return "C"

class D(B, C):
    pass
>>> D.__mro__
(<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)
>>> D().who()
'B'

C3 guarantees that a class always appears before its own bases, and that the declared base order (B before C in class D(B, C)) is preserved — so D is checked first, then B, then C, then their shared ancestor A only once, and finally object.

super() in a Multi-Parent Hierarchy

super() does not mean "my direct parent" — it means "the next class in the MRO after the current one". Each cooperative method calls super().init(…​) and lets the MRO decide who runs next, so every base in the chain gets a chance to run exactly once. This is documented under the built-in super() reference.

class Base:
    def __init__(self):
        print("Base.__init__")

class Left(Base):
    def __init__(self):
        print("Left.__init__")
        super().__init__()   # goes to whatever is next in *this instance's* MRO

class Right(Base):
    def __init__(self):
        print("Right.__init__")
        super().__init__()

class Bottom(Left, Right):
    def __init__(self):
        print("Bottom.__init__")
        super().__init__()
>>> Bottom()
Bottom.__init__
Left.__init__
Right.__init__
Base.__init__
<__main__.Bottom object at 0x...>

Left.init’s `super() calls Right.init, not Base.init directly — because in Bottom’s MRO, `Right sits between Left and Base. Without cooperative super() calls, Base.init would run twice or not at all.

Delegation as an Alternative to Inheritance

Delegation wraps an internal instance and forwards calls to it explicitly, instead of inheriting its interface. It gives full control over which operations are exposed, at the cost of writing the forwarding methods by hand. The standard library’s own collections.UserDict is built this way — it wraps a real dict in a .data attribute rather than subclassing dict directly.

class ReadOnlyList:
    """Wraps a list, exposing only read operations -- no append/remove/etc."""

    def __init__(self, items):
        self._items = list(items)   # composed instance, not inherited

    def __getitem__(self, index):
        return self._items[index]   # forward to the wrapped list

    def __len__(self):
        return len(self._items)     # forward to the wrapped list

    def __iter__(self):
        return iter(self._items)    # forward to the wrapped list

r = ReadOnlyList([1, 2, 3])
print(len(r), list(r), r[0])   # 3 [1, 2, 3] 1
# r.append(4)                  # AttributeError: not forwarded, and that's the point

Delegation trades the automatic interface inheritance gives you for explicit control over the surface area — useful whenever a class should reuse an object’s behavior without exposing everything that object can do.

See Also