Python Dunder Methods — Python's Protocol: Practice Problems & Exercises
Practice: Dunder Methods — Python's Protocol System at Engineering Depth
← Back to lessonEasy
Predict all four outputs. Focus on Python's fallback rule: if __bool__ is absent, bool(obj) calls __len__ and returns False when it equals 0.
class Bag:
def __init__(self, items):
self.items = list(items)
def __len__(self):
return len(self.items)
b1 = Bag([1, 2, 3])
b2 = Bag([])
print(len(b1))
print(bool(b1))
print(bool(b2))
if b2:
print("truthy")
else:
print("falsy")Solution
3
True
False
falsy
Explanation: Python's truthiness protocol checks for __bool__ first; if absent, it checks __len__. A non-zero length is truthy; zero is falsy. b1 has 3 items — len(b1) is 3, so bool(b1) is True. b2 has no items — len(b2) is 0, so bool(b2) is False and the if b2 branch takes the else path. This fallback is why empty lists, dicts, and strings are falsy without any explicit __bool__ implementation.
class Bag:
def __init__(self, items):
self.items = list(items)
def __len__(self):
return len(self.items)
b1 = Bag([1, 2, 3])
b2 = Bag([])
print(len(b1))
print(bool(b1))
print(bool(b2))
if b2:
print("truthy")
else:
print("falsy")Expected Output
3\nTrue\nFalse\nfalsyHints
Hint 1: When __bool__ is not defined, Python falls back to __len__. If __len__ returns 0, the object is falsy.
Hint 2: b1 has 3 items so len(b1) == 3 and bool(b1) is True.
Hint 3: b2 has 0 items so len(b2) == 0 and bool(b2) is False.
Implement __len__ and __contains__ so that len() and the in operator work on WordSet in a case-insensitive way.
class WordSet:
def __init__(self, words):
self._words = set(w.lower() for w in words)
def __len__(self):
return len(self._words)
def __contains__(self, word):
return word.lower() in self._words
ws = WordSet(["Python", "Java", "Rust"])
print(len(ws))
print("python" in ws)
print("RUST" in ws)
print("Go" in ws)Solution
class WordSet:
def __init__(self, words):
self._words = set(w.lower() for w in words)
def __len__(self):
return len(self._words)
def __contains__(self, word):
return word.lower() in self._words
Explanation: __len__ enables len(ws) and the truthiness fallback. __contains__ enables the in operator — without it, Python would fall back to iterating the object (slow and requires __iter__). Normalising both the stored words (in __init__) and the query (in __contains__) achieves case-insensitive matching efficiently via a hash-set lookup in O(1).
class WordSet:
def __init__(self, words):
self._words = set(w.lower() for w in words)
def __len__(self):
# TODO: return number of words
pass
def __contains__(self, word):
# TODO: return True if word (case-insensitive) is in the set
pass
ws = WordSet(["Python", "Java", "Rust"])
print(len(ws))
print("python" in ws)
print("RUST" in ws)
print("Go" in ws)Expected Output
3\nTrue\nTrue\nFalseHints
Hint 1: __len__ should return len(self._words).
Hint 2: __contains__ should return word.lower() in self._words.
Hint 3: The set already stores lowercase words, so normalise the query in __contains__.
Implement __call__ so that Multiplier instances can be used like functions. callable() should return True for them.
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
return value * self.factor
double = Multiplier(2)
triple = Multiplier(3)
print(double(5))
print(triple(5))
print(callable(double))Solution
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
return value * self.factor
Explanation: __call__ makes an instance callable like a function. Whereas a plain function is stateless, a callable object carries state in its instance attributes — factor in this case. This is the basis of function closures implemented as classes, decorators implemented as classes, and many ML frameworks where layers are callable objects (e.g., torch.nn.Module.__call__). callable(obj) returns True if and only if the type defines __call__.
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
# TODO: return value multiplied by self.factor
pass
double = Multiplier(2)
triple = Multiplier(3)
print(double(5))
print(triple(5))
print(callable(double))Expected Output
10\n15\nTrueHints
Hint 1: Implement __call__ with a single parameter (besides self) and return value * self.factor.
Hint 2: Instances with __call__ defined are callable — callable(obj) returns True.
Hint 3: This pattern is useful for function-like objects that also carry state.
The Point class has a partial bug: __eq__ is defined but __hash__ is missing. Observe the bug, then fix it by adding a correct __hash__.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y
# Demonstrate the bug:
p1 = Point(1, 2)
p2 = Point(1, 2)
p3 = Point(3, 4)
print(p1 == p2)
s = {p1, p3}
print(p2 in s) # False because p2 has a different hash than p1
# Fixed version:
class PointFixed:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
if not isinstance(other, PointFixed):
return NotImplemented
return self.x == other.x and self.y == other.y
def __hash__(self):
return hash((self.x, self.y))
p1f = PointFixed(1, 2)
p2f = PointFixed(1, 2)
p3f = PointFixed(3, 4)
sf = {p1f, p3f}
print(p2f in sf) # True nowSolution
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y
def __hash__(self):
return hash((self.x, self.y))
Explanation: Python's contract: if a == b then hash(a) == hash(b). When you define __eq__, Python automatically sets __hash__ = None to prevent inconsistency. Objects without __hash__ cannot be put in sets or used as dict keys. Adding __hash__ that hashes a tuple of the fields used in __eq__ restores hashability. hash((self.x, self.y)) works because tuples of hashable types are themselves hashable, and equal tuples always produce equal hashes.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y
# BUG: __hash__ is missing — what happens when we use Points in a set?
p1 = Point(1, 2)
p2 = Point(1, 2)
p3 = Point(3, 4)
print(p1 == p2) # True — same coordinates
s = {p1, p3}
print(p2 in s) # should be True if __hash__ is correctExpected Output
True\nFalseHints
Hint 1: When you define __eq__, Python sets __hash__ to None, making the object unhashable by default.
Hint 2: For p2 in s to work, __hash__ must be consistent with __eq__: equal objects must have equal hashes.
Hint 3: Add __hash__ = lambda self: hash((self.x, self.y)) or define a proper __hash__ method.
Medium
Implement __add__ and __mul__ for the Fraction class. The constructor already handles normalisation via GCD.
import math
class Fraction:
def __init__(self, numerator, denominator):
if denominator == 0:
raise ValueError("Denominator cannot be zero")
g = math.gcd(abs(numerator), abs(denominator))
sign = -1 if denominator < 0 else 1
self.num = sign * numerator // g
self.den = sign * denominator // g
def __add__(self, other):
return Fraction(
self.num * other.den + other.num * self.den,
self.den * other.den
)
def __mul__(self, other):
return Fraction(self.num * other.num, self.den * other.den)
def __eq__(self, other):
if not isinstance(other, Fraction):
return NotImplemented
return self.num == other.num and self.den == other.den
def __repr__(self):
return f"Fraction({self.num}, {self.den})"
a = Fraction(1, 2)
b = Fraction(1, 3)
print(a + b)
print(a * b)
print(Fraction(2, 4) == Fraction(1, 2))Solution
def __add__(self, other):
return Fraction(
self.num * other.den + other.num * self.den,
self.den * other.den
)
def __mul__(self, other):
return Fraction(self.num * other.num, self.den * other.den)
Explanation: Both methods return a new Fraction instance, preserving immutability. __add__ applies the standard cross-multiplication formula. __mul__ simply multiplies numerators and denominators. By delegating to the constructor, we get automatic GCD normalisation for free — Fraction(2, 4) reduces to Fraction(1, 2), which is why the equality check works. This pattern (operations create new instances, constructor normalises) is the standard approach for value objects.
import math
class Fraction:
def __init__(self, numerator, denominator):
if denominator == 0:
raise ValueError("Denominator cannot be zero")
g = math.gcd(abs(numerator), abs(denominator))
sign = -1 if denominator < 0 else 1
self.num = sign * numerator // g
self.den = sign * denominator // g
def __add__(self, other):
# TODO: return a new Fraction that is self + other
pass
def __mul__(self, other):
# TODO: return a new Fraction that is self * other
pass
def __eq__(self, other):
if not isinstance(other, Fraction):
return NotImplemented
return self.num == other.num and self.den == other.den
def __repr__(self):
return f"Fraction({self.num}, {self.den})"
a = Fraction(1, 2)
b = Fraction(1, 3)
print(a + b)
print(a * b)
print(Fraction(2, 4) == Fraction(1, 2))Expected Output
Fraction(5, 6)\nFraction(1, 6)\nTrueHints
Hint 1: For addition: result numerator = self.num * other.den + other.num * self.den; denominator = self.den * other.den.
Hint 2: For multiplication: result numerator = self.num * other.num; denominator = self.den * other.den.
Hint 3: Pass the results through Fraction(...) — the constructor normalises via gcd automatically.
Implement the iterator protocol on Countdown so it can be used in for loops and with next(). The iterator must be resettable.
class Countdown:
def __init__(self, start):
self.start = start
self._current = start
def __iter__(self):
self._current = self.start
return self
def __next__(self):
if self._current < 1:
raise StopIteration
value = self._current
self._current -= 1
return value
for n in Countdown(3):
print(n)
it = iter(Countdown(2))
print(next(it))
print(next(it))Solution
class Countdown:
def __init__(self, start):
self.start = start
self._current = start
def __iter__(self):
self._current = self.start
return self
def __next__(self):
if self._current < 1:
raise StopIteration
value = self._current
self._current -= 1
return value
Explanation: The iterator protocol requires __iter__ (returns the iterator object) and __next__ (returns the next value or raises StopIteration). When an object implements both on itself, it is both an iterable and its own iterator. __iter__ resets _current so the same Countdown can be iterated multiple times. In __next__, we capture the value before decrementing to return the current count, not the next one.
class Countdown:
def __init__(self, start):
self.start = start
self._current = start
def __iter__(self):
# TODO: reset and return self
pass
def __next__(self):
# TODO: yield current value, decrement, raise StopIteration when done
pass
for n in Countdown(3):
print(n)
# Using iter() and next() directly:
it = iter(Countdown(2))
print(next(it))
print(next(it))Expected Output
3\n2\n1\n2\n1Hints
Hint 1: __iter__ should reset self._current = self.start and return self.
Hint 2: __next__ should raise StopIteration when self._current < 1.
Hint 3: Otherwise, capture the current value, decrement self._current by 1, and return the captured value.
Implement __lt__ for semantic version comparison. @total_ordering will derive the remaining comparison operators.
from functools import total_ordering
@total_ordering
class Version:
def __init__(self, version_str):
self.parts = tuple(int(x) for x in version_str.split("."))
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
def __repr__(self):
return ".".join(str(p) for p in self.parts)
v1 = Version("1.2.3")
v2 = Version("1.10.0")
v3 = Version("1.2.3")
print(v1 < v2)
print(v2 > v1)
print(v1 == v3)
print(sorted([v2, v1, v3]))Solution
def __lt__(self, other):
if not isinstance(other, Version):
return NotImplemented
return self.parts < other.parts
Explanation: Python tuples compare element-by-element left-to-right, which is exactly what semantic version comparison requires. (1, 2, 3) < (1, 10, 0) is True because the second element 2 < 10. @total_ordering is a class decorator that, given __eq__ and one ordering method (__lt__), synthesises __le__, __gt__, and __ge__. This allows sorted() and all comparison operators to work without manually writing all six methods. Returning NotImplemented (not False) when the types don't match lets Python try the reflected operation.
from functools import total_ordering
@total_ordering
class Version:
def __init__(self, version_str):
self.parts = tuple(int(x) for x in version_str.split("."))
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
# TODO: return True if self is an earlier version than other
pass
def __repr__(self):
return ".".join(str(p) for p in self.parts)
v1 = Version("1.2.3")
v2 = Version("1.10.0")
v3 = Version("1.2.3")
print(v1 < v2)
print(v2 > v1)
print(v1 == v3)
print(sorted([v2, v1, v3]))Expected Output
True\nTrue\nTrue\n[1.2.3, 1.2.3, 1.10.0]Hints
Hint 1: Tuples compare lexicographically in Python: (1, 2, 3) < (1, 10, 0) is True.
Hint 2: Simply return self.parts < other.parts.
Hint 3: @total_ordering fills in __le__, __gt__, __ge__ automatically from __eq__ and __lt__.
Implement the context manager protocol on Timer so it measures elapsed time inside a with block.
import time
class Timer:
def __enter__(self):
self.start = time.time()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.elapsed = time.time() - self.start
return False # do not suppress exceptions
with Timer() as t:
time.sleep(0.05)
print(t.elapsed >= 0.05)
print(t.elapsed < 0.5)Solution
import time
class Timer:
def __enter__(self):
self.start = time.time()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.elapsed = time.time() - self.start
return False
Explanation: __enter__ is called when the with block is entered; its return value is bound to the as target (t). __exit__ is called when the block exits, whether normally or due to an exception. It receives the exception type, value, and traceback. Returning a truthy value suppresses the exception; returning False (or None) lets it propagate. Recording elapsed in __exit__ captures the total time including the body of the with block.
class Timer:
import time as _time
def __enter__(self):
# TODO: record start time and return self
pass
def __exit__(self, exc_type, exc_val, exc_tb):
# TODO: record elapsed time; do NOT suppress exceptions
pass
import time
with Timer() as t:
time.sleep(0.05)
print(t.elapsed >= 0.05)
print(t.elapsed < 0.5)Expected Output
True\nTrueHints
Hint 1: In __enter__, store self.start = time.time() and return self.
Hint 2: In __exit__, store self.elapsed = time.time() - self.start.
Hint 3: Return False (or None) from __exit__ to let exceptions propagate normally.
Hard
Complete __getitem__ and __reversed__ so that CircularBuffer behaves like a sequence with index access and reversed() support.
class CircularBuffer:
def __init__(self, capacity):
self._capacity = capacity
self._buffer = [None] * capacity
self._start = 0
self._size = 0
def append(self, item):
pos = (self._start + self._size) % self._capacity
if self._size < self._capacity:
self._buffer[pos] = item
self._size += 1
else:
self._buffer[self._start] = item
self._start = (self._start + 1) % self._capacity
def __len__(self):
return self._size
def __getitem__(self, index):
if index < 0:
index += self._size
if not (0 <= index < self._size):
raise IndexError("CircularBuffer index out of range")
return self._buffer[(self._start + index) % self._capacity]
def __reversed__(self):
for i in range(self._size - 1, -1, -1):
yield self[i]
cb = CircularBuffer(3)
cb.append(10)
cb.append(20)
cb.append(30)
print(len(cb))
print(cb[0])
print(cb[-1])
cb.append(40)
print(cb[0])
print(list(reversed(cb)))Solution
def __getitem__(self, index):
if index < 0:
index += self._size
if not (0 <= index < self._size):
raise IndexError("CircularBuffer index out of range")
return self._buffer[(self._start + index) % self._capacity]
def __reversed__(self):
for i in range(self._size - 1, -1, -1):
yield self[i]
Explanation: The key insight for circular buffer indexing is the modular arithmetic: the logical index i maps to physical position (self._start + i) % self._capacity. Negative indices are normalised by adding self._size. Providing __reversed__ avoids the O(n) copy that the default reversed() fallback would require. Without __reversed__, Python would fall back to creating a reversed copy via __len__ and __getitem__, but providing it explicitly as a generator is more memory-efficient and idiomatic.
class CircularBuffer:
"""Fixed-size circular buffer that overwrites oldest entries."""
def __init__(self, capacity):
self._capacity = capacity
self._buffer = [None] * capacity
self._start = 0
self._size = 0
def append(self, item):
pos = (self._start + self._size) % self._capacity
if self._size < self._capacity:
self._buffer[pos] = item
self._size += 1
else:
self._buffer[self._start] = item
self._start = (self._start + 1) % self._capacity
def __len__(self):
return self._size
def __getitem__(self, index):
# TODO: support positive and negative indices; raise IndexError for out-of-range
pass
def __reversed__(self):
# TODO: yield items in reverse order (newest to oldest)
pass
cb = CircularBuffer(3)
cb.append(10)
cb.append(20)
cb.append(30)
print(len(cb))
print(cb[0])
print(cb[-1])
cb.append(40) # overwrites 10
print(cb[0]) # oldest remaining = 20
print(list(reversed(cb)))Expected Output
3\n10\n30\n20\n[40, 30, 20]Hints
Hint 1: In __getitem__, normalise negative indices: if index < 0: index += self._size. Then check 0 <= index < self._size.
Hint 2: The real array index is (self._start + index) % self._capacity.
Hint 3: In __reversed__, yield self[i] for i in range(self._size - 1, -1, -1).
Implement __getattr__ in LazyProxy to forward attribute access to the inner _data object, loading it lazily on first access.
class LazyProxy:
def __init__(self, loader):
object.__setattr__(self, "_loader", loader)
object.__setattr__(self, "_data", None)
object.__setattr__(self, "_loaded", False)
def _ensure_loaded(self):
if not object.__getattribute__(self, "_loaded"):
data = object.__getattribute__(self, "_loader")()
object.__setattr__(self, "_data", data)
object.__setattr__(self, "_loaded", True)
def __getattr__(self, name):
self._ensure_loaded()
return object.__getattribute__(self, "_data")[name]
def load_config():
print("loading...")
return {"host": "localhost", "port": 5432}
proxy = LazyProxy(load_config)
print("proxy created")
print(proxy.host)
print(proxy.port)Solution
def __getattr__(self, name):
self._ensure_loaded()
return object.__getattribute__(self, "_data")[name]
Explanation: __getattr__ is Python's "attribute not found" fallback — it is only called when the normal lookup (instance __dict__, class, MRO) fails. This is different from __getattribute__, which intercepts every attribute access. By using __getattr__, the proxy's own attributes (_loader, _data, _loaded) are accessible normally without triggering the fallback. Using object.__getattribute__ and object.__setattr__ inside the proxy avoids infinite recursion that would occur if the proxy's own __getattribute__ or __setattr__ were defined. The "loading..." message appears only once, confirming the lazy load is cached.
class LazyProxy:
"""Proxy that loads data only when an attribute is first accessed."""
def __init__(self, loader):
object.__setattr__(self, "_loader", loader)
object.__setattr__(self, "_data", None)
object.__setattr__(self, "_loaded", False)
def _ensure_loaded(self):
if not object.__getattribute__(self, "_loaded"):
data = object.__getattribute__(self, "_loader")()
object.__setattr__(self, "_data", data)
object.__setattr__(self, "_loaded", True)
def __getattr__(self, name):
# Called ONLY when normal lookup fails
# TODO: ensure loaded, then return getattr on the inner _data
pass
def load_config():
print("loading...")
return {"host": "localhost", "port": 5432}
proxy = LazyProxy(load_config)
print("proxy created")
print(proxy.host) # triggers load
print(proxy.port) # no reloadExpected Output
proxy created\nloading...\nlocalhost\n5432Hints
Hint 1: __getattr__ is called only when the attribute is NOT found through the normal lookup path.
Hint 2: Call self._ensure_loaded() inside __getattr__, then return getattr(self._data, name).
Hint 3: Using object.__getattribute__ inside the proxy prevents infinite recursion when accessing _loader/_data/_loaded.
Implement __matmul__ so that A @ B performs standard matrix multiplication. The @ operator was added in Python 3.5 specifically for matrix operations.
class Matrix:
def __init__(self, rows):
self._rows = [list(r) for r in rows]
self._m = len(rows)
self._n = len(rows[0]) if rows else 0
def __matmul__(self, other):
if self._n != other._m:
raise ValueError(
f"Incompatible shapes: ({self._m},{self._n}) @ ({other._m},{other._n})"
)
result = [
[
sum(self._rows[i][k] * other._rows[k][j] for k in range(self._n))
for j in range(other._n)
]
for i in range(self._m)
]
return Matrix(result)
def __eq__(self, other):
if not isinstance(other, Matrix):
return NotImplemented
return self._rows == other._rows
def __repr__(self):
return f"Matrix({self._rows})"
A = Matrix([[1, 2], [3, 4]])
B = Matrix([[5, 6], [7, 8]])
C = A @ B
print(C)Solution
def __matmul__(self, other):
if self._n != other._m:
raise ValueError(
f"Incompatible shapes: ({self._m},{self._n}) @ ({other._m},{other._n})"
)
result = [
[
sum(self._rows[i][k] * other._rows[k][j] for k in range(self._n))
for j in range(other._n)
]
for i in range(self._m)
]
return Matrix(result)
Explanation: PEP 465 added the @ operator and __matmul__/__rmatmul__ dunders to Python 3.5 to support libraries like NumPy without repurposing *. The implementation applies the standard matrix multiply formula: element [i][j] of the result is the dot product of row i of the left matrix with column j of the right matrix. A shape check guards against incompatible dimensions. The result is wrapped in Matrix(...) to preserve the type, enabling chained operations like A @ B @ C.
class Matrix:
def __init__(self, rows):
self._rows = [list(r) for r in rows]
self._m = len(rows)
self._n = len(rows[0]) if rows else 0
def __matmul__(self, other):
# TODO: implement matrix multiplication (self @ other)
# self is m x n, other must be n x p, result is m x p
pass
def __eq__(self, other):
if not isinstance(other, Matrix):
return NotImplemented
return self._rows == other._rows
def __repr__(self):
return f"Matrix({self._rows})"
A = Matrix([[1, 2], [3, 4]])
B = Matrix([[5, 6], [7, 8]])
C = A @ B
print(C)Expected Output
Matrix([[19, 22], [43, 50]])Hints
Hint 1: Check that self._n == other._m before multiplying, raise ValueError otherwise.
Hint 2: Result[i][j] = sum(self._rows[i][k] * other._rows[k][j] for k in range(self._n)).
Hint 3: Build the result as a list of lists, then return Matrix(result_rows).
