Make your classes feel built in
Special methods for printing, equality, ordering, arithmetic and containers.
Create a free account Try part of it here
Why it matters
Why does len() work on lists, strings and DataFrames alike? Why can you add two numpy arrays with +? Python turns syntax into calls to special methods with double-underscore names, often called "dunder" methods. Define them on your own classes and your objects work with print, ==, sorted, sum, len, in and for, like the built-in types. That's what makes a library pleasant to use, and it's how pandas, NumPy and PyTorch feel so natural.
What you'll be able to do
- Give objects useful text with __repr__ and __str__
- Define equality and hashing so objects work in sets and as dictionary keys
- Make objects sortable, addable and usable with len(), in and for
- Know which special method Python calls for each piece of syntax
What it covers
- Syntax is method calls
- repr for developers, str for people
- Equality and hashing
- Ordering, adding and sum()
- Subtraction and truth
- Containers: len, in, for and [ ]
- Designing a value type
- Worked example: a price list that reads naturally
Syntax is method calls
Each piece of Python syntax is a call to a special method on an object:
| You write | Python calls |
|---|---|
repr(x), or x shown in the console | x.__repr__() |
str(x), print(x), f"{x}" | x.__str__() |
a == b | a.__eq__(b) |
a < b | a.__lt__(b) |
a + b | a.__add__(b), then b.__radd__(a) if that fails |
len(x) | x.__len__() |
item in x | x.__contains__(item) |
for item in x | x.__iter__() |
x[key] | x.__getitem__(key) |
if x: | x.__bool__(), or __len__ |
x(arg) | x.__call__(arg) |
This lesson builds a Money type for the practice shop. Money is a classic case: floats can't store cents exactly (0.1 + 0.2 isn't 0.3), so the value is kept as a whole number of cents, and the special methods make it as easy to use as a number.
repr for developers, str for people
__repr__ should be unambiguous, ideally code that would recreate the object. It's what you see in the console, in lists and in error messages. __str__ is the friendly version for people. Without __str__, Python uses __repr__; without either, you get <Money object at 0x10f3...>, which helps nobody.
class Money: def __init__(self, cents): self.cents = int(cents) @classmethod def dollars(cls, amount): return cls(round(amount * 100)) def __repr__(self): return f"Money({self.cents})" def __str__(self): sign = "-" if self.cents < 0 else "" return f"{sign}${abs(self.cents) / 100:,.2f}" price = Money.dollars(1249.5) print(price) print(repr(price)) print([price, Money(99)]) print(f"Total: {price}")
Notice the list prints its items with repr: collections always show what's inside unambiguously.
sum() starts with 0, so the first step calls __radd__; after that, __add__ joins the amounts. print() finally calls __repr__ (there's no __str__).
Equality and hashing
By default, == on your objects checks whether they're the same object, so two Money(500) aren't equal. Define __eq__ to compare values. Then define __hash__ too, consistent with it: equal objects must have equal hashes, or sets and dictionaries break.
class Money: def __init__(self, cents): self.cents = int(cents) def __repr__(self): return f"Money({self.cents})" def __eq__(self, other): if not isinstance(other, Money): return NotImplemented return self.cents == other.cents def __hash__(self): return hash(self.cents) print(Money(500) == Money(500), Money(500) == 500) print({Money(500), Money(500), Money(250)}) prices = {Money(500): "tent stake set"} print(prices[Money(500)])
Returning NotImplemented (a special value, not an error) tells Python "I don't know how to compare with that". Python then tries the other object's method, and in the end treats them as not equal. Only hash objects whose value never changes. If cents could change after an object is in a set, the set would look in the wrong place for it. Treat value objects like Money as immutable.
class P: def __init__(self, x): self.x = x a, b = P(1), P(1) print(a == b, a == a, len({a, b}))
Show the answer
False True 2
Without __eq__, == checks identity: a and b are different objects. Without __eq__ and __hash__, a set keeps both.
Ordering, adding and sum()
functools.total_ordering writes <=, > and >= for you from __eq__ and __lt__. Arithmetic works the same way: __add__ handles a + b. sum() starts from the number 0, so it computes 0 + first. That's why __radd__ matters: it's called when the left side doesn't know how to add your object.
from functools import total_ordering @total_ordering class Money: def __init__(self, cents): self.cents = int(cents) def __repr__(self): return f"Money({self.cents})" def __eq__(self, other): return isinstance(other, Money) and self.cents == other.cents def __hash__(self): return hash(self.cents) def __lt__(self, other): if not isinstance(other, Money): return NotImplemented return self.cents < other.cents def __add__(self, other): if isinstance(other, Money): return Money(self.cents + other.cents) return NotImplemented def __radd__(self, other): return self if other == 0 else self.__add__(other) def __mul__(self, factor): return Money(round(self.cents * factor)) __rmul__ = __mul__ basket = [Money(2000), Money(450), Money(12999)] print(sorted(basket), max(basket)) print(sum(basket), Money(450) * 3, 2 * Money(450)) print(Money(450) >= Money(450))
Multiplying by a number makes sense (three mugs), but adding a plain number doesn't (is 5 dollars or cents?). So __add__ accepts only Money, and __radd__ makes the single exception for sum()'s 0. Designing a type means deciding which operations are meaningful.
Subtraction and truth
Add __sub__ (Money minus Money) and __bool__ (a Money is truthy unless it's zero) to this Money, so the last line prints Money(550) False True.
class Money: def __init__(self, cents): self.cents = int(cents) def __repr__(self): return f"Money({self.cents})" paid, price = Money(2000), Money(1450) change = paid - price print(change, bool(Money(0)), bool(change))
Containers: len, in, for and [ ]
A class that holds things can behave like a collection. __len__ answers len(), __contains__ answers in, __iter__ makes it work in a for loop (and in list(), sum() and sorted()), and __getitem__ answers square brackets:
class Cart: def __init__(self): self._lines = {} def add(self, sku, qty=1): self._lines[sku] = self._lines.get(sku, 0) + qty def __len__(self): return sum(self._lines.values()) def __contains__(self, sku): return sku in self._lines def __iter__(self): return iter(self._lines.items()) def __getitem__(self, sku): return self._lines[sku] def __call__(self, sku): self.add(sku) cart = Cart() cart.add("TE-101") cart("PA-108") cart("PA-108") print(len(cart), "TE-101" in cart, cart["PA-108"]) for sku, qty in cart: print(sku, qty) print(bool(Cart()))
__iter__ here hands back an iterator over the dictionary's items; the next lesson shows how to write iterators of your own. Inheriting from collections.abc.Mapping or Sequence instead fills in the rest of a container's methods (get, keys, index and so on) from the few you write. An empty cart is falsy with no __bool__ needed, because Python falls back to __len__. __call__ makes the object callable like a function, handy for objects that are mostly one action, but use it sparingly: cart.add(sku) says more than cart(sku).
Designing a value type
Special methods are powerful, which is a reason to use them with restraint. A few guidelines keep a class pleasant rather than surprising:
- Only add operations that have one obvious meaning. Money plus Money is clear; Money times a number is clear; Money plus a plain number isn't (dollars or cents?), so it's refused. If two people could read
a + bdifferently, use a named method instead. - Keep value types immutable. Anything you hash must not change afterwards. A frozen dataclass gives you
__eq__,__hash__and__repr__for free, and refuses accidental changes. - Return new objects from arithmetic rather than changing
self.a + bshouldn't altera. - Return NotImplemented, don't raise, when you can't handle the other type. Python then gives the other object a chance, and raises a clear TypeError if neither can.
- Write
__repr__first. It's the one you'll read in every debugging session, test failure and log line. A good repr likeMoney(1250)can be pasted straight back into code to recreate the object.
This is how NumPy, pandas and PyTorch feel natural: arrays add with +, compare with <, slice with [ ] and report their size with len(), all through the same special methods you just wrote.
Worked example: a price list that reads naturally
Combine a value type and a container: totals with sum(), sorting by price, membership and lookup, all with ordinary Python syntax.
from dataclasses import dataclass from functools import total_ordering @total_ordering @dataclass(frozen=True) class Money: cents: int def __str__(self): return f"${self.cents / 100:,.2f}" def __lt__(self, other): return self.cents < other.cents def __add__(self, other): return Money(self.cents + other.cents) if isinstance(other, Money) else NotImplemented def __radd__(self, other): return self if other == 0 else NotImplemented class PriceList: def __init__(self, prices): self._prices = dict(prices) def __getitem__(self, sku): return self._prices[sku] def __contains__(self, sku): return sku in self._prices def __len__(self): return len(self._prices) def __iter__(self): return iter(sorted(self._prices, key=self._prices.get)) prices = PriceList({"TE-101": Money(20000), "CA-132": Money(500), "PA-108": Money(5500)}) print(len(prices), "products; cheapest first:", list(prices)) order = ["TE-101", "PA-108", "PA-108"] print("total:", sum(prices[s] for s in order)) print("XX-000" in prices, max(prices[s] for s in prices))
@dataclass(frozen=True) writes __init__, __repr__, __eq__ and __hash__ for Money, so the class only adds what's special: formatting, ordering and addition. total_ordering fills in > from <, so max works. PriceList.__iter__ decides what a loop over it means: SKUs from cheapest to dearest. The code at the bottom reads like plain Python, because the special methods carry the meaning.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why does sum() need __radd__ on a custom number-like class?