Programming with PythonFiles, Errors, and Object-Oriented Python › Day 68

Day 68: Inheritance, Composition, and Dunder Methods

Day 68 of 365 — Inheritance, Composition, and Dunder Methods

After this lesson you will be able to choose deliberately between inheritance and composition instead of reaching for whichever comes first: state the is-a and has-a tests, predict where a missing super().__init__() will actually blow up, derive a diamond hierarchy's method resolution order by hand from the two C3 rules and check it against __mro__, and then implement enough of Python's data model — __len__, __getitem__, __iter__, __contains__, __eq__ with __hash__, __lt__, and __enter__/__exit__ — that len(), indexing, for, in, sorted(), max(), set() and with all work on a class you wrote, with none of those builtins modified.

Course
Programming with Python
Category
Files, Errors, and Object-Oriented Python
Reading time
≈ 40 min
Practical time
≈ 30 min
Lesson duration
1h 10m
Last verified
2026-07-19

Hands-on lab for this lesson

Lab files on GitHub: https://github.com/ai-roadmap-365/ai-roadmap-365.github.io/tree/main/labs/sections/programming-with-python/day-068-inheritance-composition-and-dunder-methods

  1. Get the hands-on files. Clone the labs repository once (you can reuse this clone for every lesson). This works on macOS, Linux, and Windows (PowerShell or WSL):
    git clone https://github.com/ai-roadmap-365/ai-roadmap-365.github.io.git
    cd ai-roadmap-365.github.io
  2. Open this lesson's lab. Move into the directory for this specific day. Every lab lives at the same predictable path — section / subsection / week / day:
    cd labs/sections/programming-with-python/day-068-inheritance-composition-and-dunder-methods
  3. Read the lab guide. Open `README.md` in that directory. It lists the exact commands, what each does, the expected output, and how to check your work — read it before running anything.
  4. Run it and check your work. Follow the README's "How to run" section: run the example first to see the finished result, then complete the numbered exercises in `starter/`, then run the tests. The tests pass (exit 0) only when your work is correct.
    bash tests/run_tests.sh   # or the test command named in the lab README

You can also open the lab as a local page (works offline, shows the file tree and expected output).

Learning objectives

By the end of this lesson you will be able to:

Prerequisites

Why this matters

Yesterday you wrote your first classes: a blueprint, an __init__ that sets up each instance, methods that act on self, properties that guard an attribute, and a __repr__ that makes an object printable. Today you answer the question that immediately follows. You have written two classes and noticed they share behaviour — an Oven and a Toaster both have wattage and both heat things. What do you do about that duplication? Python gives you two very different answers, inheritance and composition, and choosing badly between them is one of the most expensive mistakes in object-oriented code. Then you learn the third thing, the one that turns a class you wrote into a first-class Python citizen: the data model, Python’s system of special methods, universally called dunder methods because they are spelled with double underscores at each end.

The consequences are concrete and they arrive fast. Choose inheritance where composition belonged and you get a base class that nobody dares change, because a one-line edit silently alters five subclasses you have not read in months — that is the fragile base class problem, and it is measured in days of debugging. Get the data model wrong and your objects become second-class: len(my_thing) raises TypeError, sorted(my_things) refuses to run, two objects that hold identical data compare unequal, and putting them in a set crashes because you defined __eq__ and unknowingly destroyed hashing. Get it right and something delightful happens: builtins and standard-library functions you never touched — len, sorted, max, in, for, with — start working on a class you wrote this afternoon, with zero changes to them.

That last sentence is not a Python curiosity; it is the whole API surface of the machine-learning libraries in the second half of this course. A PyTorch model is a class that subclasses nn.Module and implements a method called forward. A PyTorch dataset is a class you write that implements __len__ and __getitem__ — and that is all the DataLoader needs in order to shuffle it, batch it, and feed it to a training loop it wrote long before your data existed. Agent and tool classes in the popular frameworks are abstract base classes with a method or two left blank for you to fill in. Every one of those is today’s lesson, applied. Learn the protocols now and the frameworks later will feel like things you already know.

The idea in plain language

Start with the reuse question. Two of your classes share behaviour. You have three honest options.

Copy the code into both. Fine for two lines; a maintenance bill for twenty, because every fix has to be made twice and one day it will be made once.

Inheritance — the “is-a” relationship. Put the shared behaviour in a base class (also called a superclass or parent) and let each class inherit from it. A Toaster is a HeatingAppliance, so class Toaster(HeatingAppliance) gives it every attribute and method the parent has, for free, plus whatever it adds or changes. The magic is in attribute lookup: when you write a dotted access on an instance, Python searches the instance first, then walks a fixed list of classes called the method resolution order (MRO) until it finds the name.

Composition — the “has-a” relationship. Instead of being a heating appliance, an Oven has a HeatingElement: it stores one as an attribute and calls it when it needs heat. Passing a call through to a stored object like that is called delegation. Nothing is inherited; the wiring is written out by hand, which is more typing and far more visible.

The mainstream guidance, roughly thirty years old now, is prefer composition over inheritance — not “never inherit,” but “reach for composition first, and inherit when the is-a relationship is genuinely true and genuinely stable.”

Then there is the second half of the day. Python’s syntax is not hard-wired to its builtin types. When you write len(x), the interpreter does not check whether x is a list; it looks up a method named __len__ on x’s type and calls it. Same for x + y and __add__, x[k] and __getitem__, for i in x and __iter__, x == y and __eq__, with x and __enter__/__exit__. That set of hooks is Python’s data model, and implementing them is how your class joins the language rather than sitting beside it. Because the checks are “does this object have the method,” not “is this object of the right class,” Python is a duck-typed language: if it walks like a duck, it is treated like one.

Historical background

Inheritance is older than most programming languages in use today. It was introduced in Simula 67, designed by Ole-Johan Dahl and Kristen Nygaard at the Norwegian Computing Center in Oslo, which gave the world classes, objects, and subclasses in one stroke. Smalltalk, developed by Alan Kay’s group at Xerox PARC in the 1970s, built an entire system on objects sending messages to one another and made inheritance a household idea among programmers. By the 1980s and 1990s inheritance was the headline feature of object orientation, and class libraries were built as deep towers of subclasses.

Then the bill arrived. Deep hierarchies proved hard to reason about, and a problem got a name in the 1990s as class libraries grew: the fragile base class problem, where a seemingly safe change to a base class breaks subclasses written by people who never read it. In 1994, the book Design Patterns by Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides — the “Gang of Four” — stated the counter-principle that is still quoted daily: favour object composition over class inheritance. It is the direct ancestor of the advice you will follow today.

