Skip to main content

Inheritance - Single, Multiple, and Cooperative at Engineering Depth

Reading time: ~35 minutes | Level: Intermediate → Engineering

Before reading further, predict every output:

class A:
def method(self):
print("A.method")
super().method()

class B:
def method(self):
print("B.method")

class C(A, B):
def method(self):
print("C.method")
super().method()

c = C()
c.method()

Most developers expect:

C.method
A.method

Possibly B.method after that. Or a crash. The actual output is:

C.method
A.method
B.method

A calls super().method() - but A's superclass is object, which has no method. Yet B.method runs. How?

Because in the context of the object c, super() in A.method does not mean "A's parent class". It means "the next class in c's MRO after A". And c's MRO is [C, A, B, object]. After A comes B, so B.method runs.

super() is not about parent classes. It is about position in the Method Resolution Order of the actual instance being operated on. This distinction changes everything about how you write and compose Python classes.

What You Will Learn

  • What inheritance actually does - shares a namespace via the MRO, not copies
  • Single inheritance and method override
  • super() and cooperative inheritance - the correct mental model
  • The MRO algorithm (C3 linearisation) - how Python computes it
  • Multiple inheritance - when it is useful and when it is dangerous
  • The fragile base class problem - why it exists in Python
  • isinstance() and issubclass() - mechanics and correct use
  • When inheritance is the right tool (true is-a relationships)
  • Common inheritance mistakes with real examples from the Python stdlib

Prerequisites

  • Lessons 01–05: the full OOP foundation - namespaces, __init__, dunders, __repr__, properties
  • Understanding Python's attribute lookup chain
  • Comfortable with super().__init__() from Lesson 02

Part 1 - What Inheritance Actually Does

Inheritance Shares a Namespace, It Does Not Copy

When a class inherits from a parent, it does not receive copies of the parent's methods and attributes. It gains a reference to the parent class in its MRO. When Python looks up an attribute, it walks the MRO chain. Nothing is copied.

class Animal:
sound = "..."

def speak(self):
return f"I say: {self.sound}"

class Dog(Animal):
sound = "Woof"

class Cat(Animal):
sound = "Meow"

# No copy - Dog.__dict__ does not contain 'speak'
print("speak" in Dog.__dict__) # False - not in Dog's own dict
print("speak" in Animal.__dict__) # True - lives in Animal's dict

d = Dog()
print(d.speak()) # "I say: Woof" - lookup found 'speak' in Animal, 'sound' in Dog

The MRO for Dog:

Dog → Animal → object

When d.speak() runs, self.sound is resolved starting from d's actual type (Dog). Dog.sound is "Woof", so that is what you get. The method speak was found in Animal, but self still refers to the Dog instance - so self.sound resolves from Dog, not from Animal.

Inspecting the MRO

print(Dog.__mro__)
# (<class '__main__.Dog'>, <class '__main__.Animal'>, <class 'object'>)

print(Dog.mro())
# [<class '__main__.Dog'>, <class '__main__.Animal'>, <class 'object'>]

The MRO is the ordered list of classes Python checks when looking up any attribute on an instance of Dog. Left-to-right, most-specific first.

Part 2 - Single Inheritance

Method Override

To override a parent method, define a method with the same name in the subclass:

class Shape:
def __init__(self, color: str = "black"):
self.color = color

def area(self) -> float:
raise NotImplementedError(f"{type(self).__name__} must implement area()")

def describe(self) -> str:
return f"A {self.color} {type(self).__name__} with area {self.area():.2f}"

class Circle(Shape):
def __init__(self, radius: float, color: str = "black"):
super().__init__(color) # Shape sets self.color
self.radius = radius

def area(self) -> float:
import math
return math.pi * self.radius ** 2

class Rectangle(Shape):
def __init__(self, width: float, height: float, color: str = "black"):
super().__init__(color)
self.width = width
self.height = height

def area(self) -> float:
return self.width * self.height

class Square(Rectangle):
def __init__(self, side: float, color: str = "black"):
super().__init__(side, side, color) # Rectangle.__init__ handles it

shapes = [
Circle(5, "red"),
Rectangle(4, 6, "blue"),
Square(3, "green"),
]

for s in shapes:
print(s.describe())
# A red Circle with area 78.54
# A blue Rectangle with area 24.00
# A green Square with area 9.00

describe() is defined once in Shape. It calls self.area() - and because of Python's dynamic dispatch, each subclass's own area() method is called. This is polymorphism: one method name, multiple implementations, selected at runtime based on the actual type.

Calling the Parent Method Explicitly

Sometimes you want to extend the parent's behaviour, not replace it:

class TimedCircle(Circle):
def __init__(self, radius: float, color: str = "black"):
from datetime import datetime, timezone
super().__init__(radius, color)
self.created_at = datetime.now(timezone.utc)

def describe(self) -> str:
base = super().describe() # get Circle's description
return f"{base} (created at {self.created_at.strftime('%H:%M:%S')})"

tc = TimedCircle(5, "purple")
print(tc.describe())
# A purple Circle with area 78.54 (created at 14:30:22)

