Skip to main content

Container & context protocols

Level 2: Level 2: Idiomsmedium13 mindunder-methodsiterationcontext-managerclasses

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 writePython callsMust return
len(x)x.__len__()a non-negative int
for v in xx.__iter__()an iterator (usually via yield)
v in xx.__contains__(v)a bool
x[k]x.__getitem__(k)the element
x(...)x.__call__(...)anything
with x as yx.__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

Check yourself
Deck implements __len__ and nothing else. You build empty = Deck([]) and write if empty: print('ready'). What happens?

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']
Check yourself
The body of with Timer() as t: raises ValueError on its second line. Does __exit__ run, and what does the caller of this code see?

__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.

Call stack — with Timer() as t: (body raises)
Step 1 / 5
main

About to enter the with statement.

__exit__ runs whether the body succeeds or raises. Returning False re-raises; returning True would silently swallow the error.
Check yourself
A teammate's __exit__ closes the connection and then ends with return True. A ValueError is raised inside the with body. What does the surrounding code observe?

Pitfalls

  • Returning True from __exit__ by accident. A bare return True, or ending with a value that happens to be truthy, silently swallows every exception in the block. Return False (or nothing at all, since None is falsy) unless suppressing is the explicit intent.
  • __len__ returning a non-integer. len() raises TypeError if you hand back a float or a string, even when the number is right.
  • Forgetting that __len__ drives truthiness. An object with __len__ returning 0 is falsy. Add __bool__ if that is wrong for your type.
  • Building a list just to iterate. __iter__ should yield rather than return list(...) when the source is large: yielding streams one item at a time instead of materialising the whole collection.
Check yourself
A test swaps the real Deck for a stub that defines __len__, __iter__, and __contains__ and inherits from nothing but object. Do len(stub), sorted(stub), and 'K' in stub work?

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.

Worked example (Python)
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.