Python’s own inheritance story has a specific date. Python 2.2, released in 2001, unified types and classes with what were called new-style classes. Python 2.3, in 2003, adopted C3 linearization as the algorithm that computes a class’s method resolution order — an algorithm published in 1996 for the Dylan language, in a paper titled “A Monotonic Superclass Linearization for Dylan.” Michele Simionato’s essay “The Python 2.3 Method Resolution Order,” still hosted on the Python website and one of today’s sources, is the canonical explanation. The rest of the toolkit arrived steadily afterwards: the context manager protocol (__enter__/__exit__) in Python 2.5 via PEP 343, the abc module for abstract base classes in Python 2.6 via PEP 3119, functools.total_ordering in Python 2.7 and 3.2, and structural typing with typing.Protocol in Python 3.8 via PEP 544. The data model itself has been in Python since the beginning; its reference page, “Special method names,” is the single densest and most useful page in the Python documentation.

What it is — and what it is not

Inheritance is a mechanism for one class to acquire the attributes and methods of another and to override or extend them. Composition is the practice of building a class out of other objects held as attributes, forwarding work to them. Dunder methods are named methods with a defined meaning in the language, which the interpreter calls on your behalf when you use ordinary syntax.

Being precise about what these are not saves real pain:

Common misconceptionThe reality
”Inheritance is for code reuse.”Inheritance is for substitutability — a subclass should be usable anywhere the parent is. Reuse is a side effect. If you inherit only to grab a method, composition is the honest tool.
super() means my parent class.”super() means the next class in this instance’s MRO, which in multiple inheritance is often not the parent you would guess. You will see this proved with real output below.
”Dunder methods are private and I should not write them.”Dunders are the public protocol of the language. A name like _helper is by-convention private; __len__ is a documented hook you are invited to implement. Inventing your own new dunder names, however, is discouraged — those are reserved by the language.
”Defining __eq__ is harmless.”Defining __eq__ sets __hash__ to None, making your objects unhashable. You must define __hash__ too if the object should live in a set or be a dict key.
isinstance is the way to check something works.”Duck typing checks capability, not ancestry. A function that only calls .read() should accept anything with .read() — a file, a socket wrapper, a test double — not only subclasses of one class.
”Multiple inheritance is broken in Python.”It is well defined: C3 linearization produces one deterministic order or refuses to build the class at all. It is hard to read, which is a different objection, and the reason mixins are kept small.

Why it was created and what problems it solves

Each of today’s tools defeats a specific failure.

Inheritance solves duplicated behaviour among genuinely related types. Without it, ten kinds of appliance each carry their own copy of “store a name and wattage, describe yourself,” and a change to the description format is ten edits and one forgotten file. With a base class, the shared behaviour has one home and one place to fix.

Composition solves the coupling that inheritance creates. A subclass depends on its parent’s internals, not just its public surface — that is why a harmless-looking base class change breaks it. An object that merely holds another object depends only on the small interface it calls. It also solves a shape problem: inheritance is single-purpose and rigid, while an object can hold as many collaborators as the job needs and swap any of them at runtime.

Abstract base classes solve the “you forgot to implement it” failure. Without them, a half-finished subclass constructs happily and explodes much later, deep inside a call, with a confusing error. With abc.ABC and @abstractmethod, Python refuses to create the instance at all, at the exact moment you tried, naming the missing method.

The data model solves interoperability. Before protocols, using a custom collection with library code meant either the library knew your type or you converted to a list. With protocols, len, in, for, sorted, max, with and hundreds of library functions work on your class because they were never asking about your class — they were asking about methods. That is what makes it possible for PyTorch’s DataLoader, written years ago, to iterate a dataset class you write tomorrow.

How it works

Inheritance: syntax, lookup, and the MRO

You inherit by naming the parent in parentheses. Here is the appliance domain as a hierarchy, with each level adding something:

class Appliance:
    def __init__(self, name, watts):
        self.name = name
        self.watts = watts

    def describe(self):
        return f"{self.name} ({self.watts}W)"


class HeatingAppliance(Appliance):
    def __init__(self, name, watts, max_celsius):
        super().__init__(name, watts)      # let the parent set name and watts
        self.max_celsius = max_celsius

    def heat(self, celsius):
        if celsius > self.max_celsius:
            raise ValueError(f"{self.name} cannot exceed {self.max_celsius}C")
        return f"heating to {celsius}C at {self.watts}W"


class Oven(HeatingAppliance):
    def __init__(self, name, watts, max_celsius, capacity_litres):
        super().__init__(name, watts, max_celsius)
        self.capacity_litres = capacity_litres

    def describe(self):                     # extend, do not replace
        return f"{super().describe()}, {self.capacity_litres}L"

Running it produces exactly this:

Deck oven (3200W), 60L
heating to 220C at 3200W
['Oven', 'HeatingAppliance', 'Appliance', 'object']
ValueError: Deck oven cannot exceed 260C

Three mechanisms are visible. Attribute lookup walks the MRO: heat is not defined on Oven, so Python looks at the next class in the list and finds it on HeatingAppliance. Overriding replaces a method — Oven.describe shadows Appliance.describe. Extending is overriding that still calls the original through super(), which is why the output has both the parent’s part and the child’s addition. And every class ends at object, the root of every Python class, which is where the default __eq__, __repr__, and __hash__ you got yesterday come from.

Now forget super().__init__() in the child and watch what actually happens:

class Oven(Appliance):
    def __init__(self, name, watts, capacity_litres):
        self.capacity_litres = capacity_litres   # no super().__init__() call

    def describe(self):
        return f"{super().describe()}, {self.capacity_litres}L"

Oven("Deck oven", 3200, 60).describe()

The real traceback ends:

  File "exp1.py", line 15, in describe
    return f"{super().describe()}, {self.capacity_litres}L"
              ~~~~~~~~~~~~~~~~^^
  File "exp1.py", line 7, in describe
    return f"{self.name} ({self.watts}W)"
              ^^^^^^^^^
AttributeError: 'Oven' object has no attribute 'name'

Read that carefully, because it is the single most common inheritance bug. The constructor did not fail. The object was created perfectly happily — just incomplete, missing the two attributes the parent’s __init__ would have set. The failure surfaced later, in an unrelated method, blaming a missing attribute rather than the constructor that never ran. Defining __init__ in a subclass replaces the parent’s; if you want the parent’s setup as well, you must call it, and by convention you call it first.

The MRO and the diamond problem

With single inheritance the MRO is obvious: child, parent, grandparent, object. With multiple inheritance it needs an algorithm, because of the diamond problem: if D inherits from B and C, and both inherit from A, in what order does Python search? Here is a diamond you can re-derive:

class Appliance:
    def power_on(self):
        return "Appliance: power on"

class Heater(Appliance):
    def power_on(self):
        return "Heater: elements warming -> " + super().power_on()

class Timer(Appliance):
    def power_on(self):
        return "Timer: clock started -> " + super().power_on()

class ToasterOven(Heater, Timer):
    def power_on(self):
        return "ToasterOven: ready -> " + super().power_on()

