Skip to content

Inheritance, Composition & Dunder Methods

What this lesson gives you

Reuse behavior through inheritance or composition, and make your objects feel native by implementing dunder (double-underscore) methods.

Estimated time: 22 min read · Part: Object-Oriented Python

Learning objectives

  • Understand Python's Method Resolution Order (MRO) and how multiple inheritance works
  • Use super() correctly to delegate to parent implementations
  • Implement the essential dunder methods: __repr__, __str__, __eq__, __hash__, __len__, __add__, __contains__
  • Distinguish "is-a" (inheritance) from "has-a" (composition) and know when each is appropriate
  • Use mixins for horizontal reuse without deep hierarchies

Inheritance models an "is-a" relationship: a Dog is-a Animal, so it inherits all of Animal's behavior and can override or extend it. Composition models a "has-a" relationship: a Car has-an Engine. Dunder methods (double-underscore, also called "magic methods") make your objects integrate with Python's built-in syntax and functions — len(obj), obj + other, obj in container, iteration, context management.

The most important design insight in OOP: inheritance is powerful but brittle when overused. A deep class hierarchy tightly couples descendants to the implementation details of their ancestors — a change in a base class ripples unpredictably through the tree. Composition is almost always more flexible. "Prefer composition over inheritance" is one of the most repeatedly proven pieces of design advice in software engineering.

Inheritance and the Method Resolution Order

Python uses C3 linearization to compute the Method Resolution Order (MRO) — the sequence in which classes are searched for a method. You can inspect it with Class.__mro__ or Class.mro(). Understanding MRO is critical for multiple inheritance: Python always follows a consistent, predictable left-to-right, depth-before-breadth order.

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

    def speak(self) -> str:
        raise NotImplementedError(f"{type(self).__name__} must implement speak()")

    def describe(self) -> str:
        return f"I am {self.name}, a {type(self).__name__}"

class Dog(Animal):
    def speak(self) -> str:
        return f"{self.name}: Woof!"

    def fetch(self) -> str:
        return f"{self.name} fetches the ball"

class Cat(Animal):
    def speak(self) -> str:
        return f"{self.name}: Meow!"

class Kitten(Cat):
    def speak(self) -> str:
        parent_sound = super().speak()         # super() delegates to Cat
        return f"{parent_sound} (tiny)"

# Polymorphism: same call, different behavior
animals: list[Animal] = [Dog("Rex"), Cat("Mia"), Kitten("Dot")]
for a in animals:
    print(a.speak())

# Multiple inheritance
class Pet:
    def is_pet(self) -> bool:
        return True

class PetDog(Dog, Pet):        # inherits from both
    pass

pd = PetDog("Buddy")
pd.speak()     # "Buddy: Woof!"  — from Dog
pd.is_pet()    # True            — from Pet
pd.fetch()     # "Buddy fetches the ball"  — from Dog

# Inspect MRO
PetDog.__mro__
# (<class 'PetDog'>, <class 'Dog'>, <class 'Animal'>, <class 'Pet'>, <class 'object'>)

Rex: Woof! Mia: Meow! Dot: Meow! (tiny)

Method Resolution Order (MRO) for PetDog(Dog, Pet)
──────────────────────────────────────────────────────────────
        object
       /      \
   Animal     Pet
     |
    Dog
     |
  PetDog

MRO search order: PetDog → Dog → Animal → Pet → object
When PetDog.speak() is called:
  1. Check PetDog.__dict__     — no speak()
  2. Check Dog.__dict__        — found! speak() = "Woof"  ← use this

When super().speak() is called inside Dog:
  1. Start AFTER Dog in MRO
  2. Check Animal.__dict__     — found! (base implementation)

Dunder Methods: Making Objects Feel Native

Dunder methods ("double underscore" or "magic methods") are Python's hook system: by implementing them, you tell Python how your object should behave with built-in syntax and functions. __repr__ controls repr(obj); __eq__ controls ==; __len__ controls len(obj); __add__ controls +; __contains__ controls in; __iter__ makes objects iterable. This is Python's approach to operator overloading — clean and explicit.

