Skip to main content
CodeOath
← All posts

Python105 min total · 18 parts

Python Fundamentals for Interviews: Data Structures, Comprehensions, and Gotchas

Part 7 of 18 · ~5 min

Functions, Scope, and Closures

Looking up a name means checking four places, in a fixed order, and taking whatever the first hit turns out to be: the current function's own locals, then any enclosing function's locals, then the module's globals, and finally Python's own built-ins — remembered by the acronym LEGB. A name found in an inner scope simply shadows whatever the same name would have resolved to further out; the outer one is never touched.

threshold = 50                        # "global"

def build_alert(ip, count):
    threshold = 100                   # this call's own "enclosing" threshold
    def format_message():
        if count > threshold:         # LEGB walks outward, finds it in build_alert's scope
            return f"{ip} exceeded {threshold} requests"
        return f"{ip} is under {threshold}"
    return format_message()

build_alert("198.51.100.7", 340)      # "198.51.100.7 exceeded 100 requests"

Closures

Time to collect the deque set aside back in the collections chapter. The watcher's real job isn't counting — it's deciding, right now, whether this request should be let through, given how many that same IP has made recently. That needs state that survives between calls without being a global every part of the program can stomp on:

from collections import deque

def make_rate_limiter(max_requests, window_seconds):
    timestamps = deque()                          # one deque per limiter — this IS the memory

    def is_allowed(now):
        while timestamps and now - timestamps[0] > window_seconds:
            timestamps.popleft()                   # drop anything outside the window — O(1)
        if len(timestamps) < max_requests:
            timestamps.append(now)
            return True
        return False

    return is_allowed

login_limiter = make_rate_limiter(max_requests=5, window_seconds=60)
search_limiter = make_rate_limiter(max_requests=200, window_seconds=60)   # a separate deque entirely

make_rate_limiter ran and returned. By every rule of scope covered so far, its local timestamps should be gone. It is not:

login_limiter(1000)   # True
login_limiter(1010)   # True  (five calls in, still under 5 within 60s)
login_limiter(1030)   # False — five timestamps are already sitting inside the 60-second window

is_allowed is what's called a closure, and the definition is really just a description of what you just watched happen: an inner function that keeps a live connection to a specific variable from the scope it was born in, one that lasts for as long as the function itself does, not just for as long as its enclosing call was running. login_limiter and search_limiter each got their own private timestamps at creation time and never share it — call one of them a thousand times in a row and it has zero effect on what the other one remembers.

The late-binding closure bug in loops

Ask someone to predict the output of this before running it and watch confidence collide with reality. The watcher wants one check function per severity threshold:

buckets = [400, 500, 999]      # client errors, server errors, catastrophic
checks = []
for limit in buckets:
    checks.append(lambda status: status >= limit)

[fn(450) for fn in checks]     # [True, True, True] — NOT [True, False, False]!

What each lambda actually remembers is the name limit itself, not a snapshot of whatever number it pointed to the moment the lambda was written. There's only one limit in the whole loop — it gets overwritten on every pass — so by the time any of these three lambdas is finally called, the loop has long since finished and every one of them looks up the exact same, now-final value. Trapping the current number requires giving each lambda its own private copy, and a default parameter is the tool for that specifically because Python reads default values once, immediately, while the def (or here, the lambda) line itself is running — a fact that's about to explain something far more dangerous once the next chapter arrives:

checks = []
for limit in buckets:
    checks.append(lambda status, limit=limit: status >= limit)  # limit=limit freezes THIS pass's value

[fn(450) for fn in checks]     # [True, False, False] — correct

nonlocal and global

def make_flag_counter():
    total = 0
    def flag():
        nonlocal total          # without this, "total += 1" raises UnboundLocalError
        total += 1
        return total
    return flag

flags_seen = make_flag_counter()
flags_seen()   # 1
flags_seen()   # 2

Delete nonlocal total and the very same line, total += 1, stops meaning "update the enclosing total" and instead declares a brand-new local variable that happens to share its name with one further out. That's not a runtime decision Python makes as the line executes — it's settled once, before flag ever runs, by scanning the whole function body for any assignment to total anywhere in it. Having found one, Python commits to treating every mention of total inside flag as local, for the entire function, including the read on the right-hand side of += that comes before the assignment completes. That read fails, because the local total doesn't have a value yet — which is exactly what UnboundLocalError is reporting. It doesn't fall back to the enclosing total the way a simple read would; the name is already claimed as local, it's just not usable yet.

make_rate_limiter and a hand-written class with an __init__ and a method would do exactly the same job — a closure is genuinely just an object with one method and no name for its state, and a class with one method is genuinely just a closure with a name for its state. The practical difference shows up in what each is convenient for. A closure is quick to build for a single piece of behavior with a small amount of private state, and that state is truly private — nothing outside is_allowed can reach timestamps at all, not even by name. A class is worth the extra ceremony the moment there's more than one related piece of behavior sharing that state (a rate limiter that also needs a .reset() and a .remaining(), say), because bundling several methods around shared state is exactly what a class is for and exactly what a closure resists.