ToasterOven.__mro__ really prints:

ToasterOven
Heater
Timer
Appliance
object

Derive it yourself with the rule C3 linearization guarantees: a class always comes before its parents (local precedence), and the order in which bases are listed is preserved (monotonicity). Start with ToasterOven. Its bases are Heater then Timer, so Heater comes next. Heater’s parent is Appliance — but Appliance cannot come yet, because Timer also inherits from it and Timer must precede its own parent. So Timer, then Appliance, then object. Notice what C3 buys you: Appliance appears once, not twice, so it is never initialised or executed twice. If no consistent order exists, Python does not guess — it raises TypeError when the class is defined.

Now the payoff, and the reason “super() means the parent” is wrong:

ToasterOven: ready -> Heater: elements warming -> Timer: clock started -> Appliance: power on

Heater.power_on calls super().power_on() and reaches Timer — a class Heater has never heard of and does not inherit from. super() walked to the next entry in this instance’s MRO. This cooperative chaining is powerful and is exactly why multiple inheritance is hard to read: you cannot know where a super() call lands by looking at one class.

Composition and delegation

Diagram: the same kitchen-appliance domain modelled two ways side by side — on the left an inheritance hierarchy from Appliance to HeatingAppliance to Oven and Toaster with the method resolution order shown, on the right a composition object graph where Oven and Toaster hold HeatingElement and Timer objects and delegate to them

The architecture diagram shows both designs at once. On the left, Oven and Toaster are subclasses; behaviour arrives implicitly, found by walking the MRO, and a change in Appliance reaches everyone. On the right, the same domain is built from parts: Oven has a HeatingElement and has a Timer, Toaster has a HeatingElement, and each forwards work explicitly. Here is the right-hand side as code:

class HeatingElement:
    def __init__(self, watts):
        self.watts = watts
    def heat(self, celsius):
        return f"heating to {celsius}C at {self.watts}W"

class Timer:
    def __init__(self):
        self.minutes = 0
    def set(self, minutes):
        self.minutes = minutes
        return f"timer set to {minutes} min"

class Oven:
    def __init__(self, name, watts):
        self.name = name
        self.element = HeatingElement(watts)   # has-a
        self.timer = Timer()                   # has-a
    def bake(self, celsius, minutes):          # delegation
        return f"{self.name}: {self.element.heat(celsius)}; {self.timer.set(minutes)}"

Real output:

Deck oven: heating to 220C at 3200W; timer set to 35 min
Two-slice: heating to 260C at 900W

Compare honestly. Inheritance gave Oven heat() for free with no forwarding code, but bound it permanently to one parent chain and exposed it to every future change in that chain. Composition made you write bake by hand, but the wiring is visible on one screen, an Oven can hold as many collaborators as it needs, and you can hand it a different element — a fan-assisted one, or a fake one in a test — without touching any class it inherits from. That last point is the practical heart of it: composition is swappable at runtime; inheritance is fixed at class-definition time.

The guidance prefer composition over inheritance rests on three concrete reasons. First, the fragile base class problem: subclasses depend on their parent’s internal behaviour, so safe-looking parent edits break them at a distance. Second, tight coupling: a subclass cannot be understood, tested, or reused without its entire ancestry. Third, deep hierarchies are hard to reason about: answering “where does this method actually come from” means walking four classes, and multiple inheritance makes it worse.

The honest exceptions matter just as much. Inherit when a framework requires it — subclassing nn.Module in PyTorch or a Thread in the standard library is how you plug into machinery that expects it. Inherit for small, stable hierarchies where the is-a relationship is real and the base class will not churn — Python’s own exception hierarchy, which you used on Day 66, is a fine example. Inherit to declare an abstract interface, which is the next tool.

Abstract base classes, and the duck typing that often replaces them

An abstract base class is a class that cannot be instantiated and that names methods subclasses must implement:

from abc import ABC, abstractmethod

class Station(ABC):
    def __init__(self, name):
        self.name = name

    @abstractmethod
    def prepare(self, dish):
        """Return a string describing how this station prepares the dish."""

    def announce(self, dish):                      # concrete, shared
        return f"{self.name}: {self.prepare(dish)}"

class GrillStation(Station):
    def prepare(self, dish):
        return f"grilling {dish} over charcoal"

class PastryStation(Station):                       # forgot to implement it
    pass

The real results:

Grill: grilling mackerel over charcoal
TypeError: Can't instantiate abstract class PastryStation without an implementation for abstract method 'prepare'

That error fires at construction, names the class and the missing method, and is far kinder than a mysterious failure hours into a run. Note also that announce — a concrete method calling an abstract one — is the template method shape, and it is exactly how nn.Module works: the framework calls your forward.

But be clear that Python does not need this to work. Duck typing means a function that calls .prepare(dish) will happily accept any object with that method, whatever its ancestry. The choice is between asking “is this object of the right class” and asking “does this object have the method”:

isinstance(t, Iterable): True      # t defines __iter__ — no inheritance involved
isinstance(t, Sized):    False     # t has no __len__
hasattr(t, "__iter__"):  True
len(t) -> TypeError: object of type 'Ticket' has no len()

Ticket there inherits from nothing but object, yet isinstance(t, Iterable) is True, because the abstract base classes in collections.abc check for the methods, not the family tree. Use abc.ABC when you own the hierarchy and want the enforcement; rely on duck typing when you are consuming objects other people wrote.

The data model: dunder methods as protocols

Flowchart: how the interpreter dispatches ordinary syntax to dunder methods — len of a menu looks up and calls len, indexing calls getitem, a for loop calls iter then next, and equality calls eq on the left operand with a NotImplemented fallback to the reflected call on the right operand and finally to identity comparison

The flow diagram traces the big idea. You write ordinary syntax; the interpreter looks up a specially named method on the type (not on the instance) and calls it; you get the result. Nothing else is going on — there is no separate mechanism for “real” collections.

You writePython callsProtocol it joins
len(x)type(x).__len__(x)Sized
x[k], x[1:3]type(x).__getitem__(x, k)Subscriptable / sequence
for i in xtype(x).__iter__(x), then __next__ on the resultIterable
k in xtype(x).__contains__(x, k), else falls back to __iter__Container
x == ytype(x).__eq__(x, y), then the reflected y.__eq__(x)Equality
x < ytype(x).__lt__(x, y)Ordering (used by sorted, min, max)
hash(x), set/dict keystype(x).__hash__(x)Hashable
x + ytype(x).__add__(x, y), then y.__radd__(x)Numeric / concatenation
str(x), print(x)type(x).__str__(x), falling back to __repr__String conversion
with x as y:type(x).__enter__(x) then __exit__Context manager
x(...)type(x).__call__(x, ...)Callable

Two rules save the most grief. First, __eq__ and __hash__ travel together. Defining __eq__ sets __hash__ to None, because objects that compare equal must hash equal and Python will not guess how. Proof:

