13 — Inheritance & Polymorphism
Single Inheritance Basics
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
raise NotImplementedError("Subclasses must implement speak()")
def describe(self):
return f"{self.name} says {self.speak()}"
class Dog(Animal):
def speak(self):
return "Woof!"
class Cat(Animal):
def speak(self):
return "Meow!"
for animal in [Dog("Rex"), Cat("Whiskers")]:
print(animal.describe())
# Rex says Woof!
# Whiskers says Meow!
This is polymorphism: describe() is defined once, on Animal, but calls self.speak() — which resolves to whichever subclass's speak the actual instance has, at call time. Python doesn't need an interface keyword or explicit virtual dispatch declarations — every method call is dynamically dispatched based on the object's actual type.
super() — Calling the Parent's Implementation
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
def annual_bonus(self):
return self.salary * 0.05
class Manager(Employee):
def __init__(self, name, salary, team_size):
super().__init__(name, salary) # delegates to Employee.__init__
self.team_size = team_size
def annual_bonus(self):
base_bonus = super().annual_bonus() # reuses parent logic, then extends it
return base_bonus + self.team_size * 100
m = Manager("Priya", 90_000, 5)
print(m.annual_bonus()) # 90000*0.05 + 5*100 = 4500 + 500 = 5000.0
Best practice: always call super().__init__(...) in a subclass's __init__ unless you have a specific, deliberate reason not to (a common pattern in mixins) — skipping it means the parent's setup logic silently never runs, leaving the instance in a partially-initialized state that only fails later, far from the actual bug.
Multiple Inheritance and the Diamond Problem
class Flyer:
def move(self):
return "flies"
class Swimmer:
def move(self):
return "swims"
class Duck(Flyer, Swimmer): # inherits from BOTH
pass
d = Duck()
print(d.move()) # "flies" — Flyer comes first in the class definition
When two parent classes define the same method, which one wins is determined by the Method Resolution Order (MRO) — not simply "leftmost parent" by coincidence, but a well-defined algorithm.
MRO and C3 Linearization
Python uses the C3 linearization algorithm to compute a single, consistent method lookup order across arbitrarily complex inheritance graphs, avoiding the ambiguities that plague simpler "depth-first" schemes.
class A:
def greet(self):
return "A"
class B(A):
def greet(self):
return "B"
class C(A):
def greet(self):
return "C"
class D(B, C):
pass
print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)
print(D().greet()) # "B" — first class in the MRO that defines greet()
The rule C3 guarantees: a class always appears before its parents, and the order of parents as listed in the class definition is preserved. This is why D(B, C) produces [D, B, C, A, object] rather than the naive depth-first [D, B, A, C, A, object] (which visits A twice, and doesn't respect the constraint that C must come before A).
When C3 linearization is impossible
class X:
pass
class Y(X):
pass
# class Z(X, Y): # TypeError: Cannot create a consistent method resolution order (MRO)
# pass
class Z(X, Y) demands that X come before Y (as listed) while also requiring Y (a subclass of X) to come before its own parent X — a genuine contradiction. Python detects this at class-creation time and refuses to create the class, rather than silently picking an arbitrary, possibly-surprising order.
Cooperative Multiple Inheritance with super()
super() in a multi-inheritance hierarchy doesn't mean "my direct parent" — it means "the next class in the MRO," which is essential to understand for mixins that all need to run.
class LoggingMixin:
def save(self):
print("Logging save operation")
super().save() # cooperatively passes control along the MRO
class ValidationMixin:
def save(self):
print("Validating before save")
super().save()
class Document(LoggingMixin, ValidationMixin):
def save(self):
print("Saving document")
Document().save()
# Logging save operation
# Validating before save
# Saving document
Document.__mro__ is [Document, LoggingMixin, ValidationMixin, object]. Each super().save() call advances to the next class in that MRO — not necessarily each class's own direct parent — which is why all three save methods run in sequence even though none of these mixins directly inherits from another. This pattern requires every cooperating class to call super() — a mixin that forgets to call it breaks the chain for everyone after it.
Abstract Base Classes (ABC)
abc.ABC and @abstractmethod let you declare an interface that cannot be instantiated until all abstract methods are implemented, catching incomplete implementations at instantiation time rather than at first use.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
...
@abstractmethod
def perimeter(self):
...
def describe(self): # concrete methods are allowed alongside abstract ones
return f"Area: {self.area()}, Perimeter: {self.perimeter()}"
# shape = Shape() # TypeError: Can't instantiate abstract class Shape
# with abstract methods area, perimeter
class Rectangle(Shape):
def __init__(self, w, h):
self.w, self.h = w, h
def area(self):
return self.w * self.h
def perimeter(self):
return 2 * (self.w + self.h)
r = Rectangle(3, 4)
print(r.describe()) # Area: 12, Perimeter: 14
Compare to the "raise NotImplementedError" pattern from the first example in this chapter: that only fails when the missing method is actually called, potentially deep in production after the object was already constructed and passed around — ABC fails immediately at __init__ time, which is a strictly earlier and safer failure point.
ABC vs duck typing vs Protocol
# Duck typing — no formal contract, just "if it has the method, it works"
class DuckTypedShape:
def area(self):
return 42
def print_area(shape):
print(shape.area()) # works on ANY object with an area() method, no inheritance required
print_area(DuckTypedShape()) # 42
print_area(Rectangle(3, 4)) # 12
Python favors duck typing by default — ABC is reached for when you specifically want to enforce a contract at instantiation time (plugin systems, framework base classes) rather than merely document one. Chapter 20 covers typing.Protocol, which gives you static-analysis-time duck-typing checks without requiring inheritance at all — a third point on this spectrum.
Overriding __init__ and Attribute Shadowing in Hierarchies
class Base:
def __init__(self):
self.value = "base"
def show(self):
return self.value
class Derived(Base):
def __init__(self):
super().__init__()
self.value = "derived" # shadows the value super().__init__() just set
d = Derived()
print(d.show()) # "derived" — the LAST assignment to self.value wins, regardless of which class set it
There's only ever one self.value slot per instance (absent __slots__ per-class tricks) — attributes aren't namespaced by which class assigned them, so a subclass's __init__ running after super().__init__() will always overwrite anything the parent set under the same name.
isinstance() and issubclass() with Multiple Inheritance
class Reader:
pass
class Writer:
pass
class ReadWriter(Reader, Writer):
pass
rw = ReadWriter()
print(isinstance(rw, Reader)) # True
print(isinstance(rw, Writer)) # True
print(issubclass(ReadWriter, Reader)) # True
print(issubclass(ReadWriter, Writer)) # True
# isinstance/issubclass also accept a tuple of types, checked with OR semantics
print(isinstance(rw, (int, str, Reader))) # True — matches Reader
💡 Tips & Tricks
- Idiom: prefer composition ("has-a") over inheritance ("is-a") when the relationship isn't a genuine specialization — a
Carthat "has an"Engineshould hold anEngineinstance as an attribute, not inherit fromEngine; inheritance for code reuse alone often produces fragile, hard-to-follow hierarchies. - Debug:
ClassName.__mro__(orClassName.mro()) prints the exact method resolution order — run it whenever a multi-inheritance call resolves to a surprising implementation instead of guessing. - Idiom: mixins should generally not define
__init__unless they callsuper().__init__(*args, **kwargs)cooperatively — a mixin with a non-cooperative__init__silently breaks any hierarchy that places another class after it in the MRO. - Debug:
abc.ABCsubclasses that seem to "ignore"@abstractmethodare almost always missing theABCbase itself, or an abstract method was overridden by a subclass with a different signature that doesn't actually match (Python doesn't check signatures, only that the name exists). - Idiom:
super()with no arguments (Python 3 only) works by inspecting the enclosing class and instance implicitly via__class__— the two-argument formsuper(ClassName, self)still works but is legacy Python 2 syntax that's rarely needed today except in some metaclass or dynamic-class-creation edge cases.
⚠️ Edge Cases & Gotchas
- Multiple inheritance with conflicting base-class orderings raises
TypeError: Cannot create a consistent MROat class-definition time, not at instantiation — this is one of the few Python errors that happens purely from writingclass Z(X, Y): pass, before any code runs. super()in cooperative multiple inheritance means "next in the MRO," not "my direct parent" — a mixin that assumessuper()refers to a specific class will behave differently depending on where it's placed in a consuming class's inheritance list.- A mixin's
__init__that doesn't callsuper().__init__(...)silently breaks the initialization chain for every class placed after it in the MRO — those later classes'__init__methods simply never run, and the resulting bug (missing attributes) surfaces far from the actual cause. abc.ABConly checks that an abstract method name is overridden — it does not check the overriding method's signature (parameter count, types) — a subclass can "implement"area(self, unit="m")for an abstractarea(self)and it will satisfy the ABC, even though callers using the base signature might behave unexpectedly.- Instance attributes aren't scoped per class in the hierarchy — there's one namespace per instance — if both a base and derived
__init__assignself.value, whichever runs last (usually the derived class's own assignment, aftersuper().__init__()) wins, regardless of which class "conceptually owns" that attribute.
🧠 Spot the Bug
A plugin framework uses mixins to compose functionality. Some plugins mysteriously never get their setup code executed. Find the bug.
class CachingMixin:
def __init__(self, *args, **kwargs):
self.cache = {}
print("Cache initialized")
class LoggingMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.log = []
print("Logger initialized")
class Plugin(LoggingMixin, CachingMixin):
def __init__(self, name):
super().__init__()
self.name = name
p = Plugin("analytics")
print(hasattr(p, "cache"))
Answer
Prints only Logger initialized (never Cache initialized), and hasattr(p, "cache") is False. Plugin.__mro__ is [Plugin, LoggingMixin, CachingMixin, object]. Plugin.__init__ calls super().__init__(), which runs LoggingMixin.__init__; that correctly calls super().__init__(*args, **kwargs), advancing to CachingMixin.__init__ — so far so good. But CachingMixin.__init__ does not call super().__init__(*args, **kwargs) — it just sets self.cache and returns, silently stopping the cooperative chain right there.
In this particular example CachingMixin is last before object so nothing further is skipped — but the real bug is structural: CachingMixin breaks the contract of cooperative multiple inheritance. Swap the class order to Plugin(CachingMixin, LoggingMixin) and now LoggingMixin.__init__ (which does the right thing) never runs at all, because CachingMixin.__init__ runs first and never calls super().
The fix is to make every mixin in a cooperative hierarchy always call super().__init__(*args, **kwargs), even if it appears to be "the last one":
class CachingMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.cache = {}
print("Cache initialized")
The lesson: every class participating in cooperative multiple inheritance must call super().__init__(*args, **kwargs), regardless of where you assume it sits in the MRO — assuming your class is "the base" is exactly the assumption that breaks when someone reorders the inheritance list later.
Key Takeaways
- Polymorphism in Python needs no special syntax — any object with the right method name works wherever that method is called, resolved dynamically at call time based on the object's actual type.
super()delegates to the next class in the MRO, not necessarily the immediate parent — this distinction only matters (and matters a lot) once multiple inheritance is involved.- Python computes a single, well-defined Method Resolution Order via C3 linearization; conflicting orderings raise
TypeErrorat class-definition time rather than picking an arbitrary order. - Cooperative multiple inheritance requires every class in the chain — including mixins — to call
super().__init__(*args, **kwargs), or later classes in the MRO silently never get initialized. abc.ABC+@abstractmethodenforces a contract at instantiation time, failing fast; theNotImplementedError-in-a-plain-method pattern only fails when that specific method is eventually called.- Prefer composition over inheritance for "has-a" relationships — reach for inheritance (and especially multiple inheritance) only for genuine "is-a" specialization or well-designed cooperative mixins.