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.
- Basics
- Data Types
- Operators
- Strings
- Control Flow
- Lists
- Tuples
- Sets
- Dictionaries
- Comprehensions
- Functions
- Advanced Functions
- Recursion
- Exception Handling
- File Handling
- Modules
- Standard Library
- OOP
- Advanced OOP
- Iterators and Generators
- Decorators
- Context Managers
- Descriptors and Dataclasses
- Python Internals
- Concurrency
- Regular Expressions
- Serialization
- Command Line Python
- Testing and Debugging
- Type Hints
- Performance
- Python Security
- DSA with Python
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())Forgettingsuper().__init__(...)is the most common inheritance bug. The parent's attributes are never created, and the failure appears later as anAttributeErrorin 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 BaseA 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 orderAttribute 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 checkedPython 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
selftosuper(): it issuper().__init__(x), notsuper().__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
- Build a
Shapeparent with three subclasses, each overridingarea. - Demonstrate the failure caused by omitting
super().__init__and then fix it. - Write a subclass that extends rather than replaces a parent method.
- Show a subclass that breaks substitutability and explain the consequence.
- 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.