Plate.__hash__ is None: True
TypeError: cannot use 'Plate' as a set element (unhashable type: 'Plate')

If your object is immutable-in-practice and belongs in sets or dicts, define __hash__ over the same fields __eq__ uses — typically return hash((self.field_a, self.field_b)).

Second, return NotImplemented, not False, for types you do not know. Returning NotImplemented tells Python “ask the other operand,” which then gets its chance:

Grams(100) == Ounces(3.5273...):
  Grams.__eq__ called with Ounces
  Ounces.__eq__ called with Grams
result: True

Grams(100) == 'text':
  Grams.__eq__ called with str
result: False

In the first case Grams bows out with NotImplemented, Python tries the reflected call, and Ounces knows the conversion — a correct answer neither class could give alone. In the second, both sides bow out and Python falls back to identity, giving False. Had Grams.__eq__ returned False directly, the first comparison would have been silently wrong.

An everyday analogy

Picture a professional kitchen, and keep it in mind for the rest of the lesson, because every idea today has a station in it.

Inheritance is a chef trained in a tradition. A chef who came up through classical French training carries a whole inherited repertoire: stocks, sauces, knife work, the order of service. Ask for a beurre blanc and it appears, because it came with the training — nobody had to write it down for this particular chef. That is a subclass getting a method for free by walking up its lineage. It is genuinely efficient, and it makes a strong promise: anywhere the tradition is expected, this chef will do. But it binds. If the tradition changes its house method for stock, every chef trained in it is affected, including ones who had built their own dishes on the old behaviour — the fragile base class problem, in aprons. And the deeper the lineage — classical French, then a specific house, then a specific mentor’s variation — the harder it becomes to answer “why did the sauce come out like that,” because the answer is somewhere up a chain you have to walk. That is the MRO, and super() is the chef saying “and now do it the way the next teacher in my line does,” which in a chef trained by two traditions may not be the teacher you assumed.

Composition is a chef who hires specialists. This head chef was not trained to make pastry; the kitchen has a pastry cook. Dessert is delegated: an order arrives, the head chef passes it to the pastry station, the plate comes back. More talking, more explicit handoffs, more lines in the service book. But the pastry cook can be replaced with a better one on Tuesday and nothing about the head chef changes; the grill cook can be swapped for a stand-in during a test service; and a new station can be added without rewriting anyone’s training. That is why composition is preferred as a default: the kitchen is assembled from parts you can name, inspect, and replace, rather than inherited wholesale from a lineage you cannot edit.

Dunder methods are the standard-shaped fittings every appliance agrees to. A commercial kitchen runs on standards: a gas hose has a standard connector, a shelf takes a standard gastronorm tray, a plug fits a standard socket. Nobody at the manufacturer knew what your kitchen would be; they only knew the fitting. So when a new machine arrives with the right fittings, it simply works with hoses, racks, and trolleys built years earlier. __len__ is the gauge that any tray must respond to; __iter__ is the standard rail that lets a trolley be wheeled through any station; __enter__ and __exit__ are the pre-service and post-service checklist every station signs, guaranteeing the gas is turned off even if service went badly. Implement the fittings and the whole kitchen’s existing equipment works with your new machine — that is what “protocol” means, and it is why an abc.ABC is a written job description while duck typing is the shift manager simply checking whether the fitting is the right shape.

Examples in practice

Now build the thing that proves it: a small container class of your own — a Menu holding Dish objects — that supports length, indexing, iteration, membership, equality, ordering, and the with statement. Every dunder here is one you just met in the table.

from functools import total_ordering

class Dish:
    def __init__(self, name, minutes):
        self.name = name
        self.minutes = minutes

    def __repr__(self):
        return f"Dish({self.name!r}, {self.minutes})"

    def __eq__(self, other):
        if not isinstance(other, Dish):
            return NotImplemented
        return (self.name, self.minutes) == (other.name, other.minutes)

    def __hash__(self):                                   # because __eq__ exists
        return hash((self.name, self.minutes))


@total_ordering
class Menu:
    def __init__(self, name, dishes=None):
        self.name = name
        self.dishes = list(dishes or [])
        self.open = False

    def __repr__(self):
        return f"Menu({self.name!r}, {len(self.dishes)} dishes)"

    def __len__(self):
        return len(self.dishes)

    def __getitem__(self, index):                          # supports slices too
        return self.dishes[index]

    def __iter__(self):
        return iter(self.dishes)

    def __contains__(self, item):
        name = item.name if isinstance(item, Dish) else item
        return any(dish.name == name for dish in self.dishes)

    def total_minutes(self):
        return sum(dish.minutes for dish in self.dishes)

    def __eq__(self, other):
        if not isinstance(other, Menu):
            return NotImplemented
        return self.dishes == other.dishes

    def __hash__(self):
        return hash(tuple(self.dishes))

    def __lt__(self, other):                               # total_ordering fills the rest
        if not isinstance(other, Menu):
            return NotImplemented
        return self.total_minutes() < other.total_minutes()

    def __enter__(self):
        self.open = True
        print(f"[service open] {self.name}")
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        self.open = False
        print(f"[service closed] {self.name}")
        return False                                        # do not swallow exceptions

Drive it with three menus — lunch (Ramen 12, Gyoza 8, Salad 4), dinner (Ramen 12, Duck 40), and copy, an independent Menu holding equal dishes. Real output:

len: 3
index: Dish('Ramen', 12) | slice: [Dish('Gyoza', 8), Dish('Salad', 4)]
iterate: ['Ramen', 'Gyoza', 'Salad']
contains: True | False
equal: True | False
eq other type: False
hash equal: True
lt: False | ge (total_ordering): True
sorted: [Menu('Lunch', 3 dishes), Menu('Dinner', 2 dishes)]
max: Menu('Lunch', 3 dishes)
longest dish: Dish('Ramen', 12)
sum minutes via comprehension: 24
set of menus: 2
list(): 2

Every line is a builtin working on a class written minutes ago. sorted put Lunch (24 minutes) before Dinner (52) using nothing but __lt__. max([dinner, lunch], key=len) used __len__ through the key function. max(lunch, key=...) iterated the menu via __iter__ to find its longest dish. The comprehension sum(dish.minutes for dish in lunch) iterated it too. And len({lunch, copy, dinner}) is 2, not 3, because lunch and copy are equal and hash equal, so the set collapsed them — the __eq__/__hash__ pair doing its job. The one line to study is lt: False | ge (total_ordering): True: you wrote only __lt__, and the @total_ordering decorator derived >, >=, and <= from it plus __eq__.

The context manager works the same way, including the case that matters:

[service open] Brunch
  inside: 1 dish, open = True
[service closed] Brunch
after: False
[service open] Bad night
[service closed] Bad night
caught: burnt the souffle | open = False