super().describe() calls the next class in the MRO after TimedCircle - which is Circle. Circle does not define describe, so it finds Shape.describe. The result is then extended with the timestamp.

Part 3 - super() and Cooperative Inheritance

super() Is Not "Call the Parent Class"

The most important thing to understand about super():

# WRONG mental model:
super().method() # calls "my parent class's method"

# CORRECT mental model:
super().method() # calls "the next class in the MRO of the actual instance"

This distinction only matters in multiple inheritance, but getting it right prevents bugs.

class A:
def greet(self):
print("A.greet")

class B(A):
def greet(self):
print("B.greet")
super().greet() # in context of B alone: calls A.greet
# in context of D below: calls C.greet

class C(A):
def greet(self):
print("C.greet")
super().greet() # calls A.greet

class D(B, C):
def greet(self):
print("D.greet")
super().greet()

print(D.__mro__)
# [D, B, C, A, object]

D().greet()

Output:

D.greet
B.greet
C.greet
A.greet

B.greet calls super().greet(). In the MRO [D, B, C, A, object], the class after B is C. So super() in B calls C.greet - even though C is not in B's direct inheritance chain.

This is cooperative inheritance: each class in the chain calls super() and passes control to the next, ensuring every class in the MRO has a chance to run.

tip

Always call super().__init__(**kwargs) in every __init__ that participates in a cooperative inheritance chain. Omitting it means the classes further down the MRO never get initialised. Prefer passing arguments as keyword arguments (**kwargs) through the chain so each class can extract what it needs without breaking the others.

# Cooperative pattern
class Mixin:
def __init__(self, **kwargs):
super().__init__(**kwargs) # pass remaining kwargs up

class Host(Mixin, Base):
def __init__(self, x, y):
super().__init__(x=x, y=y) # thread all kwargs cooperatively

super() Signature and Mechanics

super() with no arguments (Python 3 style) is equivalent to super(CurrentClass, self) where CurrentClass is determined by the compiler from the enclosing class context:

class B(A):
def greet(self):
# These are identical in Python 3:
super().greet()
super(B, self).greet() # explicit - use when you need to skip levels

super(B, self) means: "find B in the MRO of self, and return a proxy that searches from the class after B."

You can use explicit super() to skip a level intentionally - but this is rare and usually indicates a design problem.

super() in __init__ with **kwargs

For mixins to work cooperatively, every class in the chain must accept **kwargs and pass them up:

class Base:
def __init__(self, x: int, **kwargs):
super().__init__(**kwargs) # passes to object.__init__
self.x = x

class LogMixin:
def __init__(self, **kwargs):
super().__init__(**kwargs)
print(f" LogMixin initialised on {type(self).__name__}")

class CacheMixin:
def __init__(self, cache_size: int = 100, **kwargs):
super().__init__(**kwargs)
self._cache = {}
self._cache_size = cache_size

class Service(LogMixin, CacheMixin, Base):
def __init__(self, x: int, cache_size: int = 100):
super().__init__(x=x, cache_size=cache_size)

print(Service.__mro__)
# [Service, LogMixin, CacheMixin, Base, object]

s = Service(x=42, cache_size=50)
# LogMixin initialised on Service
print(s.x) # 42
print(s._cache_size) # 50

The **kwargs pattern ensures that each class extracts its own named arguments and passes the remainder up the chain. Without it, you get TypeError: object.__init__() takes exactly one argument when unknown kwargs reach object.__init__.

Part 4 - The MRO Algorithm

C3 Linearisation

Python uses the C3 linearisation algorithm (introduced in Python 2.3) to compute the MRO. The algorithm guarantees:

  1. Local precedence: a class always appears before its parents
  2. Monotonicity: the relative order of classes in parent MROs is preserved in the child's MRO
  3. Consistency: the result is the same regardless of how you traverse the graph
class A: pass
class B(A): pass
class C(A): pass
class D(B, C): pass

print(D.__mro__)
# [D, B, C, A, object]

Why [D, B, C, A, object] and not [D, B, A, C, A, object]? Because C3 merges the linearisations without repetition, respecting the order in D(B, C).

MRO Failures

Some class hierarchies are impossible to linearise. Python raises TypeError at class definition time:

class A: pass
class B(A): pass
class C(A): pass

# This creates an inconsistency: D says A before B, E says B before A
class D(A, B): pass # A before B
class E(B, A): pass # B before A

# class F(D, E): pass # TypeError: Cannot create a consistent MRO

This is Python protecting you from an ambiguous inheritance hierarchy. If Python accepted this, the MRO would be different depending on which path you took through the graph.

Visualising C3 Step by Step

For class D(B, C) with class B(A) and class C(A):

C3(D) = D + merge(C3(B), C3(C), [B, C])
= D + merge([B, A, object], [C, A, object], [B, C])
= D, B + merge([A, object], [C, A, object], [C]) # B is head, not in any tail
= D, B, C + merge([A, object], [A, object], []) # C is head, not in tail of [A, object]
= D, B, C, A + merge([object], [object], [])
= D, B, C, A, object

The rule: take the head of the first list if it does not appear in the tail of any other list. If it does appear in a tail, skip to the next list and try its head.

