When two classes share behaviour, you have two ways to avoid writing it twice: inheritance, where one class is a kind of another, and composition, where one class has a helper inside it.
Inheritance
A subclass gets everything the parent has, and can add or replace parts.
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return "..."
def introduce(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("Momo"), Animal("Thing")]:
print(animal.introduce())introduce is written once, in Animal, but calls self.speak() — and
self is whichever animal it actually is. That is polymorphism: the same
call, different behaviour, decided by the object.
super()
When a subclass needs its own __init__ as well as the parent’s, super()
calls up the chain.
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
def describe(self):
return f"{self.name} earns {self.salary}"
class Manager(Employee):
def __init__(self, name, salary, reports):
super().__init__(name, salary) # run the parent's setup first
self.reports = reports # then add our own
def describe(self):
base = super().describe() # reuse the parent's logic
return f"{base} and manages {len(self.reports)} people"
ada = Employee("Ada", 90_000)
grace = Manager("Grace", 120_000, ["Ada", "Alan"])
print(ada.describe())
print(grace.describe())
print(isinstance(grace, Employee), isinstance(ada, Manager))isinstance(grace, Employee) is True: a Manager is an Employee. That
relationship is the test for whether inheritance is the right tool.
When inheritance is wrong
The classic mistake
A Car is not a kind of Engine, so class Car(Engine) is wrong however much
code it saves. A car has an engine. Inheritance models what something is;
composition models what it has.
class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower
def start(self):
return f"engine ({self.horsepower}hp) running"
class GPS:
def route(self, destination):
return f"routing to {destination}"
class Car:
def __init__(self, model, horsepower):
self.model = model
self.engine = Engine(horsepower) # has-a
self.gps = GPS() # has-a
def start(self):
return f"{self.model}: {self.engine.start()}"
car = Car("Saloon", 140)
print(car.start())
print(car.gps.route("Lisbon"))Composition keeps the pieces independently understandable and independently testable. Modern Python code uses it far more than inheritance.
Duck typing
Python does not require a shared base class for polymorphism. If an object has the method you call, it works — “if it walks like a duck and quacks like a duck”.
class Duck:
def speak(self):
return "Quack"
class Robot:
def speak(self):
return "Beep"
class Person:
def speak(self):
return "Hello"
# Nothing here inherits from anything, and it makes no difference:
for thing in [Duck(), Robot(), Person()]:
print(thing.speak())This is why Python code often has shallow class hierarchies: you rarely need a common parent just to treat objects the same way.
Checking types
# `pass` means "nothing here" - Python needs a body after the colon, and
# these three classes exist only to show the relationship between them.
class Animal: pass
class Dog(Animal): pass
class Puppy(Dog): pass
pup = Puppy()
print(isinstance(pup, Puppy), isinstance(pup, Dog), isinstance(pup, Animal))
print(type(pup) is Dog) # exact type - usually too strict
print(issubclass(Puppy, Animal))Prefer isinstance over type(x) is C — it accepts subclasses, which is
almost always what you meant.
Where Python looks: the MRO
When you call pup.speak(), Python does not search the whole family tree hoping
to find something. Each class carries a fixed, ordered list of the classes to
look in — its method resolution order, almost always written MRO — and
the first one that has a speak wins. __mro__ prints that list.
class Animal:
def speak(self):
return "..."
def breathe(self):
return "in and out"
class Dog(Animal):
def speak(self):
return "Woof" # overrides Animal's
class Puppy(Dog):
pass # adds nothing of its own
pup = Puppy()
print(pup.speak()) # Dog's - the first speak in the list
print(pup.breathe()) # Animal's - nothing nearer defines one
# __mro__ holds the classes themselves, which print as <class '__main__.Dog'>.
# This comprehension swaps each one for its .__name__ so the order is readable.
print(Puppy.__mro__)
print([cls.__name__ for cls in Puppy.__mro__])The order is the one you would have guessed: the class itself, then its parent,
then its parent, ending at object — the class every Python object inherits
from, and the reason print() and == work on a class you just wrote.
This list is also what super() walks, which is why super().describe() inside
Manager found Employee.describe earlier: Employee is simply the next name
after Manager in the MRO.
Why it has such a formal name
With one parent per class the order is just a chain, and __mro__ tells you
little you could not work out. It earns the name when a class has two parents
— class C(A, B) — where “look upwards” is ambiguous and Python computes a
single consistent order for you. You are unlikely to need those rules soon, but
ClassName.__mro__ will explain almost any “which method actually ran?”
surprise you ever hit.
Before you write your own
Predict. One subclass overrides send; the other doesn’t.
class Notification:
def __init__(self, message):
self.message = message
def send(self):
return f"sending: {self.message}"
class Email(Notification):
def send(self):
return "email " + super().send()
class SMS(Notification):
pass
print(Email("hi").send())
print(SMS("hey").send())
print(isinstance(Email("x"), Notification))Check your prediction
email sending: hi
sending: hey
TrueSMS has no send of its own, so Python finds the parent’s. Email has one,
and super().send() calls the parent’s version from inside it. An Email is
a Notification, which is what isinstance confirms.
Fix. This should print Ada 0 0.02.
class Account:
def __init__(self, owner):
self.owner = owner
self.balance = 0
class Savings(Account):
def __init__(self, owner, rate):
self.rate = rate
s = Savings("Ada", 0.02)
print(s.owner, s.balance, s.rate)Check your fix
class Account:
def __init__(self, owner):
self.owner = owner
self.balance = 0
class Savings(Account):
def __init__(self, owner, rate):
super().__init__(owner)
self.rate = rate
s = Savings("Ada", 0.02)
print(s.owner, s.balance, s.rate)Ada 0 0.02Defining __init__ in the subclass replaces the parent’s, so the parent’s
setup never ran and there was no owner. super().__init__(owner) runs it
first, then the subclass adds what’s new.
Shapes
Write a base class Shape with an area() that raises
NotImplementedError, then Rectangle(width, height) and Circle(radius)
subclasses that implement it. Print each shape’s area, then the total. The
numbers, to two decimal places:
Rectangle: 12.00
Circle: 12.57
Rectangle: 4.00
Total: 28.57from math import pi
class Shape:
def area(self):
raise NotImplementedError("subclasses must implement area()")
# Write Rectangle(width, height) and Circle(radius) here, then:
# shapes = [Rectangle(3, 4), Circle(2), Rectangle(2, 2)]Hint 1: the idea
Each subclass stores its own measurements in __init__ and overrides area()
with its own formula. The base class only promises that every shape has an
area. Because they share that one method, a single loop can ask any shape for
its area without caring which kind it is.
Hint 2: a first step
Write class Rectangle(Shape): with an __init__ that stores width and height
and an area that returns their product. Check print(Rectangle(3, 4).area())
shows 12 before writing Circle the same way (pi * radius ** 2).
Show one solution
from math import pi
class Shape:
def area(self):
raise NotImplementedError("subclasses must implement area()")
def describe(self):
return f"{type(self).__name__}: {self.area():.2f}"
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return pi * self.radius ** 2
shapes = [Rectangle(3, 4), Circle(2), Rectangle(2, 2)]
for shape in shapes:
print(shape.describe())
print(f"Total: {sum(shape.area() for shape in shapes):.2f}")raise NotImplementedError in the base turns “I forgot to implement area”
from a silent wrong answer into an immediate, clearly named error.
type(self).__name__ gives each subclass its own label without repeating
describe three times.
What you learned
class Child(Parent):inherits every attribute and method.- Overriding a method changes behaviour for that subclass only.
super()calls the parent’s version, typically inside__init__.- Use inheritance for “is a”, composition for “has a” — and prefer composition.
- Duck typing means a shared base class is often unnecessary.
__mro__is the ordered list of classes Python searches for an attribute;super()walks the same list.