Level 4 · Pythonic

Inheritance and Dunder Methods 🧬

Classes can build on other classes. This is the most over-used feature in programming, so this lesson teaches it and then teaches you when not to.

Inheriting

class Character:
    def __init__(self, name, hp=100):
        self.name = name
        self.hp = hp

    def speak(self):
        return f"{self.name} says nothing."

    def __repr__(self):
        return f"{type(self).__name__}({self.name!r}, hp={self.hp})"


class Pirate(Character):
    def __init__(self, name, insults=0):
        super().__init__(name, hp=120)      # run the parent's setup
        self.insults = insults

    def speak(self):                        # override the parent's version
        return f"{self.name}: You fight like a dairy farmer!"


class Ghost(Character):
    def speak(self):
        parent_line = super().speak()       # extend rather than replace
        return parent_line + " (it is a ghost, so this is unsurprising)"


for character in [Character("Otis"), Pirate("Guybrush", 8), Ghost("LeChuck", hp=999)]:
    print(character)
    print("  " + character.speak())
Character('Otis', hp=100)
  Otis says nothing.
Pirate('Guybrush', hp=120)
  Guybrush: You fight like a dairy farmer!
Ghost('LeChuck', hp=999)
  LeChuck says nothing. (it is a ghost, so this is unsurprising)

Three things happened there:

isinstance, and the type of a thing

class Character: pass
class Pirate(Character): pass


guy = Pirate()

print(isinstance(guy, Pirate))
print(isinstance(guy, Character))     # a pirate IS a character
print(type(guy) is Pirate)
print(type(guy) is Character)         # but its exact type is not Character
print(Pirate.__mro__)
True
True
True
False
(<class '__main__.Pirate'>, <class '__main__.Character'>, <class 'object'>)

__mro__ is the method resolution order: the exact list of classes Python searches, in order, when you use a dot. Everything inherits from object in the end.

Dunder methods: hooking into the language

Those __double_underscore__ names are how your objects plug into Python's own syntax. Define the right one and +, len(), ==, in and for all start working on your type.

class Inventory:
    def __init__(self, items=None):
        self.items = list(items or [])

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

    def __getitem__(self, index):
        return self.items[index]

    def __contains__(self, item):
        return item in self.items

    def __add__(self, other):
        return Inventory(self.items + other.items)

    def __eq__(self, other):
        return isinstance(other, Inventory) and sorted(self.items) == sorted(other.items)

    def __repr__(self):
        return f"Inventory({self.items!r})"


bag = Inventory(["map", "grog"])
pockets = Inventory(["mints"])

print(len(bag))
print(bag[0])
print("grog" in bag)
print(bag + pockets)
print(Inventory(["a", "b"]) == Inventory(["b", "a"]))

for item in bag:            # works via __getitem__, no __iter__ needed
    print(" -", item)
2
map
True
Inventory(['map', 'grog', 'mints'])
True
 - map
 - grog
Write thisAnd this works
__len__len(x), and truthiness
__getitem__x[0], slicing, and iteration
__iter__for i in x (the proper way, Lesson 34)
__contains__y in x
__eq__x == y
__lt__x < y, and sorted()
__add__x + y
__call__x(), making the object callable
__enter__ / __exit__with x: (Lesson 36)
⚖️ If you write __eq__, think about __hash__

Defining __eq__ sets __hash__ to None, so your objects can no longer go in a set or be dictionary keys. That is deliberate: two objects that are equal must hash the same, and Python will not guess your rule. Either define __hash__ too, or use a frozen dataclass, which does it for you.

Sorting your own objects

from functools import total_ordering


@total_ordering
class Score:
    def __init__(self, name, points):
        self.name = name
        self.points = points

    def __eq__(self, other):
        return self.points == other.points

    def __lt__(self, other):
        return self.points < other.points

    def __repr__(self):
        return f"{self.name}({self.points})"


scores = [Score("Otis", 42), Score("Guybrush", 95), Score("Elaine", 88)]

print(sorted(scores))
print(max(scores))
print(Score("a", 10) >= Score("b", 10))
[Otis(42), Elaine(88), Guybrush(95)]
Guybrush(95)
True

@total_ordering fills in >, <= and >= from the two you wrote. Often though, the simplest answer is no dunder methods at all: sorted(scores, key=lambda s: s.points).

Composition usually beats inheritance

# Inheritance: a Car IS an Engine? Obviously not.
class Engine:
    def start(self):
        return "vroom"


class BadCar(Engine):          # wrong relationship
    pass


# Composition: a Car HAS an Engine. Much better.
class Car:
    def __init__(self, engine):
        self.engine = engine

    def start(self):
        return f"Car starting: {self.engine.start()}"


print(BadCar().start())
print(Car(Engine()).start())
vroom
Car starting: vroom
CONCEPTUALIZATION[Formidable: Success]

The test is a sentence. 'A pirate is a character': true, inherit. 'A car is an engine': false, so the car should hold an engine instead.