Part 5 - Multiple Inheritance in Practice

The Mixin Pattern

The most legitimate use of multiple inheritance in Python is the mixin pattern. A mixin is a class that provides reusable methods to be added to unrelated classes. It is not meant to stand alone - it is never instantiated directly.

from datetime import datetime, timezone
from typing import Any, Dict

class JSONMixin:
"""Adds JSON serialisation to any class."""

def to_json(self) -> str:
import json
return json.dumps(self.to_dict(), default=str)

def to_dict(self) -> Dict[str, Any]:
return {
k: v for k, v in self.__dict__.items()
if not k.startswith("_")
}

class TimestampMixin:
"""Adds created_at and updated_at tracking."""

def __init__(self, **kwargs):
super().__init__(**kwargs)
now = datetime.now(timezone.utc)
self.created_at = now
self.updated_at = now

def touch(self):
self.updated_at = datetime.now(timezone.utc)

class ValidationMixin:
"""Adds is_valid() method based on _validate()."""

def is_valid(self) -> bool:
try:
self._validate()
return True
except ValueError:
return False

def _validate(self):
pass # override in host class

class User(TimestampMixin, JSONMixin, ValidationMixin):
def __init__(self, username: str, email: str):
super().__init__() # TimestampMixin cooperatively initialises
self.username = username
self.email = email

def _validate(self):
if not self.username:
raise ValueError("username required")
if "@" not in self.email:
raise ValueError("invalid email")

u = User("alice", "[email protected]")
print(u.to_json())
# {"username": "alice", "email": "[email protected]", "created_at": "...", "updated_at": "..."}

print(u.is_valid()) # True
print(User.__mro__)
# [User, TimestampMixin, JSONMixin, ValidationMixin, object]

Mixin conventions:

  • Names typically end in Mixin or Base to signal intent
  • They define __init__ cooperatively with **kwargs
  • They operate on self but make no assumptions about what self is
  • They should never inherit from each other (forms a diamond that the MRO handles, but complicates the design)

Multiple Inheritance from the Standard Library

Python's standard library uses multiple inheritance extensively:

# socketserver.TCPServer and threading.Thread combined
from socketserver import TCPServer, StreamRequestHandler, ThreadingMixIn

class ThreadedTCPServer(ThreadingMixIn, TCPServer):
"""A TCP server that handles each connection in its own thread."""
pass

# Python io module hierarchy
import io
print(io.BytesIO.__mro__)
# [BytesIO, _BufferedIOBase, _IOBase, object]

# collections.abc shows the pattern clearly
from collections.abc import MutableMapping, Hashable
print(MutableMapping.__mro__)
# [MutableMapping, Mapping, Collection, Sized, Iterable, Container, Hashable, object]

ThreadingMixIn adds process_request() that spawns a thread. Combined with TCPServer, the result is a multithreaded server with no duplication of TCP server logic.

Part 6 - The Fragile Base Class Problem

What It Is

The fragile base class problem: a change to a base class silently breaks a subclass, even when neither author did anything obviously wrong. Python is especially vulnerable because all methods are virtual (overridable) by default.

class Base:
def method_a(self):
print("Base.method_a")
self.method_b() # calls method_b - seems innocuous

def method_b(self):
print("Base.method_b")

class Sub(Base):
def method_b(self):
print("Sub.method_b")
super().method_b()
# Sub adds logic AFTER calling base

s = Sub()
s.method_a()
# Base.method_a
# Sub.method_b ← Sub.method_b is called from Base.method_a!
# Base.method_b

Base.method_a calls self.method_b(). When self is a Sub instance, Python's dynamic dispatch routes this to Sub.method_b. Sub's author may not know that Base.method_a calls method_b - this is an internal implementation detail that is now a public API contract.

If Base later changes method_a to not call method_b, or calls it twice, or calls it with an argument - Sub.method_b may break silently.

warning

The fragile base class problem is one of the most common sources of subtle, hard-to-diagnose bugs in Python codebases. When you write a base class that will be inherited by other developers:

  • Document every method that calls other overridable methods (the calling contract)
  • Use hook methods (empty methods designed to be overridden) instead of overriding the full algorithm
  • Mark methods as "final" via convention (_final_method or a comment) if they must not be overridden
  • Consider composition over inheritance when the subclass relationship is not a strict is-a

If you maintain a base class, changing which methods call other methods is a breaking change even if no public signatures change.

How to Mitigate It

  1. Document which methods are designed to be overridden using @abstractmethod or docstring conventions
  2. Use hooks instead of direct calls: define empty hook methods that subclasses are expected to override
  3. Prefer composition over inheritance when the relationship is not a true is-a
class Base:
def method_a(self):
print("Base.method_a")
self._on_method_a() # documented hook - subclasses override this, not method_a

def _on_method_a(self):
"""Override this in subclasses to extend method_a behaviour."""
pass

class Sub(Base):
def _on_method_a(self):
# Safe override - this is the documented extension point
print("Sub hook running")

s = Sub()
s.method_a()
# Base.method_a
# Sub hook running

This is the Template Method pattern: method_a is the template (algorithm skeleton); _on_method_a is the hook (variable step).