Look at the last three lines. The body raised ValueError, and [service closed] still printed — __exit__ runs on the way out whether the block succeeded or blew up. That is the guarantee you relied on with files on Day 64, now implemented by you. Returning False from __exit__ lets the exception continue to the caller; returning True would swallow it, which you should do only with a very good reason.

Finally, the interoperability proof — an unmodified standard-library helper handling your class because of protocols alone:

from collections.abc import Sequence

class Rack(Sequence):                 # you implement two methods
    def __init__(self, trays):
        self._trays = list(trays)
    def __len__(self):
        return len(self._trays)
    def __getitem__(self, i):
        return self._trays[i]

Real output:

index: 1 count: 1 reversed: ['c', 'b', 'a'] contains: True

You wrote __len__ and __getitem__; Sequence gave you index, count, __contains__, __reversed__, and __iter__ for free. That is the whole argument for protocols in one line of output.

Implications: security, privacy, performance, scalability, and cost

Security. Inheritance widens the surface area you are trusting. Subclassing a third-party class means your object inherits every method it has, including ones you never read, and a future version of that library can add or change behaviour inside your object without your code changing at all. Composition trusts only the methods you actually call. Dunders carry their own quiet risk: __eq__, __hash__, __getitem__, and __repr__ run implicitly, so a slow or side-effecting implementation executes in places you never see in the source — a __repr__ that logs a secret will happily leak it into a traceback. Keep dunders pure, cheap, and free of I/O.

Privacy. Because __repr__ and __str__ are what appear in logs, error messages, and tracebacks, they are a real privacy control. A Customer whose __repr__ prints an email address will put that address into every log line and crash report that touches the object. Print identifiers, not payloads.

Performance. Attribute lookup walks the MRO, so a very deep hierarchy costs a little more per miss; this is almost never your bottleneck and is a bad reason to change a design. What does matter: __hash__ is called on every set and dict operation and __eq__ on every collision, so an expensive hash over a large structure shows up immediately. Hash a small tuple of identifying fields, not the whole object.

Scalability. This is where the composition preference earns its keep across a team. A deep hierarchy is a shared global structure: to add a behaviour, someone edits a base class, and the blast radius is every subclass anyone has written. Composition localises change — a new collaborator object affects only the classes that hold it. Protocols scale further still, because they let independently written code interoperate with no shared base class at all, which is precisely how the Python ecosystem manages to fit together.

Cost. The dominant cost is understanding. Reading a well-composed class means reading one file and the small interfaces it calls; reading a class four levels deep with two mixins means reconstructing an MRO in your head. Dunders reduce cost dramatically in the other direction: every protocol you implement is a body of existing code — builtins, collections.abc, sorted, DataLoader — that you now get to use without writing or paying for it.

Alternatives: free, open source, and commercial

Everything here ships with Python and is free and open source. There is nothing to buy and no paid tier; the choice is about fit. (attrs is the one item below that needs pip, and it is free and open source too.)

ApproachCostWhen to choose it
Plain inheritanceFree, built inA genuine, stable is-a relationship; a framework requires subclassing
Composition + delegationFree, built inThe default; has-a relationships; anything you want to swap or test in isolation
abc.ABC + @abstractmethodFree, built inYou own the hierarchy and want missing methods caught at construction
typing.ProtocolFree, built in (3.8+)You want an interface checked without forcing anyone to inherit
MixinsFree, built inOne small, orthogonal capability shared by unrelated classes
functools.total_orderingFree, built inYou need full ordering and only want to write __eq__ and __lt__
collections.abc base classesFree, built inBuilding a real collection and wanting the derived methods for free
attrsFree, open source (pip)Larger projects wanting generated __init__/__eq__/__repr__ with validators

Plain inheritance — how, and an example. Name the base in parentheses, call super().__init__(...) first in the child’s __init__, and override only what differs. The ApplianceHeatingApplianceOven chain above is the worked example, and Oven("Deck oven", 3200, 260, 60).describe() returns Deck oven (3200W), 60L.

Composition and delegation — how, and an example. Create collaborators in __init__ and store them as attributes, then write short forwarding methods. The Oven holding a HeatingElement and a Timer above is the worked example, and bake(220, 35) returns Deck oven: heating to 220C at 3200W; timer set to 35 min. Choose it whenever you cannot say “is a” out loud without wincing.

abc.ABC — how, and an example. Subclass ABC, decorate the required methods with @abstractmethod, and put shared concrete methods alongside them. The Station/GrillStation/PastryStation example is the worked case: the incomplete subclass raises TypeError: Can't instantiate abstract class PastryStation without an implementation for abstract method 'prepare' the moment you try to construct it.

typing.Protocol — how, and an example. Declare the shape, and any class with matching methods satisfies it — structural typing, duck typing written down. Nobody inherits anything:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Preparer(Protocol):
    def prepare(self, dish: str) -> str: ...

class Grill:
    def prepare(self, dish): return f"grilled {dish}"

class Sink:
    def wash(self, dish): return f"washed {dish}"

Real output: Grill is a Preparer: True, Sink is a Preparer: False. Choose Protocol when you consume objects other people wrote and cannot demand they inherit from you; choose abc.ABC when you own the family and want the enforcement. (Those -> str annotations are type hints, which you meet properly tomorrow; here they are just documentation.)

Mixins — how, and an example. A mixin is a small class providing one capability, never instantiated on its own, mixed in via multiple inheritance:

class LoggingMixin:
    def log(self, message):
        return f"[{type(self).__name__}] {message}"

class Blender(LoggingMixin):
    def blend(self):
        return self.log("blending 30s")

Real output: [Blender] blending 30s, with Blender.__mro__ being Blender, LoggingMixin, object. Choose a mixin for one orthogonal capability across unrelated classes; if it starts holding state or growing methods, it wants to be a collaborator object instead.

functools.total_ordering — how, and an example. Decorate the class, define __eq__ and __lt__, and the decorator fills in <=, >, and >=. The Menu above is the worked example: writing only __lt__ gave a working dinner >= lunch, which returned True.

collections.abc base classes — how, and an example. Inherit from Sequence, Mapping, Set, or Iterable and implement the two or three abstract methods; the base fills in the rest. The Rack(Sequence) example above is the worked case — two methods written, five gained.

