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
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))
# The method resolution order: where Python looks, in order.
print([cls.__name__ for cls in Puppy.__mro__])Prefer isinstance over type(x) is C — it accepts subclasses, which is
almost always what you meant.
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.
from 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)]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.