Object-oriented Python · about 50 min

Inheritance, super() and composition

Why this matters

Many types are variations of one another: a savings account is an account with interest; a FileNotFoundError is a kind of OSError; a square is a kind of rectangle. Inheritance lets a class reuse and extend another class's code, and lets code written for the general type work with the specific ones. It is also one of the most overused tools in programming. This lesson shows how inheritance works in Python (including the method resolution order behind super()), how to define interfaces with abstract base classes, and when to prefer composition, building objects out of other objects, instead.

Subclassing

Put the parent class (the base class or superclass) in parentheses:

class Account:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        self.balance += amount

    def describe(self):
        return f"{self.owner}: {self.balance:.2f}"


class SavingsAccount(Account):
    def __init__(self, owner, balance=0, rate=0.02):
        super().__init__(owner, balance)   # let Account set up its part
        self.rate = rate

    def add_interest(self):
        self.deposit(self.balance * self.rate)

    def describe(self):                    # override
        return super().describe() + f" (savings at {self.rate:.0%})"


s = SavingsAccount("Ada", 100)
s.add_interest()
print(s.describe())
print(isinstance(s, Account), issubclass(SavingsAccount, Account))
Ada: 102.00 (savings at 2%)
True True

What happened:

Every class without an explicit base inherits from object, which supplies defaults such as __repr__, __eq__ (identity) and __init__.

Dynamic dispatch

Methods are looked up on the actual object at run time. A base-class method that calls self.something() gets whatever the subclass provides:

class Report:
    def render(self):
        return self.header() + "\n" + self.body()
    def header(self):
        return "REPORT"
    def body(self):
        return "(empty)"

class SalesReport(Report):
    def body(self):
        return "sales: 42"

print(SalesReport().render())
REPORT
sales: 42

render is written once; subclasses fill in the parts. This is how frameworks let you customize behavior by overriding a method.

isinstance(obj, Cls) is true for instances of Cls and its subclasses. Prefer it over type(obj) is Cls, which rejects subclasses. Both accept a tuple: isinstance(x, (int, float)).

The MRO and what super() really does

Python allows multiple inheritance: class D(B, C). To decide where lookup goes, each class has a method resolution order, computed once when the class is created (by an algorithm called C3 linearization):

class A: pass
class B(A): pass
class C(A): pass
class D(B, C): pass

print([k.__name__ for k in D.__mro__])
['D', 'B', 'C', 'A', 'object']

The MRO lists each class once, children before parents, and keeps the left-to-right order of bases. Attribute lookup walks this list.

super() does not mean "my parent class". It means "the next class after the current one in the MRO of type(self)". In a diamond like the one above, super() inside B goes to C when the object is a D, so every class's method runs exactly once, provided each class calls super(). That is called cooperative inheritance. In single-inheritance code (most code) the next class simply is the parent, and you can think of it that way.

Behind the scenes, the zero-argument super() works because the compiler gives methods defined in a class a hidden reference to that class (__class__), and super combines it with self.

Abstract base classes

Sometimes a base class exists only to define an interface: "every shape has an area". The abc module enforces it:

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        ...

    def describe(self):
        return f"area {self.area():.1f}"

class Circle(Shape):
    def __init__(self, r):
        self.r = r
    def area(self):
        return 3.14159 * self.r ** 2

print(Circle(1).describe())
Shape()
area 3.1
TypeError: Can't instantiate abstract class Shape without an implementation for abstract method 'area'

A subclass that forgets to implement area fails the same way when instantiated, which catches the mistake early rather than when area is finally called. The ... (Ellipsis) is a conventional "no body here" placeholder, like pass. The standard library defines many ABCs in collections.abc (Iterable, Mapping, Sequence); isinstance(x, collections.abc.Iterable) asks "does x support iteration?".

Remember also that Python mostly relies on duck typing: if an object has the methods you call, it works, whatever its class. ABCs are for when you want the interface written down and checked.

Composition: has-a versus is-a

Inheritance models is-a relationships. Use it only when the subclass truly is a specialized version that can be used anywhere the base is expected (this is called the Liskov substitution principle). Otherwise, use composition: the object has another object and delegates to it.

A classic mistake is class Stack(list). It saves a few lines, but a stack is not a list: users can now call insert(0, x), sort(), or index into the middle, all of which violate stack rules, and you can never change the internal representation without breaking them. With composition:

class Stack:
    def __init__(self):
        self._items = []          # has-a list
    def push(self, x):
        self._items.append(x)
    def pop(self):
        return self._items.pop()

The public interface is exactly what you chose. Other signs that composition fits: you want to swap the part at run time (a Car has an Engine, electric or petrol), or you would need to inherit from two unrelated things. A short rule: favor composition; inherit when the is-a relationship is real and you want polymorphism.

Common mistakes

Recap

Going further: print bool.__mro__ and FileNotFoundError.__mro__ to see real hierarchies, and read the abc and collections.abc module docs.

Exercises

Exercise 1 · quiz

Which method runs?

What does this print? Type the two words separated by a space.

class Animal:
    def speak(self):
        return "..."
    def intro(self):
        return self.speak()

class Cat(Animal):
    def speak(self):
        return "meow"

print(Animal().intro(), Cat().intro())
Answer

intro is defined only in Animal, but self.speak() is looked up on the actual object at run time. For a Cat, lookup finds Cat.speak first. This is dynamic dispatch: a base-class method can call behavior that subclasses customize, which is the backbone of the "template method" pattern.

Exercise 2 · write code

A shape hierarchy with an abstract base

In solution.py:

  1. Write an abstract base class Shape (inherit from abc.ABC) with an abstract method area(self) and a concrete method describe(self) returning f"{self.name} with area {self.area():.2f}". Shape.__init__(self, name) stores name.
  2. Write Rectangle(Shape) with __init__(self, w, h) that calls super().__init__("rectangle") and stores w, h; its area returns w * h.
  3. Write Square(Rectangle) with __init__(self, side) that calls super().__init__(side, side) and then sets self.name = "square".
  4. Write Circle(Shape) with __init__(self, r), name "circle", area math.pi * r ** 2.

Shape("x") must raise TypeError because it is abstract. For example Square(3).describe() returns square with area 9.00.

File: solution.py

Starting point

import math
from abc import ABC, abstractmethod


class Shape:
    def __init__(self, name):
        self.name = name

    def area(self):
        return 0

    def describe(self):
        return f"{self.name} with area {self.area():.2f}"
Hint 1

class Shape(ABC): plus @abstractmethod above def area(self): makes Shape impossible to instantiate.

Hint 2

Each subclass __init__ should call super().__init__(...) so the parent sets up its part.

Hint 3

Square only needs __init__; it inherits area from Rectangle.

Reference solution
import math
from abc import ABC, abstractmethod


class Shape(ABC):
    def __init__(self, name):
        self.name = name

    @abstractmethod
    def area(self):
        ...

    def describe(self):
        return f"{self.name} with area {self.area():.2f}"


class Rectangle(Shape):
    def __init__(self, w, h):
        super().__init__("rectangle")
        self.w = w
        self.h = h

    def area(self):
        return self.w * self.h


class Square(Rectangle):
    def __init__(self, side):
        super().__init__(side, side)
        self.name = "square"


class Circle(Shape):
    def __init__(self, r):
        super().__init__("circle")
        self.r = r

    def area(self):
        return math.pi * self.r ** 2

Exercise 3 · quiz

Method resolution order

Given

class A:
    def hello(self): return "A"
class B(A):
    def hello(self): return "B" + super().hello()
class C(A):
    def hello(self): return "C" + super().hello()
class D(B, C):
    def hello(self): return "D" + super().hello()

print(D().hello())

what is printed?

Answer
  • DBCA

D.__mro__ is (D, B, C, A, object). super() does not mean "my parent", it means "the next class after me in the MRO of the object's actual type". So super() inside B (for a D instance) goes to C, not A, and each class runs exactly once: DBCA. This cooperative behavior is why every class in a hierarchy should call super().

Exercise 4 · write code

Composition over inheritance

A tempting design is class Stack(list), but then callers can also insert(0, x) or sort() your stack, breaking its rules. Use composition instead.

In solution.py, write a class Stack that has a list (in an attribute _items) rather than being one:

Stack must not inherit from list, and must not have insert or sort attributes.

File: solution.py

Starting point

class Stack(list):
    def push(self, x):
        self.append(x)
Hint 1

class Stack: with self._items = [] in __init__.

Hint 2

Each method delegates to the list: self._items.append(x), self._items.pop(), self._items[-1].

Hint 3

Check if not self._items: and raise IndexError("pop from empty stack").

Reference solution
class Stack:
    def __init__(self):
        self._items = []

    def push(self, x):
        self._items.append(x)

    def pop(self):
        if not self._items:
            raise IndexError("pop from empty stack")
        return self._items.pop()

    def peek(self):
        if not self._items:
            raise IndexError("pop from empty stack")
        return self._items[-1]

    def __len__(self):
        return len(self._items)

    def __repr__(self):
        return f"Stack({self._items!r})"