🎯 Learning Objectives
- Understand what dunder (magic) methods are and how Python calls them
- Implement string representation with
__repr__and__str__ - Support arithmetic and comparison operators
- Make objects iterable, sized, and subscriptable
- Implement callable objects with
__call__ - Create context managers with
__enter__and__exit__ - Control attribute access with
__getattr__and__setattr__
What are Dunder Methods?
Dunder (double underscore) methods — also called magic methods or special methods — are the hooks Python calls behind the scenes when you use operators, built-in functions, or language syntax on your objects.
When you write a + b, Python calls a.__add__(b).
When you write len(obj), Python calls obj.__len__().
Implementing these methods makes your custom objects feel like native Python types.
class Bag:
def __init__(self, items):
self._items = list(items)
def __len__(self): # len(bag)
return len(self._items)
def __contains__(self, item): # item in bag
return item in self._items
def __repr__(self): # repr(bag) / REPL display
return f"Bag({self._items!r})"
b = Bag(["apple", "banana", "cherry"])
print(len(b)) # 3
print("apple" in b) # True
print("grape" in b) # False
print(b) # Bag(['apple', 'banana', 'cherry'])
intro.py
obj.__len__()).
You implement them so Python's operators and built-ins can call them on your behalf.
This is the data model — the backbone of Python's design.
String Representation
class Card:
SUITS = {"S": "♠", "H": "♥", "D": "♦", "C": "♣"}
RANKS = {1: "A", 11: "J", 12: "Q", 13: "K"}
def __init__(self, rank, suit):
self.rank = rank
self.suit = suit
def __repr__(self):
"""Unambiguous — should ideally reconstruct the object."""
return f"Card({self.rank!r}, {self.suit!r})"
def __str__(self):
"""Human-readable — shown by print()."""
rank_str = self.RANKS.get(self.rank, str(self.rank))
suit_str = self.SUITS.get(self.suit, self.suit)
return f"{rank_str}{suit_str}"
ace = Card(1, "S")
print(repr(ace)) # Card(1, 'S') — __repr__
print(str(ace)) # A♠ — __str__
print(ace) # A♠ — print() uses __str__
# In a list, Python uses __repr__ for each item
hand = [Card(1,"S"), Card(13,"H"), Card(10,"D")]
print(hand)
# [Card(1, 'S'), Card(13, 'H'), Card(10, 'D')]
str_repr.py
__repr__ is for developers (use in logging, REPL, debugging);
__str__ is for end users (use in UI, print statements).
If only __repr__ is defined, it is used as fallback for __str__.
Always define __repr__ — it makes debugging vastly easier.
Comparison Operators
from functools import total_ordering
@total_ordering # auto-generates missing comparisons from __eq__ and ONE of __lt__/__gt__/__le__/__ge__
class Version:
"""Semantic version number (major.minor.patch)."""
def __init__(self, major, minor=0, patch=0):
self.major = major
self.minor = minor
self.patch = patch
def _tuple(self):
return (self.major, self.minor, self.patch)
def __eq__(self, other):
if not isinstance(other, Version):
return NotImplemented
return self._tuple() == other._tuple()
def __lt__(self, other):
if not isinstance(other, Version):
return NotImplemented
return self._tuple() < other._tuple()
def __repr__(self):
return f"Version({self.major}, {self.minor}, {self.patch})"
def __str__(self):
return f"{self.major}.{self.minor}.{self.patch}"
v1 = Version(1, 2, 3)
v2 = Version(1, 10, 0)
v3 = Version(1, 2, 3)
print(v1 < v2) # True
print(v1 > v2) # False — provided by @total_ordering
print(v1 == v3) # True
print(v1 <= v3) # True — provided by @total_ordering
print(sorted([v2, v1, v3]))
# [Version(1, 2, 3), Version(1, 2, 3), Version(1, 10, 0)]
comparison.py
| Operator | Dunder | Reflected |
|---|---|---|
== | __eq__ | __eq__ |
!= | __ne__ | __ne__ |
< | __lt__ | __gt__ |
<= | __le__ | __ge__ |
> | __gt__ | __lt__ |
>= | __ge__ | __le__ |
NotImplemented (not False) when a comparison is
not supported for the given type. This signals Python to try the reflected
method on the other operand before giving up.
Arithmetic Operators
class Money:
"""Immutable money value with currency."""
def __init__(self, amount, currency="GBP"):
self.amount = round(float(amount), 2)
self.currency = currency
def _check_currency(self, other):
if self.currency != other.currency:
raise ValueError(
f"Cannot operate on {self.currency} and {other.currency}"
)
# ── Binary operators ──
def __add__(self, other):
self._check_currency(other)
return Money(self.amount + other.amount, self.currency)
def __sub__(self, other):
self._check_currency(other)
return Money(self.amount - other.amount, self.currency)
def __mul__(self, factor): # Money * scalar
return Money(self.amount * factor, self.currency)
def __rmul__(self, factor): # scalar * Money
return self.__mul__(factor)
def __truediv__(self, divisor): # Money / scalar
return Money(self.amount / divisor, self.currency)
# ── Unary operators ──
def __neg__(self): # -money
return Money(-self.amount, self.currency)
def __abs__(self): # abs(money)
return Money(abs(self.amount), self.currency)
def __repr__(self):
return f"Money({self.amount}, {self.currency!r})"
def __str__(self):
symbol = {"GBP": "£", "USD": "$", "EUR": "€"}.get(self.currency, self.currency)
return f"{symbol}{self.amount:,.2f}"
a = Money(10.50)
b = Money(3.25)
print(a + b) # £13.75
print(a - b) # £7.25
print(a * 2) # £21.00
print(2 * a) # £21.00
print(-a) # £-10.50
print(abs(-a)) # £10.50
arithmetic.py
| Operator | Dunder | Reflected (right-hand) | In-place |
|---|---|---|---|
+ | __add__ | __radd__ | __iadd__ |
- | __sub__ | __rsub__ | __isub__ |
* | __mul__ | __rmul__ | __imul__ |
/ | __truediv__ | __rtruediv__ | __itruediv__ |
// | __floordiv__ | __rfloordiv__ | __ifloordiv__ |
% | __mod__ | __rmod__ | __imod__ |
** | __pow__ | __rpow__ | __ipow__ |
-x | __neg__ | — | — |
abs(x) | __abs__ | — | — |
Container Protocol
Make your objects behave like sequences, mappings, or sets by implementing the container dunders:
class SortedList:
"""A list that stays sorted at all times."""
def __init__(self, items=None):
self._data = sorted(items or [])
def add(self, item):
import bisect
bisect.insort(self._data, item)
# ── Sequence protocol ──
def __len__(self): # len(sl)
return len(self._data)
def __getitem__(self, index): # sl[i], sl[1:3], for x in sl
return self._data[index]
def __contains__(self, item): # item in sl
import bisect
i = bisect.bisect_left(self._data, item)
return i < len(self._data) and self._data[i] == item
def __iter__(self): # for x in sl
return iter(self._data)
def __reversed__(self): # reversed(sl)
return reversed(self._data)
def __repr__(self):
return f"SortedList({self._data!r})"
sl = SortedList([5, 1, 3])
sl.add(2)
sl.add(4)
print(sl) # SortedList([1, 2, 3, 4, 5])
print(len(sl)) # 5
print(sl[0]) # 1
print(sl[-1]) # 5
print(sl[1:3]) # [2, 3]
print(3 in sl) # True
print(list(reversed(sl))) # [5, 4, 3, 2, 1]
for item in sl:
print(item, end=" ") # 1 2 3 4 5
container.py
__getitem__ alone (without __iter__) is
enough for a for loop to work — Python will call
obj[0], obj[1], … until IndexError.
But defining __iter__ explicitly is faster and clearer.
Callable Objects — __call__
Any object with a __call__ method can be invoked like a function.
This is useful for objects that need to maintain state between calls:
class Multiplier:
"""A callable that multiplies its input by a fixed factor."""
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
return value * self.factor
def __repr__(self):
return f"Multiplier({self.factor})"
double = Multiplier(2)
triple = Multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
print(callable(double)) # True
# Useful for stateful transformers and pipelines
class RateLimiter:
"""Allows at most `limit` calls per session."""
def __init__(self, limit):
self.limit = limit
self._calls = 0
def __call__(self, func, *args, **kwargs):
if self._calls >= self.limit:
raise RuntimeError(f"Rate limit of {self.limit} calls exceeded")
self._calls += 1
return func(*args, **kwargs)
limiter = RateLimiter(3)
for i in range(3):
print(limiter(str.upper, "hello")) # HELLO × 3
try:
limiter(str.upper, "hello") # 4th call
except RuntimeError as e:
print(e)
callable.py
Context Managers — __enter__ & __exit__
Implement __enter__ and __exit__ to make your objects
work with with statements — guaranteeing setup and teardown:
import time
class Timer:
"""Context manager that measures elapsed time."""
def __enter__(self):
self._start = time.perf_counter()
return self # value bound to 'as' variable
def __exit__(self, exc_type, exc_val, exc_tb):
self.elapsed = time.perf_counter() - self._start
print(f"Elapsed: {self.elapsed:.4f}s")
return False # False = don't suppress exceptions
with Timer() as t:
total = sum(range(1_000_000))
print(f"Sum: {total}, Time: {t.elapsed:.4f}s")
timer_cm.py
class ManagedDatabase:
"""Simulates a database connection with guaranteed cleanup."""
def __init__(self, url):
self.url = url
self._conn = None
def __enter__(self):
print(f"Connecting to {self.url}")
self._conn = {"url": self.url, "open": True} # simulate connection
return self._conn
def __exit__(self, exc_type, exc_val, exc_tb):
print("Closing connection")
if self._conn:
self._conn["open"] = False
if exc_type is not None:
print(f"Exception occurred: {exc_val}")
return False # propagate exceptions
with ManagedDatabase("postgres://localhost/mydb") as conn:
print(f"Connected: {conn}")
# ... do database work ...
# Closing connection (always happens)
managed_db.py
__exit__ receives three arguments: the exception type, value, and
traceback (all None if no exception occurred). Return True
to suppress the exception; return False (or None)
to let it propagate.
Attribute Access
These dunders let you intercept and customise attribute getting and setting:
class AttrLogger:
"""Log all attribute accesses and mutations."""
def __init__(self, **kwargs):
# Use object.__setattr__ to bypass our own __setattr__ during init
object.__setattr__(self, "_data", {})
for k, v in kwargs.items():
self._data[k] = v
def __getattr__(self, name):
# Called only when normal lookup fails
if name in self._data:
print(f"GET {name}")
return self._data[name]
raise AttributeError(f"No attribute {name!r}")
def __setattr__(self, name, value):
# Called on EVERY attribute assignment
print(f"SET {name} = {value!r}")
self._data[name] = value
def __delattr__(self, name):
print(f"DEL {name}")
del self._data[name]
obj = AttrLogger(x=1, y=2)
obj.z = 3 # SET z = 3
print(obj.x) # GET x → 1
del obj.z # DEL z
attr_access.py
__getattr__ vs __getattribute__:
__getattr__ is only called when normal attribute lookup fails (safe to override).
__getattribute__ is called on every attribute access — very easy
to cause infinite recursion. Almost always use __getattr__.
__slots__ — memory-efficient objects
class Point:
"""Uses __slots__ to prevent arbitrary attribute creation
and reduce per-instance memory by ~40%."""
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1, 2)
print(p.x) # 1
# p.z = 3 # AttributeError — only x and y are allowed
# print(p.__dict__) # AttributeError — no __dict__ with __slots__
slots.py
Numeric Conversions
class Fraction:
def __init__(self, numerator, denominator):
from math import gcd
g = gcd(abs(numerator), abs(denominator))
self.num = numerator // g
self.den = denominator // g
# ── Numeric conversion dunders ──
def __int__(self): # int(fraction)
return self.num // self.den
def __float__(self): # float(fraction)
return self.num / self.den
def __bool__(self): # bool(fraction), truthiness
return self.num != 0
def __round__(self, n=0): # round(fraction, n)
return round(float(self), n)
def __repr__(self):
return f"Fraction({self.num}, {self.den})"
def __str__(self):
return f"{self.num}/{self.den}"
f = Fraction(3, 4)
print(float(f)) # 0.75
print(int(f)) # 0
print(bool(f)) # True
print(bool(Fraction(0, 5))) # False
print(round(f, 1)) # 0.8
numeric.py
Quick Reference
| Category | Dunder | Triggered by |
|---|---|---|
| Representation | __repr__ | repr(x), REPL, !r format |
__str__ | str(x), print(x) | |
__format__ | format(x, spec), f-string {x:spec} | |
| Container | __len__ | len(x) |
__getitem__ | x[key] | |
__setitem__ | x[key] = val | |
__contains__ | item in x | |
| Iteration | __iter__ | for item in x, iter(x) |
__next__ | next(x) | |
| Callable | __call__ | x(...) |
| Context mgr | __enter__ | with x as y |
__exit__ | end of with block | |
| Attribute | __getattr__ | x.name (when not found) |
__setattr__ | x.name = val | |
__delattr__ | del x.name | |
| Numeric | __int__ / __float__ | int(x) / float(x) |
__bool__ | bool(x), truthiness tests | |
__hash__ | hash(x), dict key, set member | |
| Lifecycle | __init__ / __del__ | Construction / garbage collection |
Primary sources: Python Docs — Data Model (complete dunder reference) · Python Docs — functools.total_ordering
Ask your AI tutor! Not sure which dunder to implement for a
specific behaviour? Confused about when to return NotImplemented?
Want to see how Python's built-in list or dict would
look if written in pure Python? Great things to explore.
💻 Exercises
Build a Fraction class that supports:
- Construction with automatic simplification (use
math.gcd) +,-,*,/between fractions==,<,>(use@total_ordering)float(f),int(f),bool(f)- Readable
__str__("3/4") and__repr__
Show solution
from math import gcd
from functools import total_ordering
@total_ordering
class Fraction:
def __init__(self, num, den=1):
if den == 0:
raise ZeroDivisionError("Fraction denominator cannot be zero")
sign = -1 if (num * den < 0) else 1
g = gcd(abs(num), abs(den))
self.num = sign * abs(num) // g
self.den = abs(den) // g
def __repr__(self):
return f"Fraction({self.num}, {self.den})"
def __str__(self):
return f"{self.num}" if self.den == 1 else f"{self.num}/{self.den}"
def __eq__(self, other):
if isinstance(other, int):
other = Fraction(other)
if not isinstance(other, Fraction):
return NotImplemented
return self.num == other.num and self.den == other.den
def __lt__(self, other):
if isinstance(other, int):
other = Fraction(other)
if not isinstance(other, Fraction):
return NotImplemented
return self.num * other.den < other.num * self.den
def __add__(self, other):
if isinstance(other, int): other = Fraction(other)
return Fraction(self.num * other.den + other.num * self.den, self.den * other.den)
def __sub__(self, other):
if isinstance(other, int): other = Fraction(other)
return Fraction(self.num * other.den - other.num * self.den, self.den * other.den)
def __mul__(self, other):
if isinstance(other, int): other = Fraction(other)
return Fraction(self.num * other.num, self.den * other.den)
def __truediv__(self, other):
if isinstance(other, int): other = Fraction(other)
return Fraction(self.num * other.den, self.den * other.num)
def __float__(self): return self.num / self.den
def __int__(self): return self.num // self.den
def __bool__(self): return self.num != 0
a = Fraction(1, 2)
b = Fraction(1, 3)
print(a + b) # 5/6
print(a - b) # 1/6
print(a * b) # 1/6
print(a / b) # 3/2
print(a > b) # True
print(float(a)) # 0.5
print(sorted([b, a, Fraction(1,4)])) # [1/4, 1/3, 1/2]
Build a FrozenDict — an immutable dictionary that:
- Accepts items at construction time only
- Supports
d[key],key in d,len(d),for key in d - Raises
TypeErroron any attempt to set or delete items - Is hashable — implements
__hash__(hash of a frozenset of items) - Implements
__repr__and__eq__
Show solution
class FrozenDict:
"""An immutable, hashable dictionary."""
def __init__(self, *args, **kwargs):
self._data = dict(*args, **kwargs)
def __getitem__(self, key):
return self._data[key]
def __setitem__(self, key, value):
raise TypeError("FrozenDict does not support item assignment")
def __delitem__(self, key):
raise TypeError("FrozenDict does not support item deletion")
def __contains__(self, key):
return key in self._data
def __len__(self):
return len(self._data)
def __iter__(self):
return iter(self._data)
def __eq__(self, other):
if isinstance(other, FrozenDict):
return self._data == other._data
if isinstance(other, dict):
return self._data == other
return NotImplemented
def __hash__(self):
return hash(frozenset(self._data.items()))
def __repr__(self):
return f"FrozenDict({self._data!r})"
fd = FrozenDict({"a": 1, "b": 2, "c": 3})
print(fd["a"]) # 1
print("b" in fd) # True
print(len(fd)) # 3
print(list(fd)) # ['a', 'b', 'c']
print(hash(fd)) # some integer — it's hashable!
# Use as dict key or in a set
lookup = {fd: "found it"}
print(lookup[fd]) # found it
try:
fd["d"] = 4
except TypeError as e:
print(e) # FrozenDict does not support item assignment
Build a Pipeline class that chains callable steps together.
It should support:
pipe | func— add a step using__or__pipe(value)— execute all steps via__call__len(pipe)— number of stepsrepr(pipe)— show the function names in order
pipeline = Pipeline() | str.strip | str.upper | str.split
print(pipeline(" hello world "))
# ['HELLO', 'WORLD']
Show solution
class Pipeline:
def __init__(self, *steps):
self._steps = list(steps)
def __or__(self, func):
"""Add a step: pipeline | func."""
return Pipeline(*self._steps, func)
def __call__(self, value):
"""Execute the pipeline."""
for step in self._steps:
value = step(value)
return value
def __len__(self):
return len(self._steps)
def __repr__(self):
names = [getattr(s, "__name__", repr(s)) for s in self._steps]
return f"Pipeline({' | '.join(names)})"
# Test
pipeline = Pipeline() | str.strip | str.upper | str.split
print(pipeline(" hello world ")) # ['HELLO', 'WORLD']
print(len(pipeline)) # 3
print(repr(pipeline)) # Pipeline(strip | upper | split)
# Composing pipelines
import math
math_pipe = Pipeline() | (lambda x: x ** 2) | math.sqrt | round
print(math_pipe(5)) # 5 (sqrt(25) = 5.0 → round → 5)
# Reusable
clean = Pipeline() | str.strip | str.lower
print(clean(" Alice ")) # alice