Inheritance, MRO, and super()

Single vs multiple inheritance, C3 linearisation, cooperative super, and why composition usually wins.

๐Ÿงฑ Module 2 8 min read Not started

Why this matters

Used well, inheritance reduces duplication and clarifies hierarchies. Used poorly, it produces unmaintainable diamond-shaped messes. This lesson teaches when to inherit, when to compose, how Python resolves multiple inheritance, and how Abstract Base Classes formalise "interfaces."

Learning objectives

  1. Use single inheritance correctly with super().
  2. Read and explain Method Resolution Order (MRO).
  3. Use multiple inheritance and mixins responsibly.
  4. Define abstract base classes with abc.
  5. Know when to compose rather than inherit.

1. Single inheritance

python
class Animal:
    def __init__(self, name: str) -> None:
        self.name = name

    def speak(self) -> str:
        return "..."

class Dog(Animal):
    def speak(self) -> str:
        return "Woof"

class Cat(Animal):
    def __init__(self, name: str, indoor: bool = True) -> None:
        super().__init__(name)           # delegate to parent
        self.indoor = indoor

    def speak(self) -> str:
        return "Meow"

super() finds the parent in the MRO and calls into it. Always use it; never call Animal.__init__(self, name) by name (breaks under multiple inheritance).

isinstance, issubclass

python
d = Dog("Rex")
isinstance(d, Dog)             # True
isinstance(d, Animal)          # True (subclasses count)
issubclass(Dog, Animal)        # True
isinstance(d, (Dog, Cat))      # tuple of options

In type hints, prefer Protocol over isinstance for duck typing (Phase 2.4 + 3.5).


2. Method Resolution Order (MRO)

When you call obj.method(), Python walks the MRO until it finds a definition.

python
class A:                    pass
class B(A):                 pass
class C(A):                 pass
class D(B, C):              pass

D.__mro__
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)

CPython uses C3 linearisation to compute MRO. The rules guarantee:

  • A subclass appears before its parents.
  • Left-to-right order in the bases list is preserved.
  • The MRO is consistent (no contradictions).

If you write an inconsistent hierarchy, Python raises TypeError: Cannot create a consistent method resolution order. Restructure.


3. super() and cooperative multiple inheritance

python
class Loggable:
    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)        # crucial
        print(f"Created {self}")

class Saveable:
    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        self._dirty = False

class Document(Loggable, Saveable):
    def __init__(self, title: str) -> None:
        super().__init__()
        self.title = title

The pattern:

  • Every class in the chain accepts *args, **kwargs and calls super().__init__(*args, **kwargs).
  • Document.__init__ starts the chain: super().__init__() walks Loggable โ†’ Saveable โ†’ object.

This is cooperative multiple inheritance: each mixin participates without knowing the others.

Without super(), only the leftmost base would initialise. With super(), every base does.


4. Mixins โ€” small classes that add a feature

Mixins are classes designed to be mixed into others, not used alone.

python
class JsonSerialisableMixin:
    def to_json(self) -> str:
        import json
        return json.dumps(self.__dict__)

class TimestampMixin:
    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        from datetime import datetime
        self.created_at = datetime.now()

class User(JsonSerialisableMixin, TimestampMixin):
    def __init__(self, name: str) -> None:
        super().__init__()
        self.name = name

u = User("Ada")
u.to_json()                      # '{"created_at": "2026-...", "name": "Ada"}'

Mixin conventions:

  • Name ends with Mixin.
  • Doesn't define __init__ (or chains via super()).
  • Each mixin adds one capability.

Pythonic alternative for many "mixin" cases: just a function, or composition.


5. Abstract Base Classes (ABCs)

Mark a class as abstract โ€” you can't instantiate it directly, and subclasses must implement specified methods.

python
from abc import ABC, abstractmethod

class Storage(ABC):
    @abstractmethod
    def save(self, key: str, value: bytes) -> None: ...

    @abstractmethod
    def load(self, key: str) -> bytes: ...

class S3Storage(Storage):
    def save(self, key: str, value: bytes) -> None:
        ...
    def load(self, key: str) -> bytes:
        ...

# Storage()        # TypeError: can't instantiate abstract class
# class Bad(Storage): pass; Bad()    # TypeError: missing abstract methods

Use ABCs for:

  • Plugin interfaces.
  • "All implementations must provide these methods."
  • Pre-Python-3.8 way to express interfaces (now usually replaced by Protocol, see Phase 2.4).

abc.abstractproperty, abc.abstractclassmethod exist but are deprecated; combine @property / @classmethod with @abstractmethod:

python
class Shape(ABC):
    @property
    @abstractmethod
    def area(self) -> float: ...

Virtual subclasses

A class can be registered as a subclass without inheriting:

python
class MyCollection: ...

