Object Oriented
Dunder Methods & Operator Overloading
"Dunder" (double-underscore) methods are the hooks your objects implement so Python's built-in syntax and protocols dispatch to them — len(x), x + y, x == y, x[i], and for … in x all call dunders. Implement only the ones you need, and your objects start behaving like built-ins.
Suggest an editDunder Methods — Plugging Into Python's Syntax
You've used len(), +, ==, [], and for … in since the first tutorials — always on built-ins. The thesis of this chapter: those operators and built-in functions are not special-cased for list and dict; they dispatch to methods with double-underscore names — the "dunders" — and your own classes can implement them too. When you write len(x), Python calls x.__len__(); when you write a + b, it calls a.__add__(b). Implement the right dunders and your objects plug straight into Python's syntax, behaving like built-ins to every caller and every library that relies on those protocols.
💡 The core idea.
- Operators and built-ins aren't special-cased for
list/dict. - They dispatch to dunder (double-underscore) methods.
len(x)callsx.__len__();a + bcallsa.__add__(b).- Implement them and your objects behave like built-ins.
The discipline is to implement only the dunders you actually need — they form the data model, a large protocol surface, but each one is opt-in. Every output below was produced by running the code.
📘 How to read the Intuition boxes. Each one is built in three moves:
- The mechanism — what the interpreter is actually doing.
- A concrete bite — a specific, runnable way the naive assumption fails.
- The earned rule — the decision heuristic, now justified rather than asserted, plus its cost.
Table of Contents
__repr__vs__str____eq__and the__hash__consequence__len__and__getitem__- Numeric dunders (
__add__) - Ordering (
__lt__) and sorting - Mental-model summary
- Gotcha checklist
1. __repr__ vs __str__
Two dunders produce text for an object, with different audiences. __repr__ is the unambiguous developer representation (shown in the REPL and inside containers); __str__ is the readable user representation (used by print and str). If __str__ is missing, str() falls back to __repr__.
Output:
20°C
20°C
Temperature(celsius=20)
[Temperature(celsius=20)]Analysis. print(t) and str(t) both call __str__, giving the human-friendly 20°C. repr(t) calls __repr__, giving the developer-friendly Temperature(celsius=20) — a string you could almost paste back into code. The last line is the key surprise: printing a list did not call __str__ on the element. Containers format their contents with __repr__, because inside a data structure you want the unambiguous form, not the prettified one.
Intuition.
Mechanism. str(x) (and print) call type(x).__str__; if the class defines no __str__, the lookup walks up to object.__str__, which simply delegates to __repr__. repr(x) always calls __repr__. A container's own __repr__ (and __str__) builds its text by calling repr() on each element — never str() — so your __str__ is invisible inside lists, dicts, and tuples.
Concrete bite. Define only __str__ and the developer view is still the useless default — and that default leaks the moment your object lands in a container:
Output (illustrative — the address varies per run):
$5
[<__main__.Money object at 0x7e2885fd6900>](The 0x… address is non-deterministic — yours will differ; it is shown here only to illustrate the default form.) print(m) looks fine, but [m] falls back to object.__repr__, exposing <__main__.Money object at 0x…>. That is exactly what you see when debugging a list of your objects in the REPL — opaque and unhelpful.
💡 Earned rule. Always define __repr__; define __str__ only when the user-facing form genuinely differs. The cost of __repr__ is one method; the payoff is that the REPL, debuggers, logs, and every container show something meaningful. If you write just one of the two, make it __repr__ — because str() falls back to it, but repr() never falls back to __str__.
2. __eq__ and the __hash__ consequence
By default, == on instances means identity (same object). Define __eq__ to make == mean equal contents — two Points with the same coordinates compare equal.
Output:
True
FalseAnalysis. Without __eq__, Point(1, 2) == Point(1, 2) would be False (two distinct objects). Defining __eq__ to compare x and y makes value-equality work as you'd expect. So far so good — but defining __eq__ has a hidden, breaking consequence for hashing, covered next.
Intuition.
Mechanism. When you define __eq__, Python sets __hash__ to None on that class. The reason is an invariant the language depends on: objects that compare equal must hash equal, so they land in the same bucket of a set or dict. Python can't guarantee your __eq__ and the inherited identity-based __hash__ agree, so rather than risk a silently broken container, it makes instances unhashable until you supply a matching __hash__. (See hashability for why containers require it.)
Concrete bite. The class above cannot go into a set or be used as a dict key:
Traceback (most recent call last):
File "/w/main.py", line 9, in <module>
print({p}) # try to put in a set
^^^
TypeError: unhashable type: 'Point'You added __eq__ to make comparison nicer and accidentally broke set(points) and every dict keyed on a Point. The fix is to define __hash__ over the same fields __eq__ uses:
1
Truehash((self.x, self.y)) reuses the tuple's hash, derived from exactly the fields that define equality. Now equal points hash equal: {p, q} collapses to one element, and membership testing works.
💡 Earned rule. If you define __eq__ and want instances usable in sets or as dict keys, define __hash__ over the same fields — and only over fields that never change. The cost is keeping the two methods in sync; the benefit is correct containers. If your objects are mutable and you mutate the hashed fields, leave them unhashable (the default after __eq__) — a mutating key silently corrupts a dict, which is far worse than a clear TypeError.
3. __len__ and __getitem__
A container-like class earns len(obj) by defining __len__, and indexing obj[i] by defining __getitem__.
Output:
3
a
cAnalysis. len(p) dispatches to __len__, which delegates to the wrapped list. p[0] and p[-1] dispatch to __getitem__(self, i) with i set to 0 and -1; because we pass the index straight to a real list, negative indexing works for free. The object now looks indexable to any caller.
Intuition.
Mechanism. len(x) calls x.__len__(); x[i] calls x.__getitem__(i). There is a deeper payoff: if a class has no __iter__, Python's for loop falls back to calling __getitem__ with 0, 1, 2, …, stopping when it raises IndexError. So __getitem__ alone makes an object iterable, even without an explicit iterator.
Concrete bite. The iteration fallback is real — this loop works with only __getitem__, no __iter__ defined:
a
b
cPython calls p[0], p[1], p[2], then p[3] raises IndexError (from the inner list), which the for loop treats as "stop." No __iter__ was needed.
💡 Earned rule. Define __len__ and __getitem__ to make a class behave like a sequence; lean on the __getitem__ iteration fallback only for genuinely index-addressable data. The cost of relying on the fallback is that it forces integer indexing semantics and only stops on IndexError — for anything lazy or non-integer-keyed, define __iter__ explicitly (iterators and generators) so iteration doesn't accidentally depend on [i].
4. Numeric dunders (__add__)
Arithmetic operators dispatch to numeric dunders: a + b calls a.__add__(b). Define __add__ to make + build a new object — here, vector addition.
Output:
Vector(4, 6)Analysis. Vector(1, 2) + Vector(3, 4) calls __add__, which returns a new Vector(4, 6) rather than mutating either operand — the right convention for +. The __repr__ from §1 is what makes the printed result readable. But notice __add__ blindly reads other.x and other.y, which only works if other is also a Vector.
Intuition.
Mechanism. a + b first tries a.__add__(b). If that method returns the special sentinel NotImplemented (not the same as raising NotImplementedError), Python then tries the reflected call b.__radd__(a); if both give up, it raises TypeError. Our __add__ doesn't check the type of other, so it doesn't reach that polite fallback — it just tries to read attributes that may not exist.
Concrete bite. Adding a plain int reaches inside __add__ and fails on the attribute access, not with a clean "unsupported operands" message:
Traceback (most recent call last):
File "/w/main.py", line 10, in <module>
print(Vector(1, 2) + 5)
~~~~~~~~~~~~~^~~
File "/w/main.py", line 8, in __add__
return Vector(self.x + other.x, self.y + other.y)
^^^^^^^
AttributeError: 'int' object has no attribute 'x'5 has no .x, so other.x raises AttributeError from inside your method — a leaky, confusing error. The disciplined version guards the type and returns NotImplemented for anything it can't handle (if not isinstance(other, Vector): return NotImplemented), which lets Python produce the standard TypeError: unsupported operand type(s) and gives the other operand a chance via __radd__ (useful for, e.g., 5 + vec or sum(vectors)).
💡 Earned rule. Implement __add__ to return a new object, and guard the operand type — return NotImplemented (don't raise) for types you don't support. The cost is a type check and remembering that NotImplemented ≠ NotImplementedError; the payoff is correct interaction with other types, sane error messages, and reflected operations like __radd__ working as Python intends.
5. Ordering (__lt__) and sorting
Sorting and the < operator dispatch to __lt__. Define it, and a list of your objects becomes sortable with the built-in sorted().
Output:
[Bo(25), Ann(30), Cy(35)]Analysis. sorted() orders elements by repeatedly asking "is a less than b?" — i.e. calling a.__lt__(b). Our __lt__ compares age, so the list comes out youngest-first. We only had to define one comparison dunder for sorting to work, because that's the only one sorted() needs.
Intuition.
Mechanism. sorted() and list.sort() order elements using only < (__lt__) — they never need >, <=, or == for the sort itself. So a single __lt__ is enough to sort. The other rich-comparison dunders (__le__, __gt__, __ge__) are independent: defining __lt__ does not auto-define them.
Concrete bite. Without any __lt__, sorting a list of your objects fails immediately:
Traceback (most recent call last):
File "/w/main.py", line 7, in <module>
print(sorted(people))
~~~~~~^^^^^^^^
TypeError: '<' not supported between instances of 'Person' and 'Person'sorted() tries to compare two Persons with <, finds no __lt__ (and no inherited ordering), and raises. The fix is the __lt__ above — or, if you need the full set of <, <=, >, >= consistently, decorate the class with @functools.total_ordering and define just __lt__ and __eq__; it fills in the rest.
💡 Earned rule. Define __lt__ for sortability; reach for functools.total_ordering only when you also need the other comparison operators. The cost of total_ordering is a slightly slower derived comparison and a required __eq__; the benefit is not hand-writing four near-identical methods. For the common case of "I just want sorted() to work," a lone __lt__ — or, simpler still, sorted(people, key=lambda p: p.age) with no dunder at all — is enough.
6. Mental-model summary
| Principle | Consequence |
|---|---|
| Built-in syntax dispatches to dunders | len(x)→__len__, x+y→__add__, x==y→__eq__, x[i]→__getitem__ |
Containers format elements with __repr__ |
Define __repr__ or your objects look like <… at 0x…> in lists/logs |
Defining __eq__ sets __hash__ to None |
Instances become unhashable; add __hash__ to use them in sets/dict keys |
__getitem__ alone makes an object iterable |
for falls back to obj[0], obj[1], … until IndexError |
Numeric dunders should return NotImplemented, not raise |
Lets the other operand try (__radd__) and yields a clean TypeError |
sorted() uses only __lt__ |
One comparison dunder enables sorting; total_ordering fills in the rest |
7. Gotcha checklist
- Your object prints as
<… object at 0x…>in a list/log → you defined only__str__(or neither); define__repr__. TypeError: unhashable typeafter adding__eq__→ Python set__hash__toNone; define__hash__over the same (immutable) fields.- A
dictkeyed on your object behaves erratically → you hashed a field you later mutated; hash only never-changing fields, or keep the object unhashable. AttributeErrorfrom inside__add__when adding a non-matching type → guard withisinstanceandreturn NotImplementedinstead of reading attributes blindly.TypeError: '<' not supported between instancesfromsorted()→ define__lt__(or passkey=…); use@functools.total_orderingif you need all four comparisons.
🧪 Predict, then check. Write a Fraction class with __init__(self, num, den), a __repr__ of the form Fraction(1, 2), and an __eq__ that treats 1/2 and 2/4 as equal (compare num * other.den == other.num * den). First predict what {Fraction(1, 2), Fraction(2, 4)} does before you add __hash__ — set size, or an error? Then add a __hash__ that makes the two collapse to one entry (hint: reduce by the gcd first, since equal fractions must hash equal), and predict the new len(...). Finally, predict whether sorted([Fraction(1, 2), Fraction(1, 3)]) works with no __lt__ defined, and what error you'd see if not.
Your Turn
Before you move on, check your understanding with the coach — explain the idea, apply it, weigh the trade-offs, then defend your reasoning.