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 are the cleanest way to model "kinds of a thing": three shapes that all know how to compute their area, or four payment providers that all have a charge(amount) method.
Inheritance also has a cost. Deep hierarchies are hard to refactor, and when a base class changes, every subclass either breaks or has to be checked. This lesson teaches the syntax, the method lookup rules, abstract base classes, and, most importantly, when to use 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, and anything else Animal defines or inherits. It can add new methods (fetch), override existing ones, or do both.
isinstance(rex, Dog) is True. isinstance(rex, Animal) is also True, because a Dog is an 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 field that only Dog has rex = Dog("Rex", "Border Collie") print(rex.name, rex.breed) # Rex Border Collie
If you forget super().__init__(...), rex.name raises AttributeError. super() returns a stand-in object for the parent class: 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 the parent method, not replace it. To do that, call super().method(...) and add to its result:
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." # replaces the parent method completely class PoliteDog(Dog): def speak(self): base = super().speak() return f"{base} Then sits politely." # adds to the parent's result 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) # works for any 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 each object runs its own area method. That's polymorphism. If you add a Hexagon class later, total_area needs no changes.
This is sometimes called duck typing, from the saying 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.
This is useful when each parent adds a separate, independent ability ("can swim", "can fly"). It causes problems when the parents overlap: if both parents define the same method, the order of the parents decides which one runs. For this reason, most Python code keeps to small mixins (Section 10) or uses composition.
6. MRO and the Diamond
When you call obj.method(), Python searches the classes in a fixed order, called the Method Resolution Order (MRO). For single inheritance the order is simple: 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, and both of them inherit from A. Without C3, Python would either visit A twice or have to pick an order by some arbitrary rule. The MRO gives you one unambiguous path: D → B → C → A → object. Read D.__mro__ whenever multiple inheritance confuses you.
7. isinstance and issubclass
class Animal: pass class Dog(Animal): pass class Cat(Animal): pass rex = Dog() print(isinstance(rex, Dog)) # True print(isinstance(rex, Animal)) # True (a Dog is also an Animal) print(isinstance(rex, (Dog, Cat))) # True if rex is either print(issubclass(Dog, Animal)) # True print(issubclass(Dog, object)) # True (every class inherits from object)
Both accept a tuple of types. isinstance(x, (int, float)) is the usual way to check "is this a number?".
Use isinstance to protect a function against bad input, or to choose what to do when polymorphism doesn't fit. Use it less than you might expect. If you write if isinstance(x, Cat): ... elif isinstance(x, Dog): ..., you are doing by hand what method calls already do. Move each branch into a method on its own class.
8. Abstract Base Classes
Sometimes you want to guarantee that subclasses implement a method. The abc module lets you state this rule in code:
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 without # # an implementation for abstract method 'area'
You can't create a Shape directly: Python raises TypeError. You also can't create an instance of a subclass that forgets to implement area. Python checks this when you create the object, not later when the missing method is called. 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 says a Car IS-A Engine. That is wrong. class Engine: def start(self): print("vroom") class Car(Engine): # awkward, because a car is not an engine pass # Composition says a 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()
Both versions behave the same from the outside. The composition version is easier to test (you can swap in a MockEngine) and easier to change (the engine can change on its own). It also doesn't give Car every method of Engine. The guideline favour composition over inheritance comes from the "Gang of Four" Design Patterns book, and it still holds today.
Use inheritance when there's a real 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 doesn't make sense on its own, so you never create an instance of a mixin directly.
from datetime import datetime class TimestampMixin: """Adds created_at and 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 give each one a single job. Frameworks such as Django use mixins heavily: LoginRequiredMixin, JSONResponseMixin, and so on.
Common Mistakes
1. Deep hierarchies. Three or more levels of inheritance (Animal → Mammal → Dog → PoliteDog) become fragile. A change at the top affects every level below it. Make the hierarchy flatter with composition or fewer abstractions.
2. Inheriting from concrete classes for code reuse. If you only want one method from another class, don't inherit. You would get every other method of that class too. 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). This is why public libraries are careful with large inheritance hierarchies.
4. isinstance(x, dict) to special-case behaviour. This is often a sign of a design problem: you are branching on type instead of asking the object what it can do. Prefer duck typing or polymorphism. A good use of it: checking input where it enters 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 to call 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, with 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), using Heron's formula for the area def total_area(shapes): # TODO 6: add up 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 that total_area doesn't know the shape types. If you add Hexagon(side) tomorrow and give it area() and perimeter(), total_area works with it without any change. That is the benefit of polymorphism.
What You Learned
class Child(Parent):for inheritance, andsuper().__init__(...)to call the parent's initialiser.- Method overriding replaces, and
super().method(...)extends. - Polymorphism: the same method name with different implementations, so callers don't need to branch on type.
- Multiple inheritance works but can cause surprising bugs. Understand the MRO (
Class.__mro__) before you use it. isinstance/issubclassfor type checks, but prefer polymorphism for dispatch.ABC+@abstractmethodto enforce a contract on subclasses.- Favour composition when the relationship is has-a, not is-a. Use mixins to add small, independent abilities.
Next: Dataclasses: generating __init__, __repr__, and __eq__ automatically, plus the patterns for immutable and ordered data containers.
Found this useful? Share it with a friend who’s learning Python.