datarekha

Inheritance vs Composition

class Child(Parent), super(), abstract base classes — and the 'composition over inheritance' rule that keeps codebases sane.

9 min read Intermediate Python Lesson 26 of 41

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:

INHERITANCE — “is-a”AnimalDogextendsCOMPOSITION — “has-a”UserServiceDatabaseheld as self._db
# 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 + @abstractmethod enforces 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

0/3
Q1What does `super().__init__(name)` do inside a subclass's __init__?
Q2What does `@abstractmethod` (with abc.ABC) buy you?
Q3When does composition typically beat inheritance?

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.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
What are Abstract Base Classes in Python, how do you define them, and how do they relate to duck typing?

Abstract 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.

When should you use composition instead of inheritance in Python, and what are the design signals for each?

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.

How does Python's Method Resolution Order (MRO) work, and what is the C3 linearization algorithm?

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()`.

How does `super()` work in Python, and why is the zero-argument form preferred over `super(ClassName, self)`?

`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.

Related lessons

Explore further

Cheat sheets
Skip to content