The __init_subclass__ Hook

Python 3.6 added __init_subclass__ - a clean way for base classes to react to being subclassed without metaclasses:

class Registry:
_registry: dict = {}

def __init_subclass__(cls, name: str = None, **kwargs):
super().__init_subclass__(**kwargs)
# Called when any class inherits from Registry
registry_name = name or cls.__name__
Registry._registry[registry_name] = cls
print(f" Registered: {registry_name!r}{cls.__name__}")

class Plugin(Registry):
pass

class AuthPlugin(Registry, name="auth"):
pass

class CachePlugin(Registry, name="cache"):
pass

print(Registry._registry)
# {'Plugin': <class 'Plugin'>, 'auth': <class 'AuthPlugin'>, 'cache': <class 'CachePlugin'>}

This is how plugin systems, ORM model registries, and serialiser registries are commonly built in Python. Django's model metaclass uses an equivalent pattern.

Part 7 - isinstance and issubclass

The Mechanics

isinstance(obj, cls) returns True if obj is an instance of cls or any subclass of cls:

class Animal: pass
class Dog(Animal): pass
class GuideDog(Dog): pass

rex = GuideDog()

print(isinstance(rex, GuideDog)) # True
print(isinstance(rex, Dog)) # True - Dog is in the MRO
print(isinstance(rex, Animal)) # True - Animal is in the MRO
print(isinstance(rex, object)) # True - object is always in the MRO
print(isinstance(rex, int)) # False

issubclass(cls, other) checks if cls is a subclass of other:

print(issubclass(GuideDog, Dog)) # True
print(issubclass(GuideDog, Animal)) # True
print(issubclass(Dog, GuideDog)) # False - reversed
print(issubclass(Dog, Dog)) # True - a class is a subclass of itself
note

isinstance(obj, Dog) is not the same as type(obj) is Dog:

  • isinstance(obj, Dog) returns True for any instance of Dog or any subclass of Dog - it works with the full inheritance hierarchy
  • type(obj) is Dog is an exact type check - it returns False for subclasses

In almost all application code, use isinstance. The type(obj) is X check is only appropriate when you explicitly need to exclude subclasses - which is a rare and usually a design smell. For example, type(obj) is int returns False for bool values, because bool is a subclass of int.

x = True
print(isinstance(x, int)) # True - bool IS-A int
print(type(x) is int) # False - bool is NOT exactly int

isinstance vs type() Equality

rex = GuideDog()

print(type(rex) is GuideDog) # True - EXACT type match
print(type(rex) is Dog) # False - not the exact type

print(isinstance(rex, GuideDog)) # True
print(isinstance(rex, Dog)) # True - works with inheritance

Use isinstance in application code. Use type(obj) is SomeClass only when you explicitly need to exclude subclasses - which is rare and usually a design smell.

isinstance with Multiple Types

def process(value):
if isinstance(value, (int, float)):
return f"number: {value}"
elif isinstance(value, str):
return f"string: {value!r}"
else:
raise TypeError(f"unsupported type: {type(value).__name__}")

print(process(42)) # number: 42
print(process(3.14)) # number: 3.14
print(process("hello")) # string: 'hello'

isinstance with ABCs - Virtual Subclasses

isinstance can return True even without direct inheritance, if the class is registered with an Abstract Base Class:

from collections.abc import Sequence, Mapping

print(isinstance([], Sequence)) # True - list implements the Sequence protocol
print(isinstance({}, Mapping)) # True - dict implements the Mapping protocol
print(isinstance("abc", Sequence)) # True - str implements Sequence

# Even though list does not inherit from Sequence directly:
print(issubclass(list, Sequence)) # True - registered as virtual subclass

ABCs use __subclasshook__ to check protocol compliance. This is covered fully in the ABCs lesson.

Part 8 - When Inheritance Is the Right Tool

The Is-A Test

Inheritance is correct when the subclass is genuinely a more specific kind of the parent. The "is-a" test:

Can you say "[SubClass] is a [BaseClass]" without qualification?

# Correct is-a relationships
class Employee(Person): pass # An Employee IS A Person
class SavingsAccount(Account): pass # A SavingsAccount IS AN Account
class Square(Rectangle): pass # A Square IS A Rectangle*

# Questionable is-a relationships
class Stack(list): pass # A Stack ISN'T REALLY a list -
# it has insert(), __getitem__, etc.
# which violate the Stack contract

class EmailParser(dict): pass # An EmailParser IS NOT A dict

*Note: Square(Rectangle) is the classic Liskov Substitution Principle violation - a Square cannot substitute for a Rectangle in code that calls r.set_width(5) and expects r.height to remain unchanged. Discussed in the SOLID lesson.

danger

The Square/Rectangle example is the canonical Liskov Substitution Principle (LSP) violation. The LSP states: if S is a subtype of T, then every program that uses T can be replaced with S without breaking correctness.

class Rectangle:
@property
def width(self): return self._width

@width.setter
def width(self, v): self._width = v

@property
def height(self): return self._height

@height.setter
def height(self, v): self._height = v

