Container & context protocols
Make your class work with len(), for, in, and with by implementing the dunders they call.
Syntax is a call in disguise
Python's built-in syntax does not check what type you are. It calls a method and trusts the answer. len(x) is x.__len__(). for v in x is driven by x.__iter__(). v in x tries x.__contains__(v). Implement the method and the syntax starts working on your class, with no inheritance and no registration.
| You write | Python calls | Must return |
|---|---|---|
len(x) | x.__len__() | a non-negative int |
for v in x | x.__iter__() | an iterator (usually via yield) |
v in x | x.__contains__(v) | a bool |
x[k] | x.__getitem__(k) | the element |
x(...) | x.__call__(...) | anything |
with x as y | x.__enter__() then x.__exit__(...) | the as value, then a falsy value |
class Deck:
def __init__(self, cards):
self._cards = cards
def __len__(self):
return len(self._cards)
def __iter__(self):
yield from self._cards
def __contains__(self, card):
return card in self._cards
deck = Deck(["A", "K", "Q"])
print(len(deck)) # 3
print("K" in deck) # True
print([c for c in deck]) # ['A', 'K', 'Q']
Three small methods and Deck now behaves like a built-in collection everywhere: comprehensions, sorted(), max(), tuple unpacking, and if deck: all work, because they are all written against these same protocols.
Truthiness comes free, and that is a trap
if deck: has no dunder of its own by default. Python falls back to __len__ and treats zero as false. That is convenient until it is not:
empty = Deck([])
if empty:
print("has cards") # never runs, because len(empty) == 0
If your object should always be truthy regardless of size, say so explicitly with __bool__. Otherwise an empty-but-valid object silently behaves like None at every if.
Context managers: guaranteeing the cleanup
with exists so that cleanup runs even when the body raises. __enter__ sets up and returns the value bound by as; __exit__ tears down and receives the exception, if any.
class Timer:
def __enter__(self):
self.calls = []
return self # this is what "as t" binds
def __exit__(self, exc_type, exc, tb):
self.calls.append("closed")
return False # do not swallow exceptions
with Timer() as t:
t.calls.append("working")
print(t.calls) # ['working', 'closed']
__exit__ receives three arguments describing the exception (or three None values on a clean exit). Its return value is a decision: falsy lets the exception propagate, truthy swallows it.
About to enter the with statement.
Pitfalls
- Returning
Truefrom__exit__by accident. A barereturn True, or ending with a value that happens to be truthy, silently swallows every exception in the block. ReturnFalse(or nothing at all, sinceNoneis falsy) unless suppressing is the explicit intent. __len__returning a non-integer.len()raisesTypeErrorif you hand back a float or a string, even when the number is right.- Forgetting that
__len__drives truthiness. An object with__len__returning0is falsy. Add__bool__if that is wrong for your type. - Building a list just to iterate.
__iter__shouldyieldrather thanreturn list(...)when the source is large: yielding streams one item at a time instead of materialising the whole collection.
Interview nuance: this is what "duck typing" concretely means. Python never asks whether Deck inherits from a collection base class; it asks whether Deck answers __len__. That is why collections.abc classes are mostly optional conveniences rather than requirements, and why a mock object that implements the same three dunders is indistinguishable from the real collection at every call site.
class Deck:
def __init__(self, cards):
self._cards = cards
def __len__(self):
return len(self._cards)
def __iter__(self):
yield from self._cards
deck = Deck(["A", "K", "Q"])
print(len(deck)) # 3
print([c for c in deck]) # ['A', 'K', 'Q']Apply
Your turn
The task this lesson builds to.
Implement __len__ and __contains__ on Deck so len(deck) counts the cards and card in deck works.
run(["A", "K"], "K") should return [2, True].
3 hints and 4 automated checks are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, so it survives past today.
Write a Session context manager whose __enter__ returns the object and whose __exit__ appends "closed" to self.log.
run("query") should return ["opened", "query", "closed"], with "closed" appended even though the body runs first.
3 hints and 3 automated checks are waiting in the workspace.