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:
- Inheriting means a
Pirategets everythingCharacterhas for free. - Overriding means defining a method the parent already has; yours wins.
super()calls the parent's version, so you can extend rather than replace. Forgettingsuper().__init__()is the classic bug: the parent's attributes never get set.
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 this | And 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) |
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
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.
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.27Inheritance or composition?
For each, say which you would use and why.
- A
SavingsAccountand aCurrentAccount. - A
Loggerthat aWebServeruses. Dog,CatandAnimal.- A
Playlistand theSongs in it. - A
Buttonthat needs to be clickable and draggable and resizable.
Reveal solution
- Inheritance. Both genuinely are accounts and share behaviour.
- Composition. A server is not a logger, it has one. It should also be swappable for a silent one in tests.
- Inheritance, and it is the textbook example precisely because real domains are rarely this clean.
- Composition. A playlist contains songs.
- 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.
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 BeNobody 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.