class Square(Rectangle):
@Rectangle.width.setter
def width(self, v):
self._width = v
self._height = v # enforce equal sides

@Rectangle.height.setter
def height(self, v):
self._height = v
self._width = v

# Code written for Rectangle - breaks with Square:
def double_width(r: Rectangle):
original_height = r.height
r.width = r.width * 2
assert r.height == original_height # AssertionError for Square!

The assertion passes for any Rectangle but fails for Square because setting width on a Square also changes height. If a Square cannot substitute for a Rectangle without breaking callers, then Square should not inherit from Rectangle. Use composition or a shared abstract base instead.

Inheritance in the Standard Library - Correct Uses

The Python standard library provides clear examples of correct inheritance:

# 1. io module - a strict hierarchy of capabilities
import io

# TextIOWrapper wraps a BufferedReader, which wraps a FileIO
# The is-a relationships are: each IS-A more specialised IOBase
class FileIO(io.RawIOBase): pass # raw binary I/O
class BufferedReader(io.BufferedIOBase): pass # buffered binary
class TextIOWrapper(io.TextIOBase): pass # text over binary

# 2. Exception hierarchy - is-a relationships are clear
# ValueError IS-A Exception IS-A BaseException
# All exceptions work with: except ValueError, except Exception, except BaseException

# 3. collections.UserList - correct way to extend list
from collections import UserList

class IndexedList(UserList):
"""A list that also supports key-based lookup."""
def __init__(self, key_fn, *args, **kwargs):
super().__init__(*args, **kwargs)
self._key_fn = key_fn
self._index = {}
for item in self.data:
self._index[key_fn(item)] = item

def append(self, item):
super().append(item)
self._index[self._key_fn(item)] = item

def by_key(self, key):
return self._index.get(key)

users = IndexedList(lambda u: u["id"], [
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"},
])
users.append({"id": 3, "name": "Carol"})

print(users[0]) # {'id': 1, 'name': 'Alice'}
print(users.by_key(2)) # {'id': 2, 'name': 'Bob'}
print(len(users)) # 3

UserList wraps list and is designed to be subclassed safely (it stores data in self.data, not in the list itself). Subclassing list directly is problematic because CPython optimises list internally and some operations bypass __getitem__.

Inheritance vs Composition

Inheritance is often the wrong tool when you want to reuse behaviour. Composition (has-a relationship) is frequently better:

# WRONG - Logger IS NOT A File
class Logger(file): # doesn't work and wrong conceptually
pass

# RIGHT - Logger HAS A file
class Logger:
def __init__(self, filepath: str):
self._file = open(filepath, "a") # composition: Logger uses a file

def log(self, message: str):
self._file.write(f"{message}\n")
self._file.flush()

def __enter__(self):
return self

def __exit__(self, *args):
self._file.close()
# WRONG - EmailSender IS NOT A SMTPConnection
class EmailSender(SMTPConnection):
pass

# RIGHT - EmailSender USES an SMTPConnection
class EmailSender:
def __init__(self, smtp_host: str, smtp_port: int):
self._smtp = SMTPConnection(smtp_host, smtp_port)

def send(self, to: str, subject: str, body: str):
self._smtp.send_message(...)

The rule: if you are reusing code but the subclass-superclass relationship does not satisfy "is-a", use composition.

Part 9 - A Complete Inheritance Example

from abc import ABC, abstractmethod
from functools import total_ordering
from typing import List

@total_ordering
class Shape(ABC):
"""Abstract base: all shapes have area, perimeter, and colour."""

def __init__(self, color: str = "black"):
self.color = color

@abstractmethod
def area(self) -> float: ...

@abstractmethod
def perimeter(self) -> float: ...

def __eq__(self, other) -> bool:
if not isinstance(other, Shape):
return NotImplemented
return self.area() == other.area()

def __lt__(self, other) -> bool:
if not isinstance(other, Shape):
return NotImplemented
return self.area() < other.area()

def __hash__(self):
return hash(self.area())

def __repr__(self) -> str:
return f"{type(self).__name__}(color={self.color!r}, area={self.area():.2f})"

def describe(self) -> str:
return (
f"{type(self).__name__} | color={self.color} | "
f"area={self.area():.2f} | perimeter={self.perimeter():.2f}"
)

class Circle(Shape):
def __init__(self, radius: float, color: str = "black"):
super().__init__(color)
if radius <= 0:
raise ValueError(f"radius must be positive, got {radius}")
self.radius = radius

def area(self) -> float:
import math
return math.pi * self.radius ** 2

def perimeter(self) -> float:
import math
return 2 * math.pi * self.radius

class Rectangle(Shape):
def __init__(self, width: float, height: float, color: str = "black"):
super().__init__(color)
if width <= 0 or height <= 0:
raise ValueError("width and height must be positive")
self.width = width
self.height = height

def area(self) -> float:
return self.width * self.height

def perimeter(self) -> float:
return 2 * (self.width + self.height)

class Square(Rectangle):
def __init__(self, side: float, color: str = "black"):
super().__init__(side, side, color)

def __repr__(self) -> str:
# Override repr - Square doesn't need to show width AND height
return f"Square(side={self.width!r}, color={self.color!r}, area={self.area():.2f})"

