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()andissubclass()- 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.
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:
- Local precedence: a class always appears before its parents
- Monotonicity: the relative order of classes in parent MROs is preserved in the child's MRO
- 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")
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
MixinorBaseto signal intent - They define
__init__cooperatively with**kwargs - They operate on
selfbut make no assumptions about whatselfis - 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.
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_methodor 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
- Document which methods are designed to be overridden using
@abstractmethodor docstring conventions - Use hooks instead of direct calls: define empty hook methods that subclasses are expected to override
- 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
isinstance(obj, Dog) is not the same as type(obj) is Dog:
isinstance(obj, Dog)returnsTruefor any instance ofDogor any subclass ofDog- it works with the full inheritance hierarchytype(obj) is Dogis an exact type check - it returnsFalsefor 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.
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:
- Does inheritance copy methods from the parent to the subclass? What does it actually do?
- What does
super()mean in the context of multiple inheritance? Give a concrete example wheresuper()in classBdoes not callB's direct parent. - What is the C3 linearisation algorithm's guarantee about the MRO?
- What does Python do when a consistent MRO cannot be computed?
- What is the difference between
type(obj) is Dogandisinstance(obj, Dog)? - What is the fragile base class problem? Give an example.
- When is inheritance appropriate vs when should you use composition?
- What is a mixin? What conventions must it follow to work cooperatively in multiple inheritance?
- Why is subclassing
listdirectly 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__callssuper().__init__(x=1, y=2, z=3)→ callsB.__init__B.__init__extractsy=2, callssuper().__init__(x=1, z=3)→ callsC.__init__C.__init__extractsz=3, callssuper().__init__(x=1)→ callsA.__init__A.__init__extractsx=1, callssuper().__init__()→ callsobject.__init__()- done
Level 3 - Design Challenge
Design a plugin system using the mixin pattern and __init_subclass__. Requirements:
- A
BaseProcessorclass with an abstractprocess(data)method - A
LoggingMixinthat logs the input and output ofprocess()automatically - the subclass should not need to add logging manually - A
TimingMixinthat records how longprocess()takes - A
RetryMixinthat retriesprocess()up to N times on exception - A
ProcessorRegistrythat automatically tracks allBaseProcessorsubclasses - A concrete
UpperCaseProcessorandReverseProcessorthat use all mixins - 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
BaseProcessorin the class definition so theirprocess()wraps the concrete method LoggingMixinandTimingMixincallsuper().process(data)to pass through to the next class__init_subclass__withregister_asparameter enables opt-in registration without changing the base class- All
isinstancechecks work because every processor inherits fromBaseProcessorthrough 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
TypeErrorat class definition time when a consistent MRO is impossible. isinstance(obj, Cls)returnsTruefor the object's class and all its superclasses in the MRO.type(obj) is Clsis an exact match that excludes subclasses - useisinstancein 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 whenRectanglehas 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.
