Inheritance & Dunder Methods
Inheritance & Dunder Methods
Inheritance
Inheritance lets a subclass reuse and specialise the behaviour of a base class:
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
raise NotImplementedError
class Dog(Animal):
def speak(self):
return f"{self.name} says Woof!"
class Cat(Animal):
def speak(self):
return f"{self.name} says Meow!"super()
super() delegates a method call to the parent class, making it easy to extend without fully replacing the parent's logic:
class ElectricCar(Car):
def __init__(self, make, model, battery_kwh):
super().__init__(make, model) # call Car.__init__
self.battery_kwh = battery_kwhMultiple Inheritance & MRO
Python allows a class to inherit from multiple bases. The Method Resolution Order (MRO) determines which method is used when the same name exists in multiple bases. Python uses the C3 linearisation algorithm:
class A: pass
class B(A): pass
class C(A): pass
class D(B, C): pass
print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)Use ClassName.__mro__ or help(ClassName) to inspect the MRO.
Dunder (Magic) Methods
Dunder methods let your objects integrate with Python's operators and built-in functions:
| Dunder | Triggered by |
|---|---|
__len__(self) | len(obj) |
__eq__(self, other) | obj == other |
__lt__(self, other) | obj < other |
__add__(self, other) | obj + other |
__contains__(self, item) | item in obj |
__iter__(self) | for x in obj |
__getitem__(self, key) | obj[key] |
dataclasses
The @dataclass decorator (Python 3.7+) auto-generates __init__, __repr__, and __eq__ based on annotated fields:
from dataclasses import dataclass, field
@dataclass(order=True)
class Point:
x: float
y: float
label: str = "" # field with default
tags: list = field(default_factory=list) # mutable defaultWith order=True, comparison operators (<, >, etc.) are generated automatically. Use frozen=True for an immutable dataclass.
Code Examples
class Vehicle:
def __init__(self, make, model, year):
self.make = make
self.model = model
self.year = year
def describe(self):
return f"{self.year} {self.make} {self.model}"
class Car(Vehicle):
def __init__(self, make, model, year, doors=4):
super().__init__(make, model, year)
self.doors = doors
def describe(self):
base = super().describe()
return f"{base} ({self.doors}-door)"
class ElectricCar(Car):
def __init__(self, make, model, year, battery_kwh):
super().__init__(make, model, year)
self.battery_kwh = battery_kwh
def describe(self):
base = super().describe()
return f"{base} [Electric, {self.battery_kwh} kWh]"
v = Vehicle("Generic", "Truck", 2020)
c = Car("Toyota", "Camry", 2022)
ev = ElectricCar("Tesla", "Model 3", 2024, 82)
print(v.describe())
print(c.describe())
print(ev.describe())
print("Is Car?", isinstance(ev, Car))
print("Is Vehicle?", isinstance(ev, Vehicle))Each subclass calls super().__init__() to initialise the parent's attributes. Overriding describe() and calling super().describe() extends rather than replaces the parent's behaviour. isinstance() checks the full inheritance chain.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Vector({self.x}, {self.y})"
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __mul__(self, scalar):
return Vector(self.x * scalar, self.y * scalar)
def __eq__(self, other):
return self.x == other.x and self.y == other.y
def __len__(self):
return 2 # always 2 dimensions
def __abs__(self):
return (self.x**2 + self.y**2) ** 0.5
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2)
print(v1 * 3)
print(v1 == Vector(1, 2))
print(len(v1))
print(f"|v2| = {abs(v2):.2f}")Implementing __add__ enables the + operator; __mul__ enables *; __eq__ enables ==; __abs__ enables abs(). Python maps operators to their corresponding dunder methods, giving your objects seamless integration with built-in syntax.
from dataclasses import dataclass, field
@dataclass(order=True)
class Student:
name: str
grade: float
courses: list = field(default_factory=list)
def enroll(self, course):
self.courses.append(course)
return self
def __str__(self):
return f"{self.name} (GPA: {self.grade:.1f}) — {', '.join(self.courses) or 'no courses'}"
s1 = Student("Alice", 3.8)
s2 = Student("Bob", 3.5)
s3 = Student("Carol", 3.9)
s1.enroll("Math").enroll("Physics")
s2.enroll("History")
print(s1)
print(s2)
# order=True enables comparison operators
students = [s3, s1, s2]
students.sort() # sorts by name (first field)
for s in students:
print(s.name, s.grade)@dataclass auto-generates __init__ and __repr__. order=True generates __lt__, __le__, __gt__, __ge__ based on the fields in order. field(default_factory=list) avoids the mutable-default-argument trap — each instance gets its own fresh list.
Quick Quiz
1. What does super().__init__() do inside a subclass __init__?
2. Which dunder method is called when you use the + operator on two objects?
3. What does the @dataclass decorator automatically generate?
Was this lesson helpful?