HSHimanshu Sharma
Python Notes

Python OOP part 2: inheritance, has-a vs is-a, MRO and super()

Python OOP part 2: inheritance, has-a vs is-a, MRO and super() Advertisement

In part 1 you built single classes. Real programs have many related classes: a Car and a Truck are both Vehicles, and a Car also has an Engine. Python offers two tools for these relationships, inheritance and composition, and choosing between them is one of the most important design decisions in object-oriented code. This post explains both relationships, the different shapes of inheritance, why method resolution order exists and how super() uses it. Every output below was produced by running the code on Python 3.9.

Two kinds of relationship

Before writing code, ask which sentence is true:

  • "A dog is a kind of animal." This is an IS-A relationship, expressed with inheritance.
  • "A car has an engine." This is a HAS-A relationship, expressed with composition (or aggregation), where one object holds another as an attribute.

HAS-A: composition and aggregation

In a HAS-A relationship, one class creates or stores objects of another class and uses their methods:

class Engine:
    def __init__(self, hp):
        self.hp = hp
    def start(self):
        return f"engine {self.hp}hp started"

class Car:
    def __init__(self, name, hp):
        self.name = name
        self.engine = Engine(hp)       # the car has an engine

    def start(self):
        return f"{self.name}: " + self.engine.start()

print(Car("Swift", 90).start())        # Swift: engine 90hp started

Notice that Car has no code for how an engine starts; it delegates to Engine. There are two flavours:

Composition Aggregation
Meaning The part cannot exist without the whole The part can exist on its own
Created inside the whole (Engine(hp) above) outside, and passed in
Example a house and its rooms; a car and its engine a university and its professors; a library and its books
class Professor:
    def __init__(self, name):
        self.name = name

class Department:
    def __init__(self, name):
        self.name = name
        self.staff = []                 # professors are added from outside
    def hire(self, prof):
        self.staff.append(prof)

p = Professor("Rao")
d1, d2 = Department("Maths"), Department("Physics")
d1.hire(p); d2.hire(p)                  # the same professor serves both
del d1
print(p.name, [s.name for s in d2.staff])        # Rao ['Rao']

The professor outlives the department that was deleted: that is aggregation. Use HAS-A whenever the relationship is "owns" or "uses", or when you only need part of another class's behaviour.

IS-A: inheritance

Inheritance lets a new class (the child, subclass or derived class) take everything from an existing class (the parent, superclass or base class) and then add or change what it needs. Write the parent in brackets:

class Animal:
    def __init__(self, name):
        self.name = name
    def eat(self):
        return f"{self.name} eats"
    def speak(self):
        return "..."

class Dog(Animal):
    def speak(self):                   # overrides the parent's method
        return "Woof"
    def fetch(self):                   # new behaviour
        return f"{self.name} fetches"

d = Dog("Rex")
print(d.eat(), d.speak(), d.fetch())          # Rex eats Woof Rex fetches
print(isinstance(d, Dog), isinstance(d, Animal), issubclass(Dog, Animal))
# True True True
print(Dog.__bases__, Dog.__mro__[1].__name__)
# (<class '__main__.Animal'>,) Animal

Dog did not define __init__ or eat, yet d.eat() works because Python looks in Dog first and then in its parent. Redefining a method in the child is called overriding. Every class in Python ultimately inherits from object.

IS-A versus HAS-A: which one to use

Use inheritance when the child really is a more specific kind of the parent and can be used wherever the parent is expected. A Dog can be used wherever an Animal is. Do not use inheritance just to reuse code: a Stack is not a kind of list, so it is cleaner for a Stack to have a list. A good rule of thumb is "prefer composition over inheritance"; inheritance couples the child tightly to the parent's details, and a change in the parent can break every child.

Types of inheritance

Single inheritance

One parent, one child. This is the Animal and Dog example above.

Multilevel inheritance

A chain: child, parent and grandparent.

class A:
    def who(self): return "A"
class B(A):
    pass
class C(B):
    pass
print(C().who(), [k.__name__ for k in C.__mro__])      # A ['C', 'B', 'A', 'object']

Hierarchical inheritance

One parent with several children:

class Shape:
    def area(self): raise NotImplementedError
class Square(Shape):
    def __init__(self, s): self.s = s
    def area(self): return self.s ** 2
class Circle(Shape):
    def __init__(self, r): self.r = r
    def area(self): return round(3.14159 * self.r ** 2, 2)
print([x.area() for x in (Square(3), Circle(2))])       # [9, 12.57]

Multiple inheritance

A child with more than one parent. Python supports it directly:

class Walker:
    def move(self): return "walk"
    def walk_only(self): return "walking"
class Swimmer:
    def move(self): return "swim"