# Polymorphism - all shapes work with the same interface
shapes: List[Shape] = [
Circle(5, "red"),
Rectangle(4, 6, "blue"),
Square(3, "green"),
Circle(1, "yellow"),
]

# sort() works because of __lt__ from Shape
shapes.sort()
for s in shapes:
print(s.describe())

# Type checks
for s in shapes:
print(f"{s!r} isinstance(Shape)={isinstance(s, Shape)}")
if isinstance(s, Rectangle):
print(f" width={s.width}, height={s.height}")

# set() works because of __hash__ and __eq__
unique = set(shapes) # circles with same area collapse to one

Common Mistakes

Mistake 1 - Not Calling super().__init__()

# Wrong - parent's __init__ never runs
class Vehicle:
def __init__(self, make):
self.make = make

class Car(Vehicle):
def __init__(self, make, model):
# MISSING: super().__init__(make)
self.model = model

c = Car("Toyota", "Camry")
print(c.model) # Camry
print(c.make) # AttributeError - Vehicle.__init__ never ran

# Right
class Car(Vehicle):
def __init__(self, make, model):
super().__init__(make)
self.model = model

Mistake 2 - Assuming super() Means "Direct Parent"

# In multiple inheritance, super() follows the MRO - not the direct parent
class A:
def m(self): print("A")

class B(A):
def m(self):
print("B")
super().m() # NOT necessarily A.m - depends on MRO context

class C(A):
def m(self): print("C")

class D(B, C): # MRO: [D, B, C, A]
pass

D().m()
# B
# C ← super() in B calls C.m, not A.m
# A

Mistake 3 - Breaking the Liskov Substitution Principle

# Wrong - Square violates the Rectangle contract if you add setters
class Rectangle:
@property
def width(self): return self._width

@width.setter
def width(self, v): self._width = v

@property
def height(self): return self._height

@height.setter
def height(self, v): self._height = v

class Square(Rectangle):
@Rectangle.width.setter
def width(self, v):
self._width = v
self._height = v # must stay equal

@Rectangle.height.setter
def height(self, v):
self._height = v
self._width = v

# Code written for Rectangle is broken by Square:
def double_width(r: Rectangle):
original_height = r.height
r.width = r.width * 2
assert r.height == original_height # AssertionError for Square

# Right: if Square cannot substitute for Rectangle,
# Square should NOT inherit from Rectangle.

Mistake 4 - Subclassing Built-ins Without Care

# Problematic - list methods like sort() and extend() may bypass __setitem__
class ValidatedList(list):
def __setitem__(self, index, value):
if not isinstance(value, int):
raise TypeError("only ints allowed")
super().__setitem__(index, value)

vl = ValidatedList([1, 2, 3])
vl[0] = "oops" # TypeError - __setitem__ called
vl += ["oops"] # NO ERROR in CPython - __setitem__ bypassed for += on list!

# Right - subclass UserList instead
from collections import UserList
class ValidatedList(UserList):
def __setitem__(self, index, value):
if not isinstance(value, int):
raise TypeError("only ints allowed")
super().__setitem__(index, value)

Mistake 5 - Diamond Inheritance Without Cooperative super()

# Wrong - some parent's __init__ runs twice or not at all
class A:
def __init__(self):
print("A.__init__")

class B(A):
def __init__(self):
A.__init__(self) # hardcoded - not cooperative
print("B.__init__")

class C(A):
def __init__(self):
A.__init__(self) # hardcoded - not cooperative
print("C.__init__")

class D(B, C):
def __init__(self):
B.__init__(self)
C.__init__(self)

D()
# A.__init__ ← called by B
# B.__init__
# A.__init__ ← called again by C - A initialised twice!
# C.__init__

# Right - use super() everywhere
class A:
def __init__(self): print("A.__init__")

class B(A):
def __init__(self):
super().__init__()
print("B.__init__")

class C(A):
def __init__(self):
super().__init__()
print("C.__init__")

class D(B, C):
def __init__(self):
super().__init__()

D()
# A.__init__ ← called exactly once
# C.__init__
# B.__init__

Engineering Checklist

Before moving to the next lesson, verify you can answer these without looking:

  1. Does inheritance copy methods from the parent to the subclass? What does it actually do?
  2. What does super() mean in the context of multiple inheritance? Give a concrete example where super() in class B does not call B's direct parent.
  3. What is the C3 linearisation algorithm's guarantee about the MRO?
  4. What does Python do when a consistent MRO cannot be computed?
  5. What is the difference between type(obj) is Dog and isinstance(obj, Dog)?
  6. What is the fragile base class problem? Give an example.
  7. When is inheritance appropriate vs when should you use composition?
  8. What is a mixin? What conventions must it follow to work cooperatively in multiple inheritance?
  9. Why is subclassing list directly problematic, and what is the alternative?

Quick Reference

# Single inheritance with super()
class Child(Parent):
def __init__(self, x, y):
super().__init__(x) # always call parent
self.y = y

def method(self):
result = super().method() # extend, not replace
return f"Child: {result}"