Inheritance couples you to the parent's entire interface forever, including the parts you did not want. Composition lets you keep only the piece you need and swap it later. Deep inheritance hierarchies are the classic sign of a codebase written by someone who had just learned about inheritance.

Abstract base classes: promising an interface

from abc import ABC, abstractmethod


class Storage(ABC):
    """Anything that can save and load a value."""

    @abstractmethod
    def save(self, key, value): ...

    @abstractmethod
    def load(self, key): ...

    def save_many(self, pairs):
        """A useful method every subclass gets for free."""
        for key, value in pairs.items():
            self.save(key, value)
        return len(pairs)


class MemoryStorage(Storage):
    def __init__(self):
        self.data = {}

    def save(self, key, value):
        self.data[key] = value

    def load(self, key):
        return self.data.get(key)


store = MemoryStorage()
print(store.save_many({"ship": "Sea Monkey", "crew": 12}))
print(store.load("ship"))

try:
    Storage()
except TypeError as err:
    print("TypeError:", err)
2
Sea Monkey
TypeError: Can't instantiate abstract class Storage without an implementation for abstract methods 'load', 'save'

An abstract base class says "any subclass must provide these". You cannot build the base itself, and forgetting a method fails loudly at construction rather than quietly at 3am. It is how you write a plugin interface: one Storage for memory, one for a file, one for a database, all interchangeable.

Duck typing: the Python way

class Duck:
    def speak(self):
        return "quack"


class Robot:
    def speak(self):
        return "beep"


def make_it_talk(thing):
    """No inheritance, no interface, no type check. Just: can it speak?"""
    return thing.speak()


for thing in [Duck(), Robot()]:
    print(make_it_talk(thing))
quack
beep

"If it walks like a duck and quacks like a duck, it is a duck." Python mostly does not care what class something is, only whether it has the method you are about to call. This is why Python needs far less inheritance than Java or C++: you can substitute any object that behaves right, with no shared ancestor at all. Lesson 38's Protocol lets you type-check exactly this.

Exercise 1

A shape hierarchy

Write an abstract Shape with an abstract area and a concrete describe. Implement Circle and Rectangle, then sort a list of them by area.

Reveal solution
from abc import ABC, abstractmethod
import math


class Shape(ABC):
    @abstractmethod
    def area(self): ...

    def describe(self):
        return f"{type(self).__name__} with area {self.area():.2f}"


class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return math.pi * self.radius ** 2


class Rectangle(Shape):
    def __init__(self, width, height):
        self.width, self.height = width, height

    def area(self):
        return self.width * self.height


shapes = [Circle(3), Rectangle(2, 5), Circle(1)]
for shape in sorted(shapes, key=lambda s: s.area()):
    print(shape.describe())
Circle with area 3.14
Rectangle with area 10.00
Circle with area 28.27
Exercise 2

Inheritance or composition?

For each, say which you would use and why.

  1. A SavingsAccount and a CurrentAccount.
  2. A Logger that a WebServer uses.
  3. Dog, Cat and Animal.
  4. A Playlist and the Songs in it.
  5. A Button that needs to be clickable and draggable and resizable.
Reveal solution
  1. Inheritance. Both genuinely are accounts and share behaviour.
  2. Composition. A server is not a logger, it has one. It should also be swappable for a silent one in tests.
  3. Inheritance, and it is the textbook example precisely because real domains are rarely this clean.
  4. Composition. A playlist contains songs.
  5. Composition, almost certainly. Three separate behaviours combined is what mixins and multiple inheritance were invented for, and it is also where inheritance hierarchies most reliably become unmaintainable. Prefer small collaborating objects.
Exercise 3

Make a class feel built in

Write a Playlist supporting len(), indexing, in, +, iteration and a readable repr.

Reveal solution
class Playlist:
    """A named list of songs that behaves like a built-in sequence."""

    def __init__(self, name, songs=None):
        self.name = name
        self.songs = list(songs or [])

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

    def __getitem__(self, index):
        return self.songs[index]

    def __contains__(self, song):
        return song in self.songs

    def __add__(self, other):
        return Playlist(f"{self.name} + {other.name}", self.songs + other.songs)

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


sea = Playlist("Sea Shanties", ["Wellerman", "Drunken Sailor"])
grog = Playlist("Grog Anthems", ["A Pirate I Was Meant To Be"])

print(sea, len(sea))
print(sea[0])
print("Wellerman" in sea)

both = sea + grog
print(both)
for song in both:
    print(" -", song)
Playlist('Sea Shanties', 2 songs) 2
Wellerman
True
Playlist('Sea Shanties + Grog Anthems', 3 songs)
 - Wellerman
 - Drunken Sailor
 - A Pirate I Was Meant To Be

Nobody using this class needs to know it is not a list. That is the whole point of the dunder protocol: your types get to be first-class citizens of the language.

+100 XP