from collections.abc import Sized
Sized.register(MyCollection)
isinstance(MyCollection(), Sized)   # True (lies!)

Useful for declaring conformance with collections.abc types when inheritance is undesirable. Rare; Protocol is usually cleaner.


6. collections.abc โ€” the ABCs you'll actually use

ABCIf you implement...You get...
Iterable__iter__for-loop support
Iterator__iter__, __next__
Sized__len__len()
Container__contains__in
Hashable__hash__usable as dict key
Collectionthe above three
Sequence__getitem__, __len__index, count, reversal
MutableSequence+ __setitem__, __delitem__, insertappend, extend, pop, ...
Mapping__getitem__, __len__, __iter__get, keys, values, ...
MutableMapping+ __setitem__, __delitem__update, pop, ...
Set__contains__, __iter__, __len__|, &, -, ^
Callable__call__

Subclass these to get many methods "for free." Or check isinstance(x, collections.abc.Mapping) to write generic code.


7. Composition over inheritance

The standard refactor when inheritance feels wrong:

python
# Inheritance โ€” Car IS-A Engine? No.
class Engine:
    def start(self) -> None: ...

class Car(Engine):
    pass

# Composition โ€” Car HAS-A Engine. Right.
class Car:
    def __init__(self, engine: Engine) -> None:
        self.engine = engine

    def start(self) -> None:
        self.engine.start()

Reach for inheritance when:

  • The relationship is truly "is-a."
  • You want polymorphism (callers treat subclasses identically).
  • You're extending a framework that requires it (e.g., Exception, unittest.TestCase).

Reach for composition when:

  • You're sharing implementation, not type.
  • You want to swap parts at runtime (DI).
  • The hierarchy would be more than 2-3 levels deep.

8. Common inheritance traps

  1. Diamond problem: solved by C3 linearisation + super(). But unclear hierarchies still produce bugs. Keep depth โ‰ค 3.
  2. Calling parent by name (Animal.__init__(self, ...)) skips MRO; breaks under multiple inheritance.
  3. LSP violations: a subclass that requires more or returns less than its parent. (Square(Rectangle) is the textbook example.)
  4. Mutable class attributes: class Foo: tags = [] is shared across instances. Use __init__ to set instance state.
  5. Overriding without super() when the parent has side effects.

9. Worked example: a small ORM-like pattern

python
from abc import ABC, abstractmethod

class Model(ABC):
    table: str = ""                    # subclasses set this

    def __init_subclass__(cls, **kwargs) -> None:
        super().__init_subclass__(**kwargs)
        if not cls.table:
            raise TypeError(f"{cls.__name__} must define 'table'")

    @classmethod
    @abstractmethod
    def from_row(cls, row: tuple) -> "Model": ...

    @abstractmethod
    def to_row(self) -> tuple: ...

class User(Model):
    table = "users"
    def __init__(self, uid: int, name: str) -> None:
        self.uid = uid; self.name = name
    @classmethod
    def from_row(cls, row): return cls(*row)
    def to_row(self): return (self.uid, self.name)

# Will raise at subclass creation:
# class Broken(Model): pass    -> TypeError: Broken must define 'table'

__init_subclass__ validates subclass declarations at class-definition time (not later when you instantiate). Cheap form of "linting."


Hands-on lab (2 hours)

  1. Build an Animal hierarchy with Dog, Cat, Cow polymorphically calling speak().
  2. Add a LoudMixin that wraps speak() to UPPERCASE; mix it in; verify MRO.
  3. Define an abstract Notifier ABC with send(message) and write EmailNotifier, SmsNotifier.
  4. Build a class hierarchy that triggers TypeError: Cannot create a consistent method resolution order; fix it.
  5. Implement a Sequence-conforming class by inheriting from collections.abc.Sequence; observe what methods you get for free.
  6. Refactor a 3-level inheritance chain into composition; compare readability.
  7. Bonus: write __init_subclass__ that registers all subclasses in a dict โ€” implement a tiny plugin loader.

Common pitfalls

  1. Calling parent's __init__ by name โ†’ breaks multiple inheritance.
  2. Mixin defining __init__ without super().
  3. Deep hierarchies; nobody can follow them.
  4. Mutable class attribute as instance default.
  5. Inheriting where composition is clearer.

Self-check

  1. What does super() do?
  2. How is MRO computed?
  3. When use ABC vs Protocol?
  4. State the Liskov Substitution Principle.
  5. State two cases where composition beats inheritance.

References

  • Fluent Python, Ramalho โ€” Chapters 13, 14.
  • Python Cookbook โ€” Chapter 8.
  • Raymond Hettinger, "Super considered super!" (PyCon 2015).
  • PEP 3119 โ€” Abstract Base Classes.
  • PEP 487 โ€” Simpler subclass customisation.

Sign in to save your progress and earn badges.