Concept AConcept BKey difference
Inheritance (“is-a”)Composition (“has-a”)Inheritance acquires behaviour implicitly through the MRO and is fixed at class definition; composition holds objects and forwards explicitly, and can be swapped at runtime
OverridingExtendingOverriding replaces the parent method entirely; extending overrides but calls super() so both run
super()The parent classsuper() is the next class in this instance’s MRO, which under multiple inheritance can be a class the current one does not inherit from at all
abc.ABCtyping.ProtocolAn ABC requires nominal inheritance and enforces at instantiation; a Protocol is structural — any matching class qualifies, with no inheritance
Duck typingisinstance checkingDuck typing asks “does it have the method”; isinstance asks “is it of this family.” The collections.abc classes bridge them by checking for methods
MixinBase classA mixin adds one capability and is never used alone; a base class models the type and is the primary parent
__eq____hash____eq__ defines equality; __hash__ defines the bucket. Defining __eq__ sets __hash__ to None, so define both if the object belongs in a set or dict
__str____repr____str__ is for users and print; __repr__ is the unambiguous developer form used in the interpreter, in containers, and as the fallback for __str__
__getitem____iter____getitem__ gives indexing (and a legacy iteration fallback); __iter__ gives proper iteration and is what you should implement for a for loop
NotImplementedNotImplementedErrorNotImplemented is a value you return from a comparison to defer to the other operand; NotImplementedError is an exception you raise from an unfinished method

When to use it — and when not to

Reach for composition by default. Whenever you catch yourself inheriting to obtain a method rather than to declare a type, stop and hold the other class as an attribute instead. Say the relationship out loud: “an Oven is a HeatingAppliance” sounds true; “an Oven is a Timer” does not, and that mismatch is your signal.

Reach for inheritance when the is-a relation is real and the hierarchy is small and stable — two or three levels, one base you control — or when a framework demands it. You will subclass nn.Module without hesitation later, and you already subclass Exception to define your own error types. Avoid it when the base class belongs to a fast-moving third-party library, when you find yourself overriding most of what you inherit (a sign the relationship is false), and when the hierarchy is already three levels deep.

Reach for abc.ABC when you are defining a family of interchangeable implementations and want missing methods caught immediately. Skip it for one-off classes, where it is ceremony without benefit, and skip it when you do not own the classes involved — use typing.Protocol or plain duck typing there.

Implement dunder methods when your class genuinely is the thing the protocol describes: __len__ and __getitem__ when it is a collection, __eq__ when value equality is meaningful, __lt__ when there is a natural order, __enter__/__exit__ when there is a resource to release. Do not implement them for cleverness. A __add__ on a Customer that “adds” two customers will confuse every reader; a method named merge will not. The test is whether the syntax would surprise someone reading the call site.

And here is where today points. When you write a PyTorch model, you will subclass nn.Module and implement forward — inheritance, into a framework, with the template-method shape you saw in Station.announce. When you write a dataset, you will implement __len__ and __getitem__ and nothing else, and a DataLoader written years before your data existed will shuffle, batch, and iterate it — the exact protocol you just implemented on Menu, which is why len(dataset) and dataset[i] are the first two things anyone asks of a dataset class. When you build agents and tools, you will fill in abstract methods on framework base classes and register objects that merely need the right method shape. Today’s three ideas — pick composition unless is-a is true, enforce interfaces where you own them, and implement protocols so other people’s code can use yours — are not preparation for that work. They are that work, learned early on a kitchen you can hold in your head.

Knowledge check

Try these from memory before looking back:

  1. Two classes share behaviour. Give the is-a test and the has-a test you use to decide between inheritance and composition, and name the three concrete costs of choosing inheritance wrongly.
  2. What exactly goes wrong when a subclass defines __init__ and does not call super().__init__() — and why does the error usually appear somewhere other than the constructor?
  3. For class ToasterOven(Heater, Timer) where both Heater and Timer subclass Appliance, write out the MRO and justify each position from the two C3 rules.
  4. Why does defining __eq__ break set membership, and what is the fix?
  5. Name the dunder method behind each of len(x), x[2], for i in x, k in x, and with x: — and say what returning NotImplemented from __eq__ causes Python to do next.

Hands-on exercise

Time to make all of this fail and then work, on purpose. In the Day 68 lab, Protocols and Hierarchies, you break inheritance deliberately, derive an MRO before you print it, rebuild the same design with composition, and then build a container class that plain Python builtins accept. Work in the lab directory; every command below is run from there.

First, watch the classic inheritance bug happen for real, then watch the fix:

python3 examples/01_inheritance_super.py

Next, predict the MRO of the four-class diamond on paper, then check yourself:

python3 examples/02_mro_diamond.py

Then compare the two designs of one domain side by side, and write your comparison into the worksheet:

python3 examples/03_composition.py

Now the main build. Open starter/kitchen.py and complete its six numbered exercises — __len__, __getitem__, __iter__, __contains__, __eq__ with __hash__, and __lt__ — then prove the builtins work on your class:

python3 starter/kitchen.py

Finish with the context manager and the abstract base class, then run the suite:

python3 examples/05_context_manager.py
python3 examples/06_abstract_base.py
bash tests/run_tests.sh

Expected output

A correct session with the reference files looks exactly like this (abbreviated to the lines that matter):

$ python3 examples/01_inheritance_super.py
-- broken: subclass __init__ never calls super().__init__() --
AttributeError: 'BrokenOven' object has no attribute 'name'
-- fixed: super().__init__(name, watts) restores the parent's setup --
Deck oven (3200W), 60L

$ python3 examples/02_mro_diamond.py
ToasterOven -> Heater -> Timer -> Appliance -> object
ToasterOven: ready -> Heater: elements warming -> Timer: clock started -> Appliance: power on

$ python3 examples/06_abstract_base.py
Grill: grilling mackerel over charcoal
TypeError: Can't instantiate abstract class PastryStation without an implementation for abstract method 'prepare'

The broken constructor fails in describe, not in __init__ — that displacement is the lesson. The diamond’s MRO lists Appliance exactly once. And the incomplete subclass is refused at construction, by name.

Validate your work

You are done when you can check every box:

Troubleshooting

Common mistakes

Practice assignment

Model a small domain of your own twice — once with inheritance, once with composition — and then give it a protocol, keeping everything in your Day 68 lab folder. Choose a domain with real shared behaviour and at least three concrete types: musical instruments (string, wind, percussion), vehicles (car, bicycle, truck), or library items (book, audiobook, magazine). First, build the inheritance version: a base class holding the shared state and one shared concrete method, at least two subclasses that each call super().__init__(...), one method genuinely overridden and one extended through super(), and a printed __mro__ for one subclass. Second, rebuild the identical behaviour with composition: pull the shared behaviour into one or two small collaborator classes, hold them as attributes, and write the forwarding methods by hand. Third, write a short comparison in a comparison.md file: which version you would keep and why, which specific change would be easier in each, and one concrete change to the base class that would break a subclass in the inheritance version but not in the composition version. Fourth, build a container class over your domain — a Collection holding your items — implementing __len__, __getitem__, __iter__, __contains__, __repr__, __eq__ with a matching __hash__, and __lt__ under @total_ordering, and prove it by printing the results of len(...), an index, a for loop, an in test, sorted(...), and max(..., key=len). Finally, add an abc.ABC base declaring one @abstractmethod that all three concrete types implement, and capture the real TypeError you get when you deliberately instantiate an incomplete subclass. Every output you record must come from a run you actually performed.

