Command Palette

Search for a command to run...

Hectal
PHASE 17Advanced ~29 min· topic 2 of 10

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.

main.pywhole filepython
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.

The tuple that raises and mutatesdiagram
Rendering diagram…

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

    Fix the grid

    In the second example, replace grid = [row] * 2 with grid = [[0] * 3 for _ in range(2)] and run it. The comprehension runs [0] * 3 once per row, so every row is its own list.

Code & diagrams

Closures, class attributes and the tuple puzzle New tab

Predict every line first. The last two lines show an error and a side effect from the same statement.

Sign in to run this example in your browser.

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)
Exhausted iterators, caching, ordering and a shared-row grid New tab

`[row] * 2` repeats one row object. That is the same bug as the shared default list.

Sign in to run this example in your browser.

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): 4

Break 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].

main.pywhole filepython
basket = ([1, 2], 3)
basket[0] += [10]
print(basket)
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 2, in <module>
basket[0] += [10]
~~~~~~^^^
TypeError: 'tuple' object does not support item assignment

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

01

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.

  • ▸

    dict equality ignores order, but OrderedDict equality is order-sensitive, and popitem(last=False) and move_to_end make OrderedDict the 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'] calls list.__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 in a.__dict__. Writing a.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 a set of 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. 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 same i. By the time you call them, the loop is over and i is 2, so each prints 2. Fix it by capturing the current value as a default argument (lambda i=i: i) or with functools.partial.

  2. 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. Reading a.total finds the class attribute. Assigning a.total = 1 (or a.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 as a.tags.append(...), changes it for every instance.

  3. 3

    **+= on a tuple that holds a list.** t[0] += [10] does two steps: it calls list.__iadd__ on the list (which mutates it in place and succeeds), then tries to assign the result back to t[0], which a tuple forbids. So you get a TypeError and the list has still changed. This is the one puzzle where an exception and a side effect both happen.

  4. 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 map object 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. 5

    Integer caching, float equality, banker's rounding. CPython reuses the integer objects from -5 to 256, so is seems to work for small numbers and then stops, which proves is is the wrong tool. 0.1 + 0.2 == 0.3 is False because binary floating point cannot store those decimals exactly; compare with math.isclose or work in decimal. round(2.5) is 2 because Python rounds ties to the nearest even number.

  6. 6

    Dict and set ordering. Since Python 3.7, a dict remembers 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. A set has 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

01

Why does [lambda: i for i in range(3)] give three functions that all return 2? How do you fix it?

02

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)?

03

What happens when you run t = ([1, 2], 3); t[0] += [10]? Does the list change?

04

Why can you loop over a list twice but over a generator only once?

05

Is 256 is 256 guaranteed to be True? What about 257 is 257?

06

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

01

Fix this so the three functions return 0, 1 and 2: fs = [lambda: i for i in range(3)]. Show two different fixes.

02

Write a function total_and_count(numbers) that works even when numbers is a generator, without looping twice.

03

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: i is short but adds a parameter anyone could override by accident. functools.partial or 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 is on numbers in one sentence each.