dunders_full.py
from functools import total_ordering

@total_ordering   # fill in missing comparison methods given __eq__ and one ordering method
class Money:
    """Immutable monetary amount with currency."""
    __slots__ = ("_amount", "_currency")   # save memory, prevent arbitrary attrs

    def __init__(self, amount: float, currency: str = "USD"):
        object.__setattr__(self, "_amount",   round(float(amount), 2))
        object.__setattr__(self, "_currency", currency.upper())

    @property
    def amount(self): return self._amount

    @property
    def currency(self): return self._currency

    # ── String representations ───────────────────────────────────────────────
    def __repr__(self) -> str:
        """Unambiguous developer representation — used in REPL and logs."""
        return f"Money({self._amount!r}, {self._currency!r})"

    def __str__(self) -> str:
        """Human-friendly display — used by print() and str()."""
        return f"{self._currency} {self._amount:,.2f}"

    # ── Equality and hashing ─────────────────────────────────────────────────
    def __eq__(self, other) -> bool:
        if not isinstance(other, Money):
            return NotImplemented   # let Python try the other side
        return self._amount == other._amount and self._currency == other._currency

    def __hash__(self) -> int:
        """If you define __eq__, also define __hash__ or the object becomes unhashable."""
        return hash((self._amount, self._currency))

    # ── Ordering (total_ordering fills in <=, >, >= from these two) ───────────
    def __lt__(self, other) -> bool:
        if not isinstance(other, Money):
            return NotImplemented
        if self._currency != other._currency:
            raise ValueError(f"Cannot compare {self._currency} and {other._currency}")
        return self._amount < other._amount

    # ── Arithmetic ────────────────────────────────────────────────────────────
    def __add__(self, other: "Money") -> "Money":
        if self._currency != other._currency:
            raise ValueError("Cannot add different currencies")
        return Money(self._amount + other._amount, self._currency)

    def __mul__(self, factor: float) -> "Money":
        return Money(self._amount * factor, self._currency)

    def __rmul__(self, factor: float) -> "Money":
        """Right multiplication: 3 * money works too."""
        return self.__mul__(factor)

    def __neg__(self) -> "Money":
        return Money(-self._amount, self._currency)

    # ── Setattr guard for immutability ────────────────────────────────────────
    def __setattr__(self, name, value):
        raise AttributeError("Money is immutable")

# Rich, native-feeling API
price  = Money(19.99, "USD")
tax    = Money(1.60,  "USD")
total  = price + tax           # Money(21.59, 'USD')
print(total)                   # USD 21.59
repr(price)                    # "Money(19.99, 'USD')"
3 * price                      # Money(59.97, 'USD')
sorted([Money(5), Money(2), Money(9)])   # [Money(2.0, 'USD'), Money(5.0, ...), ...]
{price, total}                 # works: Money is hashable

USD 21.59 | Dunder | Called by | Purpose | |---|---|---| | __repr__ | repr(obj), REPL, logs | Unambiguous developer representation | | __str__ | str(obj), print() | Human-friendly display | | __eq__ | == | Equality test | | __hash__ | hash(), dict key, set member | Required if __eq__ defined | | __lt__, __le__, etc. | <, <=, etc. | Ordering; use @total_ordering | | __len__ | len(obj) | Length/size | | __add__, __mul__ | +, * | Arithmetic operators | | __contains__ | x in obj | Membership test | | __iter__ | for x in obj | Make object iterable | | __getitem__ | obj[key] | Index/key access | | __enter__/__exit__ | with obj: | Context manager protocol | | __call__ | obj(args) | Make instance callable |

Define hash whenever you define eq

In Python 3, if you define __eq__ without __hash__, the class becomes unhashable (__hash__ is set to None). This means instances can't be used as dict keys or set members. If your objects are mutable, this is correct — mutable objects shouldn't be hashable (their hash could change). If your objects are conceptually values (like Money), define __hash__ = hash((self.a, self.b)) using the same fields as __eq__.

