Inheritance & Polymorphism
1 · The lesson
readInheritance lets a class adopt and extend the attributes and methods of another. Polymorphism lets unrelated objects respond to the same call in their own way. Together they're the cleanest way to model "kinds of a thing" — three shapes that all know how to compute their area, four payment providers that all expose charge(amount).
That power comes with a tax. Deep hierarchies are hard to refactor, and the moment a base class changes, every subclass either breaks or has to be reviewed. This lesson teaches the syntax, the resolution rules, the abstract-base-class contract, and — most importantly — when to reach for composition instead.
1. class Child(Parent) — Basic Inheritance
class Animal: def __init__(self, name): self.name = name def speak(self): return f"{self.name} makes a sound." class Dog(Animal): def fetch(self): return f"{self.name} fetches the ball." rex = Dog("Rex") print(rex.speak()) # Rex makes a sound. — inherited from Animal print(rex.fetch()) # Rex fetches the ball.
Dog inherits everything from Animal — __init__, speak, anything else defined or inherited. It can add new methods (fetch), override existing ones, or do both.
isinstance(rex, Dog) is True. isinstance(rex, Animal) is also True — Dog is-a Animal.
2. super().__init__(...) — Calling the Parent
When a subclass defines its own __init__, the parent's __init__ no longer runs automatically. You must call it yourself:
class Animal: def __init__(self, name): self.name = name class Dog(Animal): def __init__(self, name, breed): super().__init__(name) # initialises self.name via Animal self.breed = breed # then sets the subclass-specific field rex = Dog("Rex", "Border Collie") print(rex.name, rex.breed) # Rex Border Collie
Forget super().__init__(...) and rex.name would raise AttributeError. super() returns a proxy for the parent — super().method(...) runs the parent's version even if the subclass has overridden it.
3. Method Overriding
A subclass can replace a parent method by defining one with the same name. Often you want to extend rather than replace — call super().method(...) and add to it:
class Animal: def speak(self): return f"{self.name} makes a sound." class Dog(Animal): def __init__(self, name): self.name = name def speak(self): return f"{self.name} barks." # full replacement class PoliteDog(Dog): def speak(self): base = super().speak() return f"{base} Then sits politely." # extend the parent print(Dog("Rex").speak()) # Rex barks. print(PoliteDog("Buddy").speak()) # Buddy barks. Then sits politely.
4. Polymorphism — Same Interface, Different Implementation
Multiple classes that expose the same method name can be used interchangeably without the caller knowing which is which.
class Circle: def __init__(self, radius): self.radius = radius def area(self): return 3.14159 * self.radius ** 2 class Rectangle: def __init__(self, width, height): self.width, self.height = width, height def area(self): return self.width * self.height class Triangle: def __init__(self, base, height): self.base, self.height = base, height def area(self): return 0.5 * self.base * self.height def total_area(shapes): return sum(s.area() for s in shapes) # doesn't care which kind of shape shapes = [Circle(2), Rectangle(3, 4), Triangle(5, 6)] print(total_area(shapes)) # 39.56636
total_area doesn't isinstance-check or branch on type. It calls .area() and lets each object do the right thing. That's polymorphism. Adding a Hexagon later requires zero changes to total_area.
This is sometimes called duck typing — if it walks like a duck and quacks like a duck, treat it like a duck. Python doesn't require the shapes to share a base class to be used polymorphically. The shared interface is what matters.
5. Multiple Inheritance
Python allows a class to inherit from more than one parent:
class Swimmer: def swim(self): return "swimming." class Flyer: def fly(self): return "flying." class Duck(Swimmer, Flyer): def __init__(self, name): self.name = name d = Duck("Donald") print(d.swim(), d.fly()) # swimming. flying.
Useful when the parents are orthogonal capability bundles ("can swim", "can fly"). Dangerous when they overlap — both parents define the same method and now order matters. Most modern Python code prefers mixins (Section 9) or composition for this reason.
6. MRO and the Diamond
When you call obj.method(), Python searches a specific order of classes — the Method Resolution Order. For single inheritance it's obvious (subclass → parent → grandparent → object). With multiple inheritance, Python uses C3 linearization to produce a deterministic order that respects each parent's own MRO and the order you wrote them.
class A: def hi(self): return "A" class B(A): def hi(self): return "B" class C(A): def hi(self): return "C" class D(B, C): pass print(D().hi()) # B — B comes before C in the MRO print([cls.__name__ for cls in D.__mro__]) # ['D', 'B', 'C', 'A', 'object']
This is the diamond problem — D inherits from both B and C, both of which inherit from A. Without C3, you'd either visit A twice or have to pick arbitrarily. The MRO gives you one unambiguous path: D → B → C → A → object. Read D.__mro__ whenever multiple inheritance confuses you.
7. isinstance and issubclass
print(isinstance(rex, Dog)) # True print(isinstance(rex, Animal)) # True — inheritance counts print(isinstance(rex, (Dog, Cat))) # True if rex is either print(issubclass(Dog, Animal)) # True print(issubclass(Dog, object)) # True — everything inherits from object
setup added so this can run · defines rex, Dog, Animal, Cat
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) rex = _AutoMock('rex') Dog = _AutoMock('Dog') Animal = _AutoMock('Animal') Cat = _AutoMock('Cat')
Both accept a tuple of types — isinstance(x, (int, float)) is the idiomatic "is this a number?" check.
Reach for isinstance to guard a function against bad input, or to dispatch when polymorphism doesn't fit. Reach for it less than you'd think — if you find yourself writing if isinstance(x, Cat): ... elif isinstance(x, Dog): ..., you've reinvented method dispatch by hand. Push the branch into a polymorphic method.
8. Abstract Base Classes
Sometimes you want to guarantee that subclasses implement a method. The abc module makes that contract explicit:
from abc import ABC, abstractmethod class Shape(ABC): @abstractmethod def area(self): ... def describe(self): return f"A shape with area {self.area()}." # concrete methods are still fine class Square(Shape): def __init__(self, side): self.side = side def area(self): return self.side ** 2 print(Square(3).describe()) # A shape with area 9. # Shape() # TypeError: Can't instantiate abstract class Shape # # with abstract method area
You can't instantiate Shape directly — Python refuses. A subclass that forgets to implement area is also un-instantiable. The contract is enforced at object-creation time, not eventually-at-call-time. Use ABCs when you ship a base class for others to extend.
9. Composition vs Inheritance — Favour Composition
Inheritance says "X is-a Y". Composition says "X has-a Y". The latter is more flexible.
# Inheritance — Car IS-A Engine? That's wrong. class Engine: def start(self): print("vroom") class Car(Engine): # awkward — a car isn't an engine pass # Composition — Car HAS-A Engine. Much better. class Engine: def start(self): print("vroom") class Car: def __init__(self): self.engine = Engine() def start(self): self.engine.start()
Same external behaviour. The composition version is easier to test (swap in a MockEngine), easier to evolve (the engine can change independently), and avoids leaking the engine's full surface area into Car. The Gang-of-Four guideline — favour composition over inheritance — is decades old and still right.
Use inheritance when there's a genuine is-a relationship and the parent's full interface makes sense on the child. Use composition for everything else.
10. Mixins
A mixin is a small class designed to be combined with others to add a single capability. It shouldn't make sense on its own — you don't instantiate a mixin directly.
from datetime import datetime class TimestampMixin: """Adds created_at / updated_at to any class.""" def __init__(self): self.created_at = datetime.now() self.updated_at = self.created_at def touch(self): self.updated_at = datetime.now() class Article(TimestampMixin): def __init__(self, title, body): super().__init__() self.title = title self.body = body a = Article("Hello", "world") print(a.created_at) a.touch() print(a.updated_at)
Convention: name them <Capability>Mixin. Keep them small and orthogonal. Django, Flask, and most modern frameworks use mixins heavily — LoginRequiredMixin, JSONResponseMixin, etc.
Common Mistakes
1. Deep hierarchies. Three or more levels of inheritance (Animal → Mammal → Dog → PoliteDog) become brittle. A change at the top ripples down through every level. Flatten with composition or fewer abstractions.
2. Inheriting from concrete classes for code reuse. If you only want a method from another class, don't inherit — you'll drag the whole interface with you. Extract the method to a helper or use composition.
3. The fragile base class problem. Changing a parent class's internal helper method can silently break subclasses that depended on the old behaviour. Document which methods are "extension points" and which are private (_underscore_prefixed). Same reason public libraries are cautious about non-trivial inheritance hierarchies.
4. isinstance(x, dict) to special-case behaviour. Often a smell — you're branching on type instead of asking the object what it can do. Prefer duck typing or polymorphism. Legitimate use: input validation at the boundary of your code.
5. Forgetting super().__init__(). A subclass __init__ that skips the parent call leaves the parent's attributes unset. self.name will raise AttributeError later, often somewhere far from the cause.
class Animal: def __init__(self, name): self.name = name class Dog(Animal): def __init__(self, breed): # forgot super().__init__(name) ! self.breed = breed d = Dog("Border Collie") print(d.name) # AttributeError
🎯 Your Turn — A Shape Hierarchy
Build a Shape abstract base class with abstract area() and perimeter() methods. Then implement Circle, Rectangle, and Triangle subclasses. Finally, write total_area(shapes) that sums the areas of any iterable of shapes.
Requirements:
1. Shape uses ABC and @abstractmethod.
2. Circle takes radius. Use math.pi.
3. Rectangle takes width and height.
4. Triangle takes three side lengths a, b, c. Use Heron's formula: s = (a+b+c)/2; area = sqrt(s*(s-a)*(s-b)*(s-c)).
5. Each subclass implements area() and perimeter().
6. total_area(shapes) works polymorphically — no isinstance branching.
7. Bonus: a __repr__ on each subclass.
Skeleton:
from abc import ABC, abstractmethod import math class Shape(ABC): @abstractmethod def area(self): ... @abstractmethod def perimeter(self): ... class Circle(Shape): def __init__(self, radius): # TODO 1 ... def area(self): # TODO 2 ... def perimeter(self): # TODO 3 ... # TODO 4: Rectangle(width, height) # TODO 5: Triangle(a, b, c) — use Heron's formula def total_area(shapes): # TODO 6: sum area() of every shape ... shapes = [Circle(2), Rectangle(3, 4), Triangle(3, 4, 5)] print(total_area(shapes)) # ~ 30.566
setup added so this can run · defines Rectangle, Triangle
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def Rectangle(*_a, **_kw): print('-> Rectangle() called') return _AutoMock('Rectangle()') def Triangle(*_a, **_kw): print('-> Triangle() called') return _AutoMock('Triangle()')
Hint 1 — Abstract methods
Decoratearea and perimeter in Shape with @abstractmethod. The body can just be ... (or pass). Python will refuse to instantiate any subclass that doesn't implement both.
Hint 2 — Heron's formula
s = (a + b + c) / 2, then area = math.sqrt(s * (s - a) * (s - b) * (s - c)). The perimeter is just a + b + c.
Show full solution
from abc import ABC, abstractmethod import math class Shape(ABC): @abstractmethod def area(self): ... @abstractmethod def perimeter(self): ... def describe(self): return f"{type(self).__name__}: area={self.area():.3f}, perimeter={self.perimeter():.3f}" class Circle(Shape): def __init__(self, radius): self.radius = radius def area(self): return math.pi * self.radius ** 2 def perimeter(self): return 2 * math.pi * self.radius def __repr__(self): return f"Circle(radius={self.radius})" class Rectangle(Shape): def __init__(self, width, height): self.width, self.height = width, height def area(self): return self.width * self.height def perimeter(self): return 2 * (self.width + self.height) def __repr__(self): return f"Rectangle(width={self.width}, height={self.height})" class Triangle(Shape): def __init__(self, a, b, c): if a + b <= c or a + c <= b or b + c <= a: raise ValueError("triangle inequality violated") self.a, self.b, self.c = a, b, c def area(self): s = self.perimeter() / 2 return math.sqrt(s * (s - self.a) * (s - self.b) * (s - self.c)) def perimeter(self): return self.a + self.b + self.c def __repr__(self): return f"Triangle(a={self.a}, b={self.b}, c={self.c})" def total_area(shapes): return sum(s.area() for s in shapes) shapes = [Circle(2), Rectangle(3, 4), Triangle(3, 4, 5)] for s in shapes: print(s.describe()) print(f"total: {total_area(shapes):.3f}")
Notice how total_area doesn't know or care about the shape types. Add Hexagon(side) tomorrow — implement area() and perimeter() and it slots in automatically. That's the leverage polymorphism gives you.
What You Learned
class Child(Parent):for inheritance;super().__init__(...)to call the parent's initialiser.- Method overriding replaces;
super().method(...)extends. - Polymorphism — same method name, different implementations, callers don't branch.
- Multiple inheritance works but is loaded with sharp edges; understand the MRO (
Class.__mro__) before reaching for it. isinstance/issubclassfor type checks; prefer polymorphism for dispatch.ABC+@abstractmethodto enforce a contract on subclasses.- Favour composition when the relationship is has-a, not is-a. Mixins for orthogonal capability bundles.
Next: Dataclasses — generating __init__, __repr__, and __eq__ for free, plus the patterns for immutable and ordered data containers.