Object-oriented Python · about 45 min

Special (dunder) methods

Why this matters

Why does len() work on lists, strings and dicts? Why can you write a + b for numbers but also for strings and lists, with different meanings? Python's built-in operations are not hard-wired to built-in types. Each one calls a method with a special name, such as __len__ or __add__. Define those methods on your own class and your objects work with len, +, ==, in, for, sorted and print just like built-in ones. This is called the data model, and it is what makes Python code read so naturally.

The idea: syntax becomes method calls

Every operator and many built-in functions translate into a call on the operand's type:

You writePython calls
repr(x), REPL displaytype(x).__repr__(x)
str(x), print(x), f-string {x}type(x).__str__(x)
x == ytype(x).__eq__(x, y)
x < ytype(x).__lt__(x, y)
x + ytype(x).__add__(x, y)
len(x)type(x).__len__(x)
item in xtype(x).__contains__(x, item)
x[i]type(x).__getitem__(x, i)
for v in xtype(x).__iter__(x)
bool(x), if x:type(x).__bool__(x) (or __len__)

They are nicknamed dunder methods (double underscore). You implement them; you rarely call them directly. Notice that Python looks them up on the type, not the instance: setting obj.__len__ = ... on one object does nothing for len(obj).

You can check this with a built-in:

>>> (3).__add__(4)
7
>>> "ab".__len__()
2
>>> [1, 2].__contains__(2)
True

repr and str

Without them, your objects print unhelpfully:

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

print(Point(1, 2))
<__main__.Point object at 0x7f3a2c1b6e40>

That default comes from object.__repr__, the class every class ultimately inherits from. Add two methods:

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __repr__(self):
        return f"Point({self.x!r}, {self.y!r})"

    def __str__(self):
        return f"({self.x}, {self.y})"

p = Point(1, 2)
print(p)          # uses __str__
print(repr(p))    # uses __repr__
print([p, p])     # containers show their items' repr
(1, 2)
Point(1, 2)
[Point(1, 2), Point(1, 2)]

The convention:

The !r in an f-string applies repr() to the value, which is why {self.name!r} gives 'Ada' with quotes.

Equality: eq and NotImplemented

By default, == on your objects means identity (is): two separate Point(1, 2) objects are unequal. Define __eq__ to compare by value:

    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return (self.x, self.y) == (other.x, other.y)

NotImplemented is a special built-in value (not the exception NotImplementedError). Returning it means "I do not know how to compare with that". Python then gives the other operand a chance (it tries other.__eq__(self)), and if that also declines, == falls back to identity and returns False. So Point(1, 2) == "hello" is simply False instead of crashing.

You get != for free: Python's default __ne__ inverts __eq__.

Note: Defining __eq__ sets __hash__ to None, making instances unhashable (they cannot be dict keys or set members). That is deliberate: objects that are equal must have equal hashes, and Python cannot guess yours. Lesson 5 (dataclasses) and the internals module come back to this.

Arithmetic: add and the reflected radd

class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __repr__(self):
        return f"Vector({self.x}, {self.y})"
    def __add__(self, other):
        if not isinstance(other, Vector):
            return NotImplemented
        return Vector(self.x + other.x, self.y + other.y)
    def __mul__(self, k):
        if not isinstance(k, (int, float)):
            return NotImplemented
        return Vector(self.x * k, self.y * k)
    def __rmul__(self, k):
        return self.__mul__(k)

v = Vector(1, 2) + Vector(10, 20)
print(v, v * 3, 3 * v)
Vector(11, 22) Vector(33, 66) Vector(33, 66)

The full dispatch for a + b:

1. r = type(a).__add__(a, b)       if r is not NotImplemented: done
2. r = type(b).__radd__(b, a)      if r is not NotImplemented: done
3. TypeError: unsupported operand type(s) for +: 'A' and 'B'

3 * v works because int.__mul__(3, v) returns NotImplemented (an int does not know vectors), so Python calls Vector.__rmul__(v, 3). Operators should return new objects and leave their operands unchanged; += uses __iadd__ if you define it, otherwise it falls back to __add__ and rebinds the name.

Ordering: lt and friends

sorted(), min(), max() and list.sort() only need <, i.e. __lt__:

class Task:
    def __init__(self, priority, name):
        self.priority, self.name = priority, name
    def __repr__(self):
        return f"Task({self.priority}, {self.name!r})"
    def __lt__(self, other):
        if not isinstance(other, Task):
            return NotImplemented
        return self.priority < other.priority

print(sorted([Task(3, "c"), Task(1, "a"), Task(2, "b")]))
[Task(1, 'a'), Task(2, 'b'), Task(3, 'c')]

