Closures, LEGB Rule, and nonlocal

Difficulty: Intermediate

Question

Explain Python closures and the LEGB scoping rule. What does nonlocal do?

Answer

LEGB rule - Python looks up names in this order: 1. Local: current function scope 2. Enclosing: enclosing function scopes (for nested functions) 3. Global: module-level scope 4. Built-in: Python built-in names (len, print, etc.)

Closure: a nested function that captures free variables from its enclosing scope. The captured variables are stored in the closure's __closure__ attribute.

nonlocal: declares that a name refers to the enclosing scope's variable and allows assignment to it (without nonlocal, assignment creates a new local variable).

Code examples

LEGB and Closures

x = 'global'

def outer():
    x = 'enclosing'

    def inner():
        # x = 'local'  # Would shadow enclosing x
        print(x)  # Finds x in enclosing scope (LEGB)

    inner()

outer()  # enclosing

# Closure - counter factory
def make_counter(start=0):
    count = start  # Free variable captured by closure

    def increment(step=1):
        nonlocal count  # Modify enclosing count
        count += step
        return count

    def reset():
        nonlocal count
        count = start

    def get():
        return count

    return increment, reset, get

inc, reset, get = make_counter(10)
print(inc())    # 11
print(inc(5))   # 16
print(get())    # 16
reset()
print(get())    # 10

# Inspect closure
print(inc.__code__.co_freevars)  # ('count', 'start')
print(inc.__closure__[0].cell_contents)  # 10

nonlocal allows modifying the enclosing scope's variable. Without it, count += step would create a new local count (UnboundLocalError).

Classic Closure Gotcha and Fix

# Classic gotcha: loop variable in closure
functions = []
for i in range(5):
    functions.append(lambda: i)  # All capture same i

result = [f() for f in functions]
print(result)  # [4, 4, 4, 4, 4] - i is 4 at call time

# Fix 1: default argument captures current value
functions = []
for i in range(5):
    functions.append(lambda i=i: i)  # i=i captures current i

print([f() for f in functions])  # [0, 1, 2, 3, 4]

# Fix 2: factory function
def make_fn(i):
    return lambda: i

functions = [make_fn(i) for i in range(5)]
print([f() for f in functions])  # [0, 1, 2, 3, 4]

# global keyword
total = 0

def add_to_total(n):
    global total  # Without this, assignment creates local total
    total += n

add_to_total(5)
add_to_total(3)
print(total)  # 8

The loop gotcha: closures capture the variable, not its value. The lambda captures the reference to i, which is 4 when all functions are called.

Key points

Concepts covered

Closure, LEGB, nonlocal, global, Free Variables