Extension challenge

Push the container further, one honest step at a time. First, make your Menu a real sequence by inheriting from collections.abc.Sequence and deleting your hand-written __contains__ and __iter__ — confirm with a run that membership, iteration, reversed(), .index(), and .count() all still work, then write one sentence on what you traded away (an inherited base class) for what you gained (five methods). Second, add __add__ so that two menus concatenate into a new one, and __radd__ so sum(list_of_menus, Menu("empty")) works; verify that both return a new object rather than mutating either operand. Third, add a __getitem__ that also accepts a dish name as a string key, raising KeyError with a helpful message when it is missing, and note in a comment why supporting both integers and strings in one dunder is a design smell worth thinking twice about. Fourth, write a second context manager class, ServiceLog, whose __exit__ inspects its three parameters and prints a different closing line when the block raised — proving you understand that __exit__ receives the exception type, value, and traceback, and that returning True from it would suppress the error. Finally, make one of your classes a typing.Protocol implementer rather than a subclass: define a runtime_checkable protocol with the two methods your code actually calls, drop the inheritance, and show with a real isinstance result that the class still satisfies the interface. You will have built a class that plain Python treats as a first-class citizen, and you will have done it the way the machine-learning libraries in the rest of this course expect you to.

Quiz

Q1. A subclass defines its own `__init__` and never calls `super().__init__(...)`. What actually happens?

  1. The class definition is rejected, because Python requires the call
  2. The parent's `__init__` still runs automatically, just after the child's
  3. The object is constructed successfully but incompletely, and the failure surfaces later in some other method as an `AttributeError`
  4. Construction raises `TypeError` immediately, naming the parent
Show answer

Answer: C. The object is constructed successfully but incompletely, and the failure surfaces later in some other method as an `AttributeError`

Defining `__init__` in a subclass REPLACES the parent's; nothing calls it for you. Construction therefore succeeds and hands you an object missing whatever the parent would have set. The error waits until some later method reads one of those attributes, which is why the traceback blames `describe` rather than the constructor that actually failed you. That displacement is what makes this the most common inheritance bug.

Q2. For `class ToasterOven(Heater, Timer)`, where `Heater` and `Timer` both inherit from `Appliance`, what is the method resolution order?

  1. ToasterOven, Heater, Timer, Appliance, object
  2. ToasterOven, Heater, Appliance, Timer, Appliance, object
  3. ToasterOven, Timer, Heater, Appliance, object
  4. ToasterOven, Appliance, Heater, Timer, object
Show answer

Answer: A. ToasterOven, Heater, Timer, Appliance, object

C3 linearization guarantees two things you can re-derive by hand. Local precedence puts a class before its parents, and monotonicity preserves the order the bases were listed in — so `Heater` comes before `Timer`. `Appliance` cannot appear after `Heater` even though it is Heater's parent, because `Timer` also inherits from it and must precede its own parent. Hence Heater, Timer, then Appliance. Note that `Appliance` appears exactly once, which is precisely what stops it being initialised twice.

Q3. Inside `Heater.power_on`, the call `super().power_on()` reaches `Timer.power_on` — even though `Heater` does not inherit from `Timer`. Why?

  1. Because `Timer` was defined after `Heater` in the source file
  2. Because `super()` searches every class in the program until it finds a match
  3. Because Python silently added `Timer` to Heater's bases when the diamond was built
  4. Because `super()` means "the next class in THIS instance's MRO", not "my parent class"
Show answer

Answer: D. Because `super()` means "the next class in THIS instance's MRO", not "my parent class"

`super()` is bound to the instance, not to the class it is written in. It walks to whatever comes next in that instance's MRO, which for a `ToasterOven` instance is `Timer`. `Heater.__bases__` is still just `(Appliance,)` — nothing was added. This cooperative chaining is what makes multiple inheritance work, and it is also exactly why it is hard to read: you cannot tell where a `super()` call lands by looking at one class in isolation.

Q4. You add `__eq__` to a class and later get `TypeError: unhashable type`. What happened, and what is the fix?

  1. Equality is only allowed on immutable classes; remove `__eq__`
  2. Defining `__eq__` sets `__hash__` to `None`, so define `__hash__` over the same fields `__eq__` compares
  3. The class needs `__lt__` before `__eq__` will work in a set
  4. `__eq__` must return `NotImplemented` for every type, which restores hashing
Show answer

Answer: B. Defining `__eq__` sets `__hash__` to `None`, so define `__hash__` over the same fields `__eq__` compares

Objects that compare equal must hash equal, and Python will not guess which fields you meant. Rather than let you build a subtly broken set, it sets `__hash__` to `None` the moment you define `__eq__`, making instances unhashable. The fix is to hash a tuple of the same identifying fields — `hash((self.name, self.minutes))`. Leaving it unhashable is also legitimate for genuinely mutable objects, but it should be a decision rather than an accident.

Q5. Why should `__eq__` return `NotImplemented` rather than `False` for a type it does not recognise?

  1. Returning `NotImplemented` lets Python try the reflected `__eq__` on the other operand, which may know how to do the comparison
  2. Because `False` would raise a `TypeError` in Python 3
  3. It makes the comparison faster by skipping the reflected lookup
  4. There is no difference; `NotImplemented` is just a stylistic convention
Show answer

Answer: A. Returning `NotImplemented` lets Python try the reflected `__eq__` on the other operand, which may know how to do the comparison

Returning `False` is a confident, final answer. Returning `NotImplemented` says "I do not know — ask the other side," and Python then tries `other.__eq__(self)`. In the lesson's worked example, `Grams` bows out, `Ounces` knows the conversion, and the pair produces a correct `True` that neither class could have produced alone. Only when both operands decline does Python fall back to identity comparison and yield `False`.

Q6. Which single statement best captures why "prefer composition over inheritance" is the default advice?

  1. Inheritance is slower at runtime because attribute lookup walks the MRO
  2. Composition uses less memory than inheritance for the same behaviour
  3. Inheritance is deprecated in modern Python and should be avoided entirely
  4. A subclass depends on its parent's internals, so a safe-looking base-class edit can break it at a distance, while an object that merely holds another depends only on the small interface it calls
Show answer

Answer: D. A subclass depends on its parent's internals, so a safe-looking base-class edit can break it at a distance, while an object that merely holds another depends only on the small interface it calls

This is the fragile base class problem, and it is about coupling rather than performance. The MRO walk does cost a little, but that is almost never a bottleneck and is a bad reason to change a design; memory is not the issue either; and inheritance is certainly not deprecated — frameworks require it, and small stable hierarchies like Python's own exception tree are a good use of it. The real cost is that a subclass cannot be understood, tested, or reused without its entire ancestry.