For x > y, Python tries x.__gt__(y) and then the reflection y.__lt__(x), so defining __lt__ alone also gives you >. For the full set (<=, >=), define them or use the functools.total_ordering decorator, which fills in the rest from __eq__ and one ordering method.

Containers: len, contains, getitem, iter

class Deck:
    def __init__(self, cards):
        self._cards = list(cards)
    def __len__(self):
        return len(self._cards)
    def __getitem__(self, i):
        return self._cards[i]
    def __contains__(self, card):
        return card in self._cards
    def __iter__(self):
        return iter(self._cards)

d = Deck(["AS", "KH", "7C"])
print(len(d), d[0], d[-1], "KH" in d, d[1:])
for c in d:
    print(c, end=" ")
print()
3 AS 7C True ['KH', '7C']
AS KH 7C

Slicing works because d[1:] passes a slice object to __getitem__, which the inner list understands. Python also has fallbacks: without __iter__, a for loop will call __getitem__(0), __getitem__(1), ... until IndexError; without __contains__, in iterates. Without __bool__, truthiness uses __len__ (zero length is false). Explicit methods are clearer and faster. The iterator protocol behind __iter__ gets a full lesson in the next module.

Common mistakes

Recap

Going further: read "Data model", section 3.3 "Special method names" in the Python Language Reference, and look at functools.total_ordering.

Exercises

Exercise 1 · quiz

What does + call?

a and b are instances of two different classes. When Python evaluates a + b, what does it try first, and what next if that returns NotImplemented?

Answer
  • a.add(b), then b.radd(a)

a + b calls type(a).__add__(a, b). If that method is missing or returns the special value NotImplemented, Python tries the reflected method type(b).__radd__(b, a). Only if both decline do you get TypeError: unsupported operand type(s) for +. Returning NotImplemented (not raising) is how a method says "I do not know how to handle that type, ask the other side".

Exercise 2 · write code

A Money type

In solution.py, write a class Money holding an amount in whole cents and a currency code:

File: solution.py

Starting point

class Money:
    def __init__(self, cents, currency):
        self.cents = cents
        self.currency = currency
Hint 1

f"{self.currency!r}" gives 'USD' with quotes.

Hint 2

For __str__: cents // 100 is the dollars, cents % 100 formatted with :02d is the cents.

Hint 3

Start __eq__ and __add__ with if not isinstance(other, Money): return NotImplemented.

Reference solution
class Money:
    def __init__(self, cents, currency):
        self.cents = cents
        self.currency = currency

    def __repr__(self):
        return f"Money({self.cents}, {self.currency!r})"

    def __str__(self):
        return f"{self.cents // 100}.{self.cents % 100:02d} {self.currency}"

    def __eq__(self, other):
        if not isinstance(other, Money):
            return NotImplemented
        return (self.cents, self.currency) == (other.cents, other.currency)

    def __add__(self, other):
        if not isinstance(other, Money):
            return NotImplemented
        if self.currency != other.currency:
            raise ValueError(f"cannot add {self.currency} and {other.currency}")
        return Money(self.cents + other.cents, self.currency)

Exercise 3 · write code

A container: Playlist

In solution.py, write a class Playlist that wraps a list of song titles:

Implement __len__, __contains__, __getitem__, __iter__ and __repr__.

File: solution.py

Starting point

class Playlist:
    def __init__(self, name, songs):
        self.name = name
        self.songs = songs
Hint 1

list(songs) makes a shallow copy.

Hint 2

Each special method can delegate to the inner list: len(self.songs), title in self.songs, self.songs[index].

Hint 3

__iter__ must return an iterator: return iter(self.songs).

Reference solution
class Playlist:
    def __init__(self, name, songs):
        self.name = name
        self.songs = list(songs)

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

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

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

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

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

Exercise 4 · write code

Make objects sortable

In solution.py, write a class Version for version strings such as "1.10.2":

Versions with different lengths compare like tuples: Version("1.2") < Version("1.2.1").

File: solution.py

Starting point

class Version:
    def __init__(self, text):
        self.text = text
Hint 1

tuple(int(p) for p in text.split(".")) turns "1.10.2" into (1, 10, 2).

Hint 2

Tuples already compare element by element, so __lt__ can return self.parts < other.parts.

Hint 3

sorted only needs __lt__.

Reference solution
class Version:
    def __init__(self, text):
        self.text = text
        self.parts = tuple(int(p) for p in text.split("."))

    def __repr__(self):
        return f"Version({self.text!r})"

    def __eq__(self, other):
        if not isinstance(other, Version):
            return NotImplemented
        return self.parts == other.parts

    def __lt__(self, other):
        if not isinstance(other, Version):
            return NotImplemented
        return self.parts < other.parts