# MRO inspection
print(MyClass.__mro__)
print(MyClass.mro())

# Multiple inheritance - cooperative __init__
class Mixin:
def __init__(self, **kwargs):
super().__init__(**kwargs) # pass remaining kwargs up

class Host(Mixin, Base):
def __init__(self, x, y):
super().__init__(x=x, y=y) # all kwargs threaded cooperatively

# isinstance and issubclass
isinstance(obj, (ClassA, ClassB)) # True if obj is an instance of either
issubclass(Child, Parent) # True if Child's MRO includes Parent

# Check MRO programmatically
for cls in MyClass.__mro__:
print(cls.__name__, cls.__dict__.keys())

Graded Practice

Level 1 - Predict the Output

For each code snippet, determine the output before running.

Question 1: What does the following print?

class A:
def greet(self): return "A"

class B(A):
def greet(self): return "B → " + super().greet()

class C(A):
def greet(self): return "C → " + super().greet()

class D(B, C):
def greet(self): return "D → " + super().greet()

print(D().greet())
print(D.__mro__)
Show Answer
D → B → C → A

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

Trace: D.greet calls super().greet()B.greet (next in MRO). B.greet calls super().greet()C.greet (next after B). C.greet calls super().greet()A.greet. Each prepends its letter. Reading backwards from the innermost call: A returns "A", C returns "C → A", B returns "B → C → A", D returns "D → B → C → A".

Question 2: What does this print?

class Animal:
def __init__(self, name):
self.name = name

class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name)
self.breed = breed

rex = Dog("Rex", "Labrador")

print(isinstance(rex, Dog))
print(isinstance(rex, Animal))
print(isinstance(rex, object))
print(type(rex) is Dog)
print(type(rex) is Animal)
Show Answer
True
True
True
True
False

isinstance checks the full MRO chain - rex is an instance of Dog, Animal, and object because all three appear in Dog.__mro__. type(rex) is Dog is an exact type match (True). type(rex) is Animal is False because rex's exact type is Dog, not Animal.

Question 3: What does this print, and what is the MRO of D?

class A:
def method(self):
print("A")

class B(A):
def method(self):
print("B")
super().method()

class C(A):
def method(self):
print("C")
super().method()

class D(B, C):
pass

D().method()
Show Answer
B
C
A

D does not define method, so Python looks up the MRO: [D, B, C, A, object]. B.method is found first. It prints "B" then calls super().method() - in the MRO of a D instance, the class after B is C. C.method prints "C" then calls super().method() - the class after C is A. A.method prints "A" with no further super() call.

Level 2 - Debug Challenge

The following multiple inheritance code has three bugs that cause either incorrect output, an exception, or A.__init__ running twice. Identify and fix all three.

class A:
def __init__(self, x):
print(f"A.__init__(x={x})")
self.x = x

class B(A):
def __init__(self, x, y):
A.__init__(self, x) # Bug 1
print(f"B.__init__(y={y})")
self.y = y

class C(A):
def __init__(self, x, z):
A.__init__(self, x) # Bug 2
print(f"C.__init__(z={z})")
self.z = z

class D(B, C):
def __init__(self, x, y, z):
B.__init__(self, x, y) # Bug 3
C.__init__(self, x, z)

D(1, 2, 3)
# Expected: A.__init__ runs exactly once
# Actual: A.__init__ runs twice
Show Answer

All three bugs are hardcoded calls to parent __init__ methods instead of cooperative super().__init__(). When D.__init__ calls B.__init__, which calls A.__init__, then D.__init__ also calls C.__init__, which again calls A.__init__ - A initialises twice.

Fixed version using cooperative super() with **kwargs:

class A:
def __init__(self, x, **kwargs):
super().__init__(**kwargs) # cooperatively passes remaining kwargs
print(f"A.__init__(x={x})")
self.x = x

class B(A):
def __init__(self, y, **kwargs):
super().__init__(**kwargs) # passes x (and anything else) up
print(f"B.__init__(y={y})")
self.y = y

class C(A):
def __init__(self, z, **kwargs):
super().__init__(**kwargs) # passes x up
print(f"C.__init__(z={z})")
self.z = z

class D(B, C):
def __init__(self, x, y, z):
super().__init__(x=x, y=y, z=z) # threads all kwargs cooperatively

print(D.__mro__)
# [D, B, C, A, object]

D(1, 2, 3)
# A.__init__(x=1) ← runs exactly once
# C.__init__(z=3)
# B.__init__(y=2)

With MRO [D, B, C, A, object]:

  • D.__init__ calls super().__init__(x=1, y=2, z=3) → calls B.__init__
  • B.__init__ extracts y=2, calls super().__init__(x=1, z=3) → calls C.__init__
  • C.__init__ extracts z=3, calls super().__init__(x=1) → calls A.__init__
  • A.__init__ extracts x=1, calls super().__init__() → calls object.__init__() - done

Level 3 - Design Challenge

