Inheritance and super()

A subclass gets everything the parent has and can replace any of it. super() calls the parent version, and it is how initialisation chains work.

Basic inheritance

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

    def speak(self):
        return "..."

    def describe(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 pet in [Dog("Rex"), Cat("Kaya"), Animal("Thing")]:
    print(pet.describe())

describe is written once in the parent and calls self.speak(). Because self is the actual object, the subclass version runs. That is the essence of inheritance and polymorphism working together.

Extending __init__ with super()

class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary
        self.active = True

    def describe(self):
        return f"{self.name}: {self.salary:,}"


class Manager(Employee):
    def __init__(self, name, salary, reports):
        super().__init__(name, salary)      # run the parent setup FIRST
        self.reports = reports              # then add your own

    def describe(self):
        return f"{super().describe()} (+{len(self.reports)} reports)"


m = Manager("Meera", 90_000, ["arun"])
print(m.name, m.active, m.reports)
print(m.describe())
Forgetting super().__init__(...) is the most common inheritance bug. The parent's attributes are never created, and the failure appears later as an AttributeError in a method that looks correct.
class Broken(Employee):
    def __init__(self, name, salary, reports):
        self.reports = reports          # parent __init__ never runs


b = Broken("Meera", 90_000, [])
# print(b.name)     # AttributeError: 'Broken' object has no attribute 'name'

super() is not "the parent class"

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())          # D -> B -> C -> A
print([cls.__name__ for cls in D.__mro__])
D -> B -> C -> A
['D', 'B', 'C', 'A', 'object']

Inside B.hello, super() resolved to C, not to A. super() means "the next class in this object's method resolution order", which depends on the object's actual type. With single inheritance it happens to be the parent; with multiple inheritance it is not. The MRO note covers this fully.

Overriding

class Notification:
    def format(self, message):
        return message

    def send(self, message):
        prepared = self.format(message)
        return f"sending: {prepared}"


class UpperNotification(Notification):
    def format(self, message):                 # replace entirely
        return message.upper()


class PrefixedNotification(Notification):
    def format(self, message):                 # extend the parent
        return "[ALERT] " + super().format(message)


for kind in [Notification(), UpperNotification(), PrefixedNotification()]:
    print(kind.send("disk full"))

Keep the signature compatible

class Base:
    def process(self, data):
        return data


class Bad(Base):
    def process(self, data, mode):        # requires an extra argument
        return data


class Good(Base):
    def process(self, data, mode="default"):    # optional, so it still substitutes
        return data


def run(processor, data):
    return processor.process(data)


print(run(Good(), "x"))
# print(run(Bad(), "x"))     # TypeError - Bad cannot stand in for Base

A subclass should be usable anywhere the parent is expected. Adding a required parameter breaks that, and it will break code that has no idea your subclass exists.

Checking relationships

class Animal: ...
class Dog(Animal): ...
class Puppy(Dog): ...


p = Puppy()

print(isinstance(p, Puppy))       # True
print(isinstance(p, Dog))         # True
print(isinstance(p, Animal))      # True
print(type(p) is Dog)             # False - exact type only

print(issubclass(Puppy, Animal))  # True
print(Puppy.__bases__)            # (<class 'Dog'>,)
print(Puppy.__mro__)              # the full lookup order

Attribute lookup order

class Parent:
    kind = "parent"

    def whoami(self):
        return "parent method"


class Child(Parent):
    kind = "child"


c = Child()
print(c.kind)          # child   - found on Child first
print(c.whoami())      # parent method - not on Child, so Parent is checked

Python looks on the instance, then the class, then each class in the MRO in turn, and raises AttributeError if nothing matches.

A worked hierarchy

class Account:
    """A bank account with a balance and a transaction history."""

    MINIMUM_BALANCE = 0

    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance
        self._history = []

    @property
    def balance(self):
        return self._balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("deposit must be positive")
        self._balance += amount
        self._history.append(("deposit", amount))
        return self._balance

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("withdrawal must be positive")
        if self._balance - amount < self.MINIMUM_BALANCE:
            raise ValueError("would breach the minimum balance")
        self._balance -= amount
        self._history.append(("withdraw", amount))
        return self._balance

    def statement(self):
        lines = [f"{type(self).__name__} for {self.owner}"]
        for kind, amount in self._history:
            lines.append(f"  {kind:<10}{amount:>10,.2f}")
        lines.append(f"  {'balance':<10}{self._balance:>10,.2f}")
        return "\n".join(lines)


class SavingsAccount(Account):
    MINIMUM_BALANCE = 1000            # the parent method now enforces this

    def __init__(self, owner, balance=0, rate=0.04):
        super().__init__(owner, balance)
        self.rate = rate

    def add_interest(self):
        interest = self._balance * self.rate
        self.deposit(interest)
        return interest


class CurrentAccount(Account):
    MINIMUM_BALANCE = -50_000         # an overdraft is allowed

    def __init__(self, owner, balance=0, overdraft_fee=500):
        super().__init__(owner, balance)
        self.overdraft_fee = overdraft_fee

    def withdraw(self, amount):
        result = super().withdraw(amount)
        if self._balance < 0:
            self._balance -= self.overdraft_fee
            self._history.append(("od fee", self.overdraft_fee))
        return self._balance


savings = SavingsAccount("Meera", 5000)
savings.add_interest()
print(savings.statement())

print()

current = CurrentAccount("Arun", 1000)
current.withdraw(3000)
print(current.statement())

Notice that MINIMUM_BALANCE is read as self.MINIMUM_BALANCE inside the parent, so each subclass changes the parent's behaviour by declaring one constant. That is inheritance used well.

Common mistakes

  • Forgetting super().__init__(...).
  • Calling Parent.__init__(self, ...) directly, which breaks with multiple inheritance.
  • Passing self to super(): it is super().__init__(x), not super().__init__(self, x).
  • Changing a method signature so the subclass cannot substitute for the parent.
  • Inheriting for code reuse when there is no "is a" relationship.
  • Building hierarchies more than three levels deep.

Best practices

  • Call super().__init__(...) first in every subclass initialiser.
  • Use super() with no arguments; the older two argument form is only for Python 2.
  • Keep overridden signatures compatible with the parent.
  • Put shared behaviour in the parent and vary it through methods or class constants.
  • Ask "is a" before inheriting; if the honest answer is "has a", use composition.

Practice

  1. Build a Shape parent with three subclasses, each overriding area.
  2. Demonstrate the failure caused by omitting super().__init__ and then fix it.
  3. Write a subclass that extends rather than replaces a parent method.
  4. Show a subclass that breaks substitutability and explain the consequence.
  5. Use a class constant in a parent method and change behaviour by overriding only that constant.

Conclusion

A subclass inherits everything and may override anything. super() calls the next class in the resolution order - which is the parent under single inheritance - and calling it in __init__ is what keeps an object properly built.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Python notes →
Python

Classes and Objects

A class describes a kind of thing; an object is one of them. Python builds every object the same way, and understanding that removes most of the myste...

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.