Scope and closures

Know where a name is looked up, why assigning to one changes the rules, and why the loop you just wrote captured the wrong value.

10of 24
≈ 12Min read
5Parts

LEGB

A name is looked up in that order and the search stops at the first hit. That is also the danger: calling a variable list or id at module level shadows the built-in for the whole file, and the error appears somewhere else entirely.

ScopeExample
LLocal — inside this functiona parameter, or anything assigned in the body
EEnclosing — a function around this onethe variable a closure captured
GGlobal — this modulea name defined at the top of the file
BBuilt-inlen, print, list

Assignment makes a name local — for the whole function

Python decides at compile time: if a function assigns to a name anywhere in its body, that name is local everywhere in the body, including on lines before the assignment. Reading it first is an UnboundLocalError, not a fall back to the global.

Mutating is not assigning — items.append(x) works on a global list without any declaration, because it never rebinds the name. items = items + [x] does rebind, and fails.

py
count = 0

def bump():
    count = count + 1       # UnboundLocalError
    #      ^ reading a local that has not been assigned yet

def bump():
    global count            # "this name is the module's one"
    count = count + 1

def show():
    print(count)            # fine — reading a global needs no declaration

Closures, and nonlocal

A closure is a function that remembers the variables of the scope it was defined in — not their values, the variables. global reaches the module; nonlocal reaches the nearest enclosing function. There is no way to reach two levels up by name.

py
def counter():
    n = 0
    def tick():
        nonlocal n          # the enclosing n, not a new local one
        n += 1
        return n
    return tick

t = counter()
t(), t(), t()               # 1, 2, 3

# the captured variable lives as long as the inner function does
t.__closure__[0].cell_contents   # 3

The late-binding loop

The closure captured the variable i, and by the time anything was called the loop had finished with it at 2. Default arguments are evaluated at definition time, which is exactly the property that fixes it here — the same rule that caused the mutable-default bug in the last lesson.

the bug everyone writes once
handlers = []
for i in range(3):
    handlers.append(lambda: i)

[h() for h in handlers]        # [2, 2, 2]  — every lambda sees the same i

# fix 1: bind the value now, as a default argument
handlers = [lambda i=i: i for i in range(3)]

# fix 2: a factory, which makes a genuinely new scope each time
def make(i):
    return lambda: i
handlers = [make(i) for i in range(3)]

[h() for h in handlers]        # [0, 1, 2]

Two smaller rules

  • A comprehension has its own scope. [x for x in items] leaves no x behind; the for loop it replaces does.
  • A class body is not an enclosing scope. A method cannot see a class attribute by bare name — it is self.thing or ClassName.thing.
  • global and nonlocal are declarations, not assignments. They change where the name lives; they do not give it a value.

Practice