Inheritance vs Composition
class Child(Parent), super(), abstract base classes — and the 'composition over inheritance' rule that keeps codebases sane.
What you'll learn
- class Child(Parent), super(), and method overriding
- Polymorphism, and a brief look at Method Resolution Order
- Abstract base classes with abc.ABC and @abstractmethod
- Why "has-a" composition usually beats "is-a" inheritance
Before you start
Inheritance is one of the most over-used features in all of object-oriented programming. Used well, it captures a genuine “is-a” relationship and keeps shared behaviour in exactly one place. Used badly, it grows a five-level-deep hierarchy where a single change means editing three files and praying. So this lesson teaches inheritance properly — and then teaches you when not to use it, because the senior-engineer instinct is to prefer composition unless inheritance truly fits.
The basic syntax
class Animal:
def __init__(self, name):
self.name = name
def describe(self):
return f"{self.name} is an animal"
def speak(self):
return "..."
class Dog(Animal): # Dog inherits from Animal
def speak(self): # override the parent's method
return "Woof"
class Cat(Animal):
def speak(self):
return "Meow"
a = Dog("Rex")
print(a.describe()) # inherited from Animal, untouched
print(a.speak()) # overridden in Dog
print(isinstance(a, Animal)) # a Dog IS an Animal
Rex is an animal
Woof
True
Dog(Animal) reads as “Dog inherits from Animal”. Every method on Animal is automatically available on Dog unless Dog defines its own — so describe came straight from the parent while speak was overridden. And isinstance(a, Animal) confirms Python sees the relationship.
super() — calling the parent
The most common reason to inherit is to extend __init__: the child wants all of the parent’s setup and some state of its own. super() is how you reach the parent:
class Animal:
def __init__(self, name):
self.name = name
def describe(self):
return f"{self.name} is an animal"
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name) # run Animal's setup first
self.breed = breed # then add Dog-specific state
def describe(self):
base = super().describe() # augment, don't replace
return f"{base} (a {self.breed})"
d = Dog("Rex", "labrador")
print(d.name, d.breed)
print(d.describe())
Rex labrador
Rex is an animal (a labrador)
That pattern — super().method(...), do the parent’s work, then add yours — is what makes inheritance composable rather than a copy-paste. And mind the trap: Python does not call the parent’s __init__ for you. Forget super().__init__(name) and the child has none of the parent’s attributes, which you will discover the moment self.name raises AttributeError.
Polymorphism — the payoff
The whole reason to build the hierarchy is this: code that needs only Animal’s interface can treat a Dog and a Cat identically.
class Animal:
def __init__(self, name): self.name = name
def speak(self): return "..."
class Dog(Animal):
def speak(self): return "Woof"
class Cat(Animal):
def speak(self): return "Meow"
class Cow(Animal):
def speak(self): return "Moo"
def chorus(animals):
# Does not care WHICH animal — only that each can .speak()
return ", ".join(a.speak() for a in animals)
zoo = [Dog("Rex"), Cat("Mittens"), Cow("Bessie"), Dog("Spot")]
print(chorus(zoo))
Woof, Meow, Moo, Woof
That is polymorphism — one function, many behaviours. chorus is closed to change but open to extension: add a Pig(Animal) tomorrow and chorus works on it with no edits at all.
Method Resolution Order, briefly
Python allows multiple inheritance, and when two parents define the same method, it needs a rule for which wins. That rule is the Method Resolution Order (MRO) — the deterministic, left-to-right sequence Python searches:
class A:
def f(self): return "A"
class B(A):
def f(self): return "B"
class C(A):
def f(self): return "C"
class D(B, C):
pass
print(D().f()) # which f() wins?
print([c.__name__ for c in D.__mro__]) # the exact search order
B
['D', 'B', 'C', 'A', 'object']
Python computes that order with the C3 linearization algorithm, so it is always well-defined. But take the warning in the output seriously: if your hierarchy is tangled enough that you need to reason about MRO, the design has usually gone wrong. Most real code uses single inheritance for behaviour, plus the occasional mixin for a cross-cutting concern.
Abstract base classes — defining an interface
When you want to declare “every subclass MUST implement these methods”, use abc.ABC and @abstractmethod. An abstract base class cannot be instantiated on its own, and Python refuses to instantiate any subclass that left an abstract method unimplemented:
from abc import ABC, abstractmethod
class UserRepository(ABC):
"""Anything that stores users must implement these four methods."""
@abstractmethod
def get(self, user_id): ...
@abstractmethod
def list_all(self): ...
@abstractmethod
def save(self, user): ...
@abstractmethod
def delete(self, user_id): ...
class InMemoryUserRepo(UserRepository):
def __init__(self): self._db = {}
def get(self, user_id): return self._db.get(user_id)
def list_all(self): return list(self._db.values())
def save(self, user): self._db[user["id"]] = user
def delete(self, user_id): self._db.pop(user_id, None)
# Works — every abstract method is implemented.
repo = InMemoryUserRepo()
repo.save({"id": 1, "name": "Aarav"})
print(repo.get(1))
# Instantiating the abstract base itself is an error.
try:
UserRepository()
except TypeError as e:
print("error:", e)
# Forgetting a method is the same error.
class BrokenRepo(UserRepository):
def get(self, user_id): pass # missing list_all, save, delete
try:
BrokenRepo()
except TypeError as e:
print("error:", e)
{'id': 1, 'name': 'Aarav'}
error: Can't instantiate abstract class UserRepository without an implementation for abstract methods 'delete', 'get', 'list_all', 'save'
error: Can't instantiate abstract class BrokenRepo without an implementation for abstract methods 'delete', 'list_all', 'save'
This is the Repository pattern: declare the interface that data access must satisfy, then provide implementations — in-memory for tests, Postgres for production, S3 for backups. Your business logic depends on the interface, never a specific implementation, and forgetting a method fails loudly at instantiation rather than at 3 a.m.
Composition usually wins
Inheritance is for “is-a”: a Dog is an Animal, a Mallard is a Duck. The moment the relationship is really “has-a” or “uses-a”, composition is cleaner — and the difference is worth seeing side by side:
# BAD: inheritance for what is really "has-a".
class Database:
def query(self, sql): return f"running: {sql}"
class UserServiceBad(Database): # a UserService is NOT a Database
def list_active(self):
return self.query("SELECT * FROM users WHERE active = true")
# GOOD: composition — a UserService HAS a Database.
class UserServiceGood:
def __init__(self, db):
self._db = db # the database is a dependency, passed in
def list_active(self):
return self._db.query("SELECT * FROM users WHERE active = true")
svc = UserServiceGood(Database())
print(svc.list_active())
# Swap in a fake for tests — no inheritance needed.
class FakeDB:
def query(self, sql): return [{"id": 1, "name": "Aarav"}]
print(UserServiceGood(FakeDB()).list_active())
running: SELECT * FROM users WHERE active = true
[{'id': 1, 'name': 'Aarav'}]
The inheritance version technically works, but it conflates two concepts: UserServiceBad inherits every Database method whether it wants them or not, and you cannot swap the database without rewriting the class. The composition version is testable, swappable, and free of unrelated methods — see how trivially FakeDB slotted in. The rule of thumb: start with composition; reach for inheritance only when you genuinely have an “is-a” relationship with shared behaviour worth extracting.
Mixins — one capability, not a hierarchy
The controlled middle ground is the mixin: a small class that adds one capability, mixed into otherwise-unrelated classes. By convention a mixin defines no __init__ and carries no state of its own — it does one thing, well:
class JsonMixin:
def to_json(self):
return json.dumps(self.__dict__, default=str)
class TimestampMixin:
def touch(self):
self.updated_at = datetime.now(timezone.utc)
class User(JsonMixin, TimestampMixin):
def __init__(self, name):
self.name = name
self.touch()
In one breath
class Child(Parent)inherits all the parent’s methods; override by redefining them.- Call
super().__init__(...)yourself — Python will not call the parent’s constructor for you. - Polymorphism lets one function work on any subclass that honours the interface.
abc.ABC+@abstractmethodenforces an interface; missing methods fail at instantiation.- Prefer composition (“has-a”, dependency passed in) over inheritance (“is-a”) unless the relationship is truly is-a.
Practice
Quick check
What’s next
You have seen classes and inheritance. Next we go the other direction — into the dunder methods (double-underscore methods) that let your own classes work with Python’s operators and built-in functions.
Practice this in an interview
All questionsAbstract Base Classes (ABCs) from the `abc` module let you declare interfaces with `@abstractmethod` — any concrete subclass that does not implement all abstract methods raises `TypeError` at instantiation. ABCs coexist with duck typing: you can register unrelated classes as virtual subclasses without inheritance, and `isinstance` checks will pass.
Inheritance models an 'is-a' relationship and is appropriate when a subclass is genuinely a specialisation of the parent. Composition models a 'has-a' relationship and is preferred when you want to reuse behaviour without coupling to a class hierarchy — it is more flexible, easier to test, and avoids the fragile base-class problem.
Python resolves method lookups by computing a linearized list of classes called the MRO using the C3 linearization algorithm, which guarantees that a class always appears before its parents and that the local precedence order declared in each class definition is preserved. You can inspect it via `ClassName.__mro__` or `ClassName.mro()`.
`super()` returns a proxy that delegates method calls to the next class in the MRO, not necessarily the direct parent. The zero-argument form `super()` (Python 3) is preferred because it uses a compiler-injected `__class__` cell, which correctly tracks the defining class even under multiple inheritance — avoiding the brittle repetition of the class name.