Composition Over Inheritance

The "prefer composition over inheritance" guideline comes from Design Patterns (the "Gang of Four" book, 1994) and is one of the most proven pieces of design advice in software. Inheritance tightly couples a subclass to its parent's implementation. A change in the base class ripples through all descendants. Deep hierarchies become hard to reason about and fragile to extend.

Composition decouples: an object holds a reference to a collaborator rather than inheriting from it. The collaborator can be swapped independently — you can pass a different implementation (a mock, a new strategy) without changing the class. This makes systems testable, flexible, and easier to understand in isolation.

composition_vs_inheritance.py
from typing import Protocol

# Inheritance approach — tightly couples Notifier logic into OrderService
class EmailService:
    def send(self, to: str, subject: str, body: str): ...

class OrderServiceWithEmail(EmailService):   # IS-A EmailService? No!
    def place_order(self, order):
        # ...process order...
        self.send(order.email, "Order confirmed", f"Order {order.id}")
# Problem: OrderService now depends on email internals; can't use SMS; can't mock easily

# ─────────────────────────────────────────────────────────────────────────────
# Composition approach — inject the dependency

class Notifier(Protocol):                    # define the contract
    def notify(self, recipient: str, message: str) -> None: ...

class EmailNotifier:
    def notify(self, recipient: str, message: str) -> None:
        print(f"Email to {recipient}: {message}")

class SMSNotifier:
    def notify(self, recipient: str, message: str) -> None:
        print(f"SMS to {recipient}: {message}")

class OrderService:
    def __init__(self, notifier: Notifier):  # HAS-A notifier; don't care which
        self._notifier = notifier

    def place_order(self, order):
        # ...process order...
        self._notifier.notify(order.email, f"Order {order.id} confirmed")

# At the call site, swap the implementation
service = OrderService(EmailNotifier())      # production
service = OrderService(SMSNotifier())        # for SMS channels

# For testing: inject a mock
class FakeNotifier:
    def __init__(self): self.sent = []
    def notify(self, recipient, message): self.sent.append((recipient, message))

fake = FakeNotifier()
service = OrderService(fake)
service.place_order(order)
assert len(fake.sent) == 1    # easy to test, no email actually sent

Consulting lens: composition makes systems testable

The pattern above — injecting a dependency rather than inheriting or hard-coding it — is called Dependency Injection (DI). It's the foundation of testable, maintainable enterprise software. When the OrderService takes a Notifier at construction time, you can: test it with a fake notifier (no real emails sent), swap email for SMS in one line at the call site, change the notification strategy at runtime. Every hard-coded dependency (import smtplib directly in the service) is a future pain point. DI is how you build systems that evolve without rewrites.

Knowledge check

InterviewWhen would you choose composition ("has-a") over inheritance ("is-a") to model a relationship?

  • Always use inheritance — composition is outdated.
  • When the relationship is "has-a" rather than a true subtype, or when you want to avoid brittle deep hierarchies — composition gives looser coupling and the ability to swap implementations independently.
  • Composition is only for numeric types; inheritance for everything else.
  • They are equivalent; the choice never matters in practice.
Answer

When the relationship is "has-a" rather than a true subtype, or when you want to avoid brittle deep hierarchies — composition gives looser coupling and the ability to swap implementations independently.

Use inheritance for genuine "is-a" relationships where the subclass truly is a more specific version of the parent, and substitutability (Liskov) holds. Use composition when you need to reuse behavior without creating a tight hierarchical dependency. The heuristic: if you'd be uncomfortable saying "A BankAccount IS-A Notifier," don't model it with inheritance. Composition is more flexible and almost always the right choice when in doubt.

Knowledge check

Concept CheckYou define __eq__ on a class but not __hash__. What happens when you try to use an instance as a dict key?

  • Python auto-generates hash based on the object's id.
  • Python sets __hash__ = None, making the class unhashable — using it as a dict key raises TypeError: unhashable type.
  • It works fine — Python uses the inherited hash from object.
  • Python raises a ValueError at class definition time.