Design a plugin system using the mixin pattern and __init_subclass__. Requirements:

  1. A BaseProcessor class with an abstract process(data) method
  2. A LoggingMixin that logs the input and output of process() automatically - the subclass should not need to add logging manually
  3. A TimingMixin that records how long process() takes
  4. A RetryMixin that retries process() up to N times on exception
  5. A ProcessorRegistry that automatically tracks all BaseProcessor subclasses
  6. A concrete UpperCaseProcessor and ReverseProcessor that use all mixins
  7. Demonstrate that isinstance(proc, BaseProcessor) works for both
Show Answer
import time
import logging
from abc import ABC, abstractmethod

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

class ProcessorRegistry:
_registry: dict = {}

def __init_subclass__(cls, register_as: str = None, **kwargs):
super().__init_subclass__(**kwargs)
if register_as:
ProcessorRegistry._registry[register_as] = cls

class LoggingMixin:
def process(self, data):
logging.info(f"{type(self).__name__}.process() input: {data!r}")
result = super().process(data)
logging.info(f"{type(self).__name__}.process() output: {result!r}")
return result

class TimingMixin:
def process(self, data):
start = time.perf_counter()
result = super().process(data)
elapsed = time.perf_counter() - start
logging.info(f"{type(self).__name__}.process() took {elapsed*1000:.2f}ms")
return result

class RetryMixin:
max_retries: int = 3

def process(self, data):
last_exc = None
for attempt in range(1, self.max_retries + 1):
try:
return super().process(data)
except Exception as e:
last_exc = e
logging.warning(f"Attempt {attempt} failed: {e}")
raise last_exc

class BaseProcessor(ProcessorRegistry, ABC):
@abstractmethod
def process(self, data): ...

def __repr__(self):
return f"{type(self).__name__}()"


class UpperCaseProcessor(LoggingMixin, TimingMixin, BaseProcessor, register_as="upper"):
def process(self, data: str) -> str:
return data.upper()

class ReverseProcessor(LoggingMixin, TimingMixin, RetryMixin, BaseProcessor, register_as="reverse"):
def process(self, data: str) -> str:
return data[::-1]


# Demo
upper = UpperCaseProcessor()
result = upper.process("hello world")
# INFO: UpperCaseProcessor.process() input: 'hello world'
# INFO: UpperCaseProcessor.process() took X.XXms
# INFO: UpperCaseProcessor.process() output: 'HELLO WORLD'
print(result) # HELLO WORLD

reverse = ReverseProcessor()
result2 = reverse.process("python")
print(result2) # nohtyp

# isinstance works for all
print(isinstance(upper, BaseProcessor)) # True
print(isinstance(reverse, BaseProcessor)) # True

# Registry
print(ProcessorRegistry._registry)
# {'upper': <class 'UpperCaseProcessor'>, 'reverse': <class 'ReverseProcessor'>}

# Create from registry
proc_cls = ProcessorRegistry._registry["upper"]
proc = proc_cls()
print(proc.process("registry lookup"))

Key design decisions:

  • Mixins come before BaseProcessor in the class definition so their process() wraps the concrete method
  • LoggingMixin and TimingMixin call super().process(data) to pass through to the next class
  • __init_subclass__ with register_as parameter enables opt-in registration without changing the base class
  • All isinstance checks work because every processor inherits from BaseProcessor through the MRO

Key Takeaways

  • Inheritance shares a namespace through the MRO - it does not copy methods. When Python looks up obj.method, it walks the MRO left-to-right until it finds the method.
  • super() is not "call my parent class". It is "call the next class in the MRO of the actual instance". This distinction only matters in multiple inheritance but getting it wrong causes hard-to-trace bugs.
  • The MRO is computed by the C3 linearisation algorithm, which guarantees local precedence, monotonicity, and consistency. Python raises TypeError at class definition time when a consistent MRO is impossible.
  • isinstance(obj, Cls) returns True for the object's class and all its superclasses in the MRO. type(obj) is Cls is an exact match that excludes subclasses - use isinstance in almost all application code.
  • The fragile base class problem: a change to a method in a base class that calls other overridable methods is a breaking change for subclasses, even without any public signature change. Mitigate with documented hook methods and the Template Method pattern.
  • The Liskov Substitution Principle: a subclass must be substitutable for its parent in all contexts the parent was designed for. Square(Rectangle) violates this when Rectangle has independent width/height setters.
  • Mixins are the legitimate use case for multiple inheritance. They provide reusable behaviour, cooperatively call super().__init__(**kwargs), and never stand alone.
  • Use super().__init__(...) in every __init__ that participates in a cooperative chain - hardcoding parent class calls (A.__init__(self, ...)) causes double initialisation in diamond hierarchies.
  • Prefer composition (has-a) over inheritance (is-a) when the relationship is not a genuine subtype. Inheritance that violates is-a leads to fragile hierarchies and LSP violations.

What's Next

Lesson 07 covers composition vs inheritance in depth - when to use each, the Dependency Inversion Principle, protocol-based composition, and how real frameworks (Django, SQLAlchemy, FastAPI) choose between the two. You will also see how Python's abc module formalises the design contracts that guide these decisions.

Understanding composition as an alternative to inheritance is the transition from writing code that works to writing code that is maintainable, testable, and extendable as systems grow.

© 2026 EngineersOfAI. All rights reserved.