Topic 17.2
Interview Deep Dive: What Does This Print?
In one line
The famous Python puzzles (late-binding closures, class versus instance attributes, tuples holding lists, exhausted generators, integer caching, dict ordering) explained by the rule behind each, so you can predict the output instead of memorising it.
Think of it like this
A restaurant order slip that says "the soup of the day". If you read the slip at 11 am you get one soup, and if you read it at 9 pm you get another, because the slip stores a question, not the answer. A Python closure stores a name to look up later, not the value the name had when the closure was made. That single idea is the first puzzle below, and the rest of the puzzles each have one rule like it.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Late binding
- Looking a name up when the code runs, not when the function was written.
- Closure
- A function that keeps access to variables from the function that created it.
- Free variable
- A name a function uses but does not define itself.
- Iterator
- An object that hands out items one at a time with
next()and can only be used once. - Iterable
- Anything you can loop over; it can produce a new iterator whenever you ask.
- Interning / caching
- Reusing one object for equal small values to save memory.
- Floating point
- The way computers store decimals in binary. Many decimals, such as 0.1, can only be approximated.
Step by step
01The procedure for any 'what does this print' question
Do not guess from the shape of the code. Ask four questions in order. Which names are involved, and which objects do they point at? Is each object mutable? When does each name get looked up (now or at call time)? Does an operator mutate in place or build a new object?
Say your reasoning out loud in the interview. A wrong prediction with a correct rule can still score well. A right answer you cannot explain scores badly.
02Late binding closures
In funcs = [lambda: i for i in range(3)] there is one variable i in the enclosing scope, and three lambdas that all refer to it. After the comprehension finishes, i is 2. Every call prints 2.
In lambda i=i: i the first i is the lambda's own parameter, and the default =i is evaluated at creation time with the current value. Each lambda therefore carries its own number.
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs]) # [2, 2, 2]
fixed = [lambda i=i: i for i in range(3)]
print([f() for f in fixed]) # [0, 1, 2]03Class attribute surprises
The class Counter has total = 0 and tags = [] on the class itself. a.total += 1 expands to a.total = a.total + 1: it reads 0 from the class, adds one, and stores 1 on the instance a. b.total still reads the class value 0. But a.tags.append('x') never assigns to a.tags. It finds the class's list and mutates it, so b.tags shows ['x'] too.
Checking 'total' in a.__dict__ proves where each value lives. The mutable class attribute is the same bug as the mutable default argument, with a different spelling.
04The tuple that raises and mutates
t[0] += [10] is shorthand for t[0] = t[0].__iadd__([10]). The right side runs first: the list is extended in place. Then the assignment t[0] = ... fails, because tuples do not support item assignment. The exception tells you the assignment failed, but the list was already changed, as the first runnable example prints.
The lesson is not to memorise this exact puzzle. It is that augmented assignment on a subscript is a read, an in-place operation, and a write, and each can succeed or fail on its own.
05Iterators run out
A generator expression (n * n for n in range(4)) is an iterator. list(gen) consumes it. The second list(gen) finds nothing left and returns [], quietly. map in Python 3 behaves the same way. If you need to loop twice, store the results in a list, or build a fresh generator each time by wrapping the expression in a function.
06Numbers: caching, floats and rounding
The second runnable example builds numbers with int('256') so the compiler cannot merge the values at compile time. 256 is 256 is True (cached), 257 is 257 is False (two objects), yet 257 == 257 is True. The lesson is the same as before: use == for values.
Floats: 0.1 + 0.2 is 0.30000000000000004. Rounding to two places before comparing works for display, but for real comparisons use math.isclose(a, b). And round(2.5) is 2, round(3.5) is 4: ties go to the even neighbour, which avoids a statistical bias in long sums.
Try it yourself
- 1
Score yourself
Cover the output of the first example and write your prediction for all six lines on paper. Run it. Count how many you got right, and for each wrong one, name the rule from the list above that decides it.
- 2
Fix the grid
In the second example, replace
grid = [row] * 2withgrid = [[0] * 3 for _ in range(2)]and run it. The comprehension runs[0] * 3once per row, so every row is its own list.
Code & diagrams
Predict every line first. The last two lines show an error and a side effect from the same statement.
Expected output
late binding: [2, 2, 2]
default arg fix: [0, 1, 2]
a.total: 1 | b.total: 0 | Counter.total: 0
b.tags: ['x'] | 'total' in a.__dict__: True
TypeError: 'tuple' object does not support item assignment
but the list changed anyway: ([1, 2, 10], 3)`[row] * 2` repeats one row object. That is the same bug as the shared default list.
Expected output
first pass: [0, 1, 4, 9]
second pass: []
map once: ['1', '2', '3'] then: []
256 is 256: True | 257 is 257: False | 257 == 257: True
insertion order kept: ['b', 'a'] | equal ignores order: True
0.1 + 0.2 == 0.3: False | round-trip: True
two empty lists are the same object: False | equal: True
True + True: 2 | 'a' * 3: aaa | 3 * [0]: [0, 0, 0]
shared rows: [[1, 0, 0], [1, 0, 0]]
-7 // 2: -4 | -7 % 2: 1 | round(2.5): 2 | round(3.5): 4Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Use += on a tuple that holds a list
Build basket = ([1, 2], 3) and run basket[0] += [10].
basket = ([1, 2], 3)
basket[0] += [10]
print(basket)Myth vs fact
Myth
A closure remembers the value a variable had when the function was created.
Fact
It remembers the variable itself. The value is read each time the function runs, which is why loop variables captured in lambdas all show the last value.
Myth
a.x += 1 changes the class attribute x.
Fact
It reads the class value and stores the new value on the instance. Only the instance gains an attribute.
Myth
Dicts are unordered, so never rely on their order.
Fact
That was true before Python 3.7. Since then insertion order is part of the language. Sets are still unordered.
Myth
Floating point in Python is broken.
Fact
Python follows the IEEE 754 standard like nearly every language. 0.1 cannot be stored exactly in binary. Use decimal for money or math.isclose for comparisons.
Interview problem
The problem
Predict the output of a five-line puzzle
An interviewer shows: class A: with items = [] and def add(self, x): self.items.append(x); then a, b = A(), A(); a.add(1); b.add(2); print(a.items, b.items). What prints, why, and how do you change the class so each instance has its own list?
The interviewer follows up
What if the interviewer changes append to self.items = self.items + [x]?
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Since Python 3.12 (PEP 709, a numbered design document for Python) list, dict and set comprehensions are inlined in the function that contains them. The loop variable still does not leak, and the late-binding rule for lambdas inside comprehensions is unchanged. Do not assume the inlining changes behaviour you can observe.
- ▸
dictequality ignores order, butOrderedDictequality is order-sensitive, andpopitem(last=False)andmove_to_endmakeOrderedDictthe natural base for an LRU cache (see the exercise). - ▸
The sneakiest class-attribute puzzle mixes the two rules. With
tags = []on the class,a.tags += ['y']callslist.__iadd__, which extends the shared list in place, and then stores the same list on the instance. So every instance sees'y'and'tags'now appears ina.__dict__. Writinga.tags = a.tags + ['y']instead builds a new list and really does leave the class alone. The same read-then-write split is why+=on a list and+on a list behave differently (Topic 17.1). - ▸
String hash randomisation (
PYTHONHASHSEED) is why the iteration order of asetof strings can differ between runs. Tests that print a set must sort it first, and so should any code that produces output a human will diff.
Remember this
- 1
Late binding. Recall from Topic 5.7: a closure is a function that remembers variables from the place where it was made. A function looks up the names it uses (its free variables) when it runs, not when it is defined.
[lambda: i for i in range(3)]creates three functions that all read the samei. By the time you call them, the loop is over andiis2, so each prints2. Fix it by capturing the current value as a default argument (lambda i=i: i) or withfunctools.partial. - 2
Class versus instance attributes (Topic 8.2). An attribute is a named value stored on an object, and an instance is one object made from a class. Attribute lookup on an instance goes: the instance's own
__dict__, then the class, then its base classes. Readinga.totalfinds the class attribute. Assigninga.total = 1(ora.total += 1, which reads then assigns) always creates an attribute on the instance and leaves the class alone. Mutating a shared mutable class attribute, such asa.tags.append(...), changes it for every instance. - 3
**
+=on a tuple that holds a list.**t[0] += [10]does two steps: it callslist.__iadd__on the list (which mutates it in place and succeeds), then tries to assign the result back tot[0], which a tuple forbids. So you get aTypeErrorand the list has still changed. This is the one puzzle where an exception and a side effect both happen. - 4
Exhausted iterators. Recall from Topics 9.1 and 9.2: an iterator hands out one value at a time, and a generator is one kind of iterator. A generator, a
mapobject and a file are iterators: they produce each value once and then are empty. Looping over a generator a second time gives nothing, with no error. A list is an iterable that can create a fresh iterator each time you loop. Many bugs come from passing a generator to code that loops twice (for example computing a sum and then a length). - 5
Integer caching, float equality, banker's rounding. CPython reuses the integer objects from -5 to 256, so
isseems to work for small numbers and then stops, which provesisis the wrong tool.0.1 + 0.2 == 0.3isFalsebecause binary floating point cannot store those decimals exactly; compare withmath.iscloseor work indecimal.round(2.5)is2because Python rounds ties to the nearest even number. - 6
Dict and set ordering. Since Python 3.7, a
dictremembers insertion order as a language guarantee, and re-assigning an existing key keeps its original position. Two dicts with the same items are==regardless of order. Asethas no meaningful order, and the order of a set of strings can change between runs because string hashes (the numbers Python uses to find items quickly, Topic 4.5) are randomised for security.
Explain it without notes
Why does [lambda: i for i in range(3)] give three functions that all return 2? How do you fix it?
A class has count = 0. After a.count += 1, what are a.count, b.count and A.count, and why? What changes if count is a list and you call a.count.append(1)?
What happens when you run t = ([1, 2], 3); t[0] += [10]? Does the list change?
Why can you loop over a list twice but over a generator only once?
Is 256 is 256 guaranteed to be True? What about 257 is 257?
A class has tags = []. What do a.tags, b.tags and A.tags show after a.tags += ['y'], and is 'tags' in a.__dict__?
Practice
Fix this so the three functions return 0, 1 and 2: fs = [lambda: i for i in range(3)]. Show two different fixes.
Write a function total_and_count(numbers) that works even when numbers is a generator, without looping twice.
Compare 0.1 + 0.2 with 0.3 correctly using the standard library, and print True.
Trade-offs
- ↔
Cleverness versus clarity. Puzzles reward knowing odd corners, but production code should avoid them. If a line needs a comment to explain what it prints, rewrite it.
- ↔
**Default-argument capture versus
partial.**lambda i=i: iis short but adds a parameter anyone could override by accident.functools.partialor a small factory function is more explicit.
Done when you can
Done when you can state the four questions to ask about any output puzzle.
Done when you can explain late binding and give two fixes.
Done when you can say where a class attribute is read from and where
+=stores its result.Done when you can explain why
t[0] += [10]both raises and mutates.Done when you can explain exhausted iterators, float equality and
ison numbers in one sentence each.