Answer

Python sets __hash__ = None, making the class unhashable — using it as a dict key raises TypeError: unhashable type.

Python 3's contract: if you define eq , Python implicitly sets hash = None , rendering the class unhashable. The reasoning: if eq is based on value, hashing by identity (the default) would break the dict/set invariant that equal objects must hash equally. You must explicitly define hash using the same fields as eq if your object should be usable in sets/dicts.

Knowledge check

InterviewWhat does super() do in Python, and why is it better than calling the parent class by name?

  • super() always calls the same parent class — it's just shorthand for Parent.method(self).
  • super() delegates to the next class in the MRO, not necessarily the direct parent. This is critical for multiple inheritance to work correctly — hardcoding the parent class name breaks the MRO cooperative call chain.
  • super() creates a copy of the parent class for safe mutation.
  • They are identical. super() is just syntactic sugar for Parent.method(self).
Answer

super() delegates to the next class in the MRO, not necessarily the direct parent. This is critical for multiple inheritance to work correctly — hardcoding the parent class name breaks the MRO cooperative call chain.

super() follows the MRO to find the next class in the search order, which in multiple inheritance may not be the direct parent. This enables "cooperative multiple inheritance" where each class in the MRO calls super() and the call chain runs through all classes in order. Hardcoding the parent name ( Parent.method(self) ) skips this chain, breaking multiple inheritance. Always use super() .

Exercise: Sortable, Printable Collection

Build a ScoreBoard class that wraps a list of (player_name, score) tuples. Implement: __len__, __repr__, __contains__ (check by player name), __iter__ (iterate sorted by score descending), __add__ (merge two scoreboards), and top(n) method. Objects should work naturally with len(), in, for, and +.

Show solution
class ScoreBoard:
    def __init__(self, entries: list[tuple[str, int]] | None = None):
        self._entries = list(entries or [])

    def add(self, player: str, score: int) -> None:
        self._entries.append((player, score))

    def __len__(self) -> int:
        return len(self._entries)

    def __repr__(self) -> str:
        top = self._sorted()[:3]
        return f"ScoreBoard({top!r}{'...' if len(self) > 3 else ''})"

    def __contains__(self, player_name: str) -> bool:
        """'player_name' in scoreboard — checks by name."""
        return any(name == player_name for name, _ in self._entries)

    def __iter__(self):
        """Iterate sorted by score descending."""
        return iter(self._sorted())

    def __add__(self, other: "ScoreBoard") -> "ScoreBoard":
        """Merge two scoreboards, deduplicating by keeping highest score."""
        combined = dict(self._entries)
        for name, score in other._entries:
            combined[name] = max(combined.get(name, 0), score)
        return ScoreBoard(list(combined.items()))

    def _sorted(self) -> list[tuple[str, int]]:
        return sorted(self._entries, key=lambda x: x[1], reverse=True)

    def top(self, n: int) -> list[tuple[str, int]]:
        return self._sorted()[:n]

# Demo
sb1 = ScoreBoard([("Alice", 95), ("Bob", 82), ("Carol", 91)])
sb2 = ScoreBoard([("Bob", 88), ("Dave", 77)])

print(len(sb1))             # 3
print("Alice" in sb1)       # True
print("Eve" in sb1)         # False

for name, score in sb1:     # sorted descending
    print(f"  {name}: {score}")

merged = sb1 + sb2
print(merged.top(3))        # [('Alice', 95), ('Carol', 91), ('Bob', 88)]

Key takeaways

  • Python uses C3 linearization for MRO; inspect with Class.__mro__. Use super(), not hardcoded parent names.
  • Dunder methods integrate your objects with Python's built-in syntax: __repr__, __eq__, __hash__, __len__, __add__, __iter__.
  • Always define __hash__ alongside __eq__; omitting it makes the class unhashable.
  • Prefer composition ("has-a") over deep inheritance ("is-a") for flexibility, testability, and loose coupling.
  • Dependency injection — passing collaborators rather than inheriting or hard-coding them — is the key to testable, swappable systems.