Q7. What is the practical difference between `abc.ABC` and `typing.Protocol`?

  1. `abc.ABC` works at runtime while `typing.Protocol` exists only in documentation and is never checkable
  2. An ABC requires classes to inherit from it and enforces missing methods at instantiation; a Protocol is structural, so any class with matching methods qualifies without inheriting anything
  3. They are interchangeable; `typing.Protocol` is simply the newer spelling of `abc.ABC`
  4. A Protocol enforces its methods at instantiation, while an ABC is only checked by external type checkers
Show answer

Answer: B. An ABC requires classes to inherit from it and enforces missing methods at instantiation; a Protocol is structural, so any class with matching methods qualifies without inheriting anything

An ABC is nominal typing: you must be in the family, and Python refuses to construct an incomplete member. A Protocol is structural typing — duck typing written down — and a class satisfies it purely by having the right methods, which is why you can apply one to classes other people wrote. Marking a Protocol `runtime_checkable` does make `isinstance` work on it, so it is not documentation-only. Choose an ABC when you own the hierarchy and want the enforcement; choose a Protocol when you are consuming code you cannot change.

Q8. A `with` block's body raises an exception. What does the object's `__exit__` do, and what does its return value control?

  1. `__exit__` is skipped when the body raises; only a `finally` clause would run
  2. `__exit__` runs and always suppresses the exception, which is why cleanup is guaranteed
  3. `__exit__` runs either way, receiving the exception type, value and traceback; returning a truthy value suppresses the exception, while returning `False` lets it continue to the caller
  4. `__exit__` runs only if `__enter__` returned a non-None value
Show answer

Answer: C. `__exit__` runs either way, receiving the exception type, value and traceback; returning a truthy value suppresses the exception, while returning `False` lets it continue to the caller

The guarantee is that `__exit__` runs on the way out of the block whether it completed or blew up — that is the whole reason the protocol exists, and it is what made `with open(...)` trustworthy on Day 64. Its three parameters are all `None` on a clean exit. The return value is a separate decision: `True` tells Python the exception has been handled and it vanishes, which is how errors silently disappear, so return `False` unless you have a very good reason not to.

Glossary

inheritance
A mechanism for one class to acquire the attributes and methods of another and to override or extend them. Written by naming the parent in parentheses. It expresses an "is-a" relationship, and its real purpose is substitutability — a subclass should be usable anywhere the parent is — with code reuse as a side effect.
base class
The class being inherited from, also called the superclass or parent. Every Python class ultimately has `object` as a base, which is where the default `__repr__`, `__eq__`, and `__hash__` come from.
composition
Building a class out of other objects held as attributes rather than inheriting from them. It expresses a "has-a" relationship. An Oven does not need to BE a Timer in order to hold one.
delegation
Passing a call through to a stored collaborator object — the forwarding method that makes composition work. More typing than inheritance, and far more visible, because the wiring is written out rather than found by walking a hierarchy.
method resolution order (MRO)
The fixed, ordered list of classes Python searches when looking up an attribute on an instance. Readable as `SomeClass.__mro__`. Attribute lookup checks the instance first, then walks this list until it finds the name.
C3 linearization
The algorithm Python has used since version 2.3 to compute the MRO. It guarantees local precedence — a class always precedes its parents — and monotonicity — the order bases are listed in is preserved. If no consistent order exists it raises TypeError at class-definition time rather than guessing.
diamond problem
The ambiguity that arises when a class inherits from two classes that share a common ancestor: in what order should the interpreter search? C3 answers it deterministically and lists the shared ancestor exactly once, so it is never initialised or executed twice.
super()
A call that dispatches to the NEXT class in this instance's MRO — which under multiple inheritance is often not the parent you would guess, and may be a class the current one does not inherit from at all. Conventionally called first inside a subclass ``__init__``.
overriding
Defining a method in a subclass that replaces the parent's version entirely. The parent's implementation does not run.
extending
Overriding a method but still calling the original through `super()`, so both run. The distinction from plain overriding is the difference between replacing a behaviour and adding to it.
fragile base class problem
The failure mode where a seemingly safe change to a base class breaks subclasses written by people who never read it, because subclasses depend on the parent's internal behaviour rather than only its public surface. Named as class libraries grew in the 1990s, and the main reason composition is the default advice.
dunder method
A method whose name begins and ends with double underscores, such as `__len__` or `__eq__`, which the interpreter calls on your behalf when you use ordinary syntax. Short for "double underscore". They are the language's public protocol, not private helpers — though inventing new dunder names of your own is discouraged, since those are reserved.
data model
Python's complete system of special method names — the set of hooks that connects syntax to behaviour, so that `len(x)` becomes `type(x).__len__(x)` and `x[k]` becomes `type(x).__getitem__(x, k)`. Implementing them is how a class you wrote joins the language rather than sitting beside it.
protocol
An informal interface defined by which methods an object has rather than by what it inherits from. Implementing a protocol is what lets builtins and library code written years earlier — `sorted`, `max`, a DataLoader — operate on a class you wrote this afternoon.
duck typing
Treating an object as usable because it has the required methods, not because of its ancestry: if it walks like a duck, it is treated like one. It is why a function that only calls `.read()` should accept a file, a socket wrapper, or a test double equally.
abstract base class
A class built with `abc.ABC` that cannot be instantiated and that names methods subclasses must implement, using the `@abstractmethod` decorator. An incomplete subclass is refused at construction, naming the missing method, rather than failing mysteriously much later.
structural typing
Checking that an object has the right shape rather than the right ancestry — duck typing written down. Provided by `typing.Protocol` since Python 3.8, and useful precisely when you consume objects other people wrote and cannot demand they inherit from you.
mixin
A small class providing one orthogonal capability, never instantiated on its own, added to unrelated classes through multiple inheritance. If it starts holding state or growing methods, it wants to be a collaborator object instead.
NotImplemented
A special value RETURNED from a comparison dunder to mean "I do not know how to compare against this type — ask the other operand." Distinct from `NotImplementedError`, which is an exception you RAISE from an unfinished method. Returning `False` instead is how comparisons become silently wrong.
hashable
An object usable as a dict key or set element, because it has a `__hash__` returning a stable integer. Defining `__eq__` sets `__hash__` to `None`, so the two must be defined together — and only over fields that do not change, since a mutated object is lost in its set.
context manager
An object implementing `__enter__` and `__exit__`, usable with the `with` statement. `__exit__` runs on the way out of the block whether it succeeded or raised, receiving the exception type, value, and traceback; returning a truthy value from it suppresses the exception.
template method
The shape where a concrete method in a base class calls an abstract one that subclasses fill in — the framework calls your code rather than the other way round. It is exactly how a PyTorch `nn.Module` calls the `forward` you wrote.

Sources and further reading


Kept in this browser, no account needed. Your progress page turns the whole record into one link you can bookmark or open on another device.