class Duck(Walker, Swimmer):
    pass
d = Duck()
print(d.move(), d.walk_only())          # walk walking

Both parents define move. Python used Walker's because it is listed first. Which method wins when there are several candidates is decided by the MRO, explained next.

Hybrid inheritance

A combination of the types above. The usual example is the diamond: two classes inherit from the same parent, and a fourth class inherits from both.

class Base:
    def hello(self): return "Base"
class Left(Base):
    def hello(self): return "Left>" + super().hello()
class Right(Base):
    def hello(self): return "Right>" + super().hello()
class Bottom(Left, Right):
    def hello(self): return "Bottom>" + super().hello()

print(Bottom().hello())
# Bottom>Left>Right>Base

Notice that Base ran once, and Right ran between Left and Base even though Left inherits directly from Base. That surprising order is the MRO at work.

Cyclic inheritance

Cyclic inheritance means A inherits from B while B inherits from A. It makes no sense, and Python does not allow it:

try:
    class X(Y): pass
except NameError as e:
    print("Y does not exist yet:", e)

try:
    class P: pass
    class Q(P): pass
    P.__bases__ = (Q,)
except TypeError as e:
    print(e)           # a __bases__ item causes an inheritance cycle

A class must exist before another can inherit from it, so you cannot even write the cycle in normal code.

Method Resolution Order (MRO)

When you call obj.method(), Python searches the classes in a fixed order called the method resolution order. For single inheritance it is simply child, parent, grandparent, up to object. With multiple inheritance, Python uses an algorithm called C3 linearisation, which follows two rules: a child comes before its parents, and the order in which you list the parents is kept. You can see the result:

print([k.__name__ for k in Bottom.__mro__])
# ['Bottom', 'Left', 'Right', 'Base', 'object']
print(Bottom.mro() == list(Bottom.__mro__))        # True

Base comes after both Left and Right, so every class appears before its parents. If the rules cannot be satisfied, Python refuses to create the class:

class M: pass
class N(M): pass
try:
    class Bad(M, N): pass            # M must come before N, but N is a child of M
except TypeError as e:
    print(e)

The message begins Cannot create a consistent method resolution order (MRO) for bases M, N. Reorder the parents (Bad(N, M)) to fix it. In practice, keep inheritance trees small, so you rarely need to think about the MRO.

super()

super() gives access to the next class in the MRO. Its most common job is running the parent's constructor, so that the parent can set up its own part of the object.

class Person:
    def __init__(self, name, age):
        self.name, self.age = name, age

class Employee(Person):
    def __init__(self, name, age, salary):
        super().__init__(name, age)       # let Person set name and age
        self.salary = salary

    def __repr__(self):
        return f"Employee({self.name!r}, {self.age}, {self.salary})"

print(Employee("Asha", 30, 50000))        # Employee('Asha', 30, 50000)

If a child defines __init__ and forgets to call the parent's, the parent's attributes are never created:

class Broken(Person):
    def __init__(self, salary):
        self.salary = salary
b = Broken(100)
print(hasattr(b, "name"))                  # False

super() is also used to extend, instead of replace, an overridden method: call super().method() and then add your own steps. In the diamond example above, each hello called super().hello(), and the chain visited Left, Right and Base in MRO order. This is why super() means "next in the MRO", not "my parent". You can still call a particular parent explicitly with Parent.method(self), but in multiple inheritance that can run a shared base twice.

Checking and using the relationship

class Vehicle: pass
class Car(Vehicle): pass
c = Car()
print(isinstance(c, Vehicle), type(c) is Vehicle, type(c) is Car, issubclass(Car, Vehicle))
# True False True True

isinstance respects inheritance and type(c) is X does not, so prefer isinstance in checks. To see where a method comes from, look at the MRO, or use Car.speak.__qualname__, which names the class that defined the method.

Common mistakes

  • Using inheritance for code reuse when the relationship is HAS-A.
  • Forgetting to call super().__init__() in a child constructor.
  • Calling super().__init__() with the wrong arguments when the parent expects more.
  • Building deep multiple-inheritance trees that nobody can follow.
  • Overriding a method with a different signature, so the child breaks code that expected the parent.
  • Expecting type(x) is Parent to be true for an instance of a child class.

Practice

Create a Vehicle class with wheels and describe(), then Car and Bike children that override describe(). Add an Owner class that has a list of vehicles. Next, build the diamond example with a fifth class and print its MRO before you run it, to see whether your prediction matches.

Continue

The next part covers how the same method name behaves differently in different classes: polymorphism. If anything about objects feels unclear, return to part 1, and see the video lessons Episode 61, Episode 62 and Episode 63. The Python video course lays out the full path.

Advertisement