Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Closures & Decorators  ›  Lesson

Closures & the Cell

Closures 22 minFunctions that keep their enclosing variables alive after the enclosing call returns
You're building a piece ofQuarry — the middleware ring
This piece — counter_readings(): A counter made by a factory that keeps its own tally between calls — the memory every wrapper needs.
Scenario Quarry's sampler keeps every fifth row and has to label them 5, 10, 15 so a bad one can be traced back to its place in the file.
Your task
Write make_counter(step), a factory returning a function that adds step to a running total (starting at 0) and returns the new total each time it is called. Then write counter_readings(step, calls), which builds one counter and returns the list of its first `calls` readings. Example: counter_readings(5, 4) → [5, 10, 15, 20].

Closures & the Cell

In short: a closure is an inner function that keeps using a variable from the function that defined it, even after that outer call has already returned — Python keeps the variable alive in a small cell, and the inner function holds a reference to the cell itself, not a copy of its value. That's why a closure sees a variable's latest value, not a snapshot from when it was defined. Reading a captured variable is free; rebinding it (the left side of = or +=) needs nonlocal, the same rule Fundamentals taught for global. Every call to the outer function builds fresh cells, so two closures from two separate calls never share state — but several closures made in the same call, or the same loop, can end up sharing one cell, which is usually a bug and occasionally exactly the point.

make_tagger("access")'s frame crossed out and gone, but the cell it left behind (prefix = "access") is still there because tag still points at it

Tag log lines by channel

A function defined inside another function can read the outer function's variables — including after the outer call has finished:

python

By the time access(...) runs, make_tagger("access") has already returned — its local variables should be gone. Python notices tag uses prefix and puts it in a cell instead of discarding it; tag keeps a reference to that cell:

python

The cell holds the variable, not a frozen value — so if it changes before the inner function runs, the inner function sees the change:

What does this print?
def build():
    limit = 10
    def show():
        return limit
    limit = 99
    return show

print(build()())

Numbering rows as they pass

A counter that numbers rows so a bad one can be reported as "row 4812" instead of "some row". The obvious attempt fails the same way a bare global mistake would:

broken — fix itUnboundLocalError

Run it and read the error name. Add one line so bump rebinds the enclosing count instead of creating its own.

nonlocal count tells Python the name belongs to the nearest enclosing function that has one, so count += 1 rebinds the cell instead of creating a fresh local:

python

Mutating an object inside the cell is not rebinding, so it needs no nonlocal at all:

python

You only need nonlocal when the name sits on the left of =, += or similar — exactly as with global.

Scale three sensor channels

Each call to a factory runs its body again and builds fresh cells, so two counters from two separate calls share nothing:

What does this print?
def make_counter():
    count = 0
    def bump():
        nonlocal count
        count += 1
        return count
    return bump

rows = make_counter()
errors = make_counter()
rows(); rows(); rows()
errors()
print(rows(), errors())

But three functions built in the same call share whatever cell that call created — which is exactly the trap that catches almost everyone once:

What does this print?
def build_scalers():
    scalers = []
    for channel in range(3):
        scalers.append(lambda x: x * channel)
    return scalers

print([f(10) for f in build_scalers()])

Step through it and read cell_contents for each function. The numbers are real, taken from the functions themselves:

step through it
1def build_scalers():
2 scalers = []
3 for channel in range(3):
4 scalers.append(lambda x: x * channel)
5 return scalers
6 
7scalers = build_scalers()
8shared = [f.__closure__[0].cell_contents for f in scalers]
9same_cell = scalers[0].__closure__[0] is scalers[2].__closure__[0]
10 
11def make_scaler(factor):
12 return lambda x: x * factor
13 
14fixed = [make_scaler(c) for c in range(3)]
15own = [f.__closure__[0].cell_contents for f in fixed]
16different = fixed[0].__closure__[0] is not fixed[2].__closure__[0]
17print(shared, same_cell, own, different)

Here is the picture the trace is describing:

Drawing diagram…

Two standard fixes, and both work by giving each function its own cell:

python

Calling make_scaler separately for each function is the clean fix. The default argument trick works too — it evaluates c at definition time, not call time — but it adds a parameter a caller could override by accident.

Track call counts with a class instead

A class can hold the same state:

python

Rule of thumb: one operation on the remembered state, reach for a closure — it's shorter, and there's no attribute for outside code to poke at by accident. Several operations sharing one state, or code outside that needs to inspect it, write a class. Closures can share one cell between several inner functions returned together — you'll use exactly that in the exercises below — but past two or three functions, a class reads better.

Quick practice

What does this print?
def make_counter():
    count = 0
    def bump():
        nonlocal count
        count += 1
        return count
    return bump

a = make_counter()
b = make_counter()
a(); a()
print(a(), b())
What does this print?
def make_adders():
    return [lambda x: x + i for i in range(3)]

print([f(10) for f in make_adders()])

🎯 Your turn

Some of Quarry's stages count in steps — a sampler that keeps every fifth row numbers them 5, 10, 15, and so on.

Write make_counter(step), a factory returning a function. Each call adds step to a running total that starts at 0, and returns the new total. Then write counter_readings(step, calls): build one counter and return what it returns on each of calls calls.

  • counter_readings(1, 3) → [1, 2, 3]
  • counter_readings(5, 4) → [5, 10, 15, 20]
  • counter_readings(2, 0) → []

Hint: the inner function rebinds the total, so it needs nonlocal. Build the counter once, outside the loop that calls it.

Press ▶ Run, then ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upA factory that remembers a greeting

Write make_greeter(greeting) returning a function that takes a name and returns f"{greeting}, {name}!". Then write greet_with(greeting, name) that builds a greeter and calls it once.

closuresfirst-class-functions
DrillRunning totals with nonlocal

Write make_accumulator() returning a function add(amount) that adds amount to a running total and returns the new total. Then running_totals(amounts) feeds every amount to one accumulator and returns the list of totals it reported.

closuresnonlocal
BuildScalers without the loop trap

Write channel_outputs(n, reading). Build a list of n functions in a loop, where function i multiplies its argument by i. Return a list of every function applied to reading. The late-binding trap will make every function use the last i unless you give each one its own cell.

closuresclosure-late-binding
BossA per-source rate limiter

Quarry refuses to read more than max_reads batches from any one source per run. Write make_limiter(max_reads) returning allow(source), which returns True while that source has been allowed fewer than max_reads times, and False after. Then limiter_verdicts(max_reads, sources) returns the list of verdicts for each source in order.

closuresdictionaries
CapstoneTwo closures, one set of cells

Write make_stats() returning a pair of functions (add, summary) that share the same state. add(x) records a value; summary() returns {"count": n, "total": t, "min": lo, "max": hi}, with min/max as None when nothing was added. Then stats_after(values) adds every value and returns the summary.

closuresnonlocalcell-variables
ExplainWhy every loop-built lambda agrees

A teammate builds handlers = [lambda row: row[i] for i in range(3)] and finds that every handler returns column 2. Explain to them what is actually happening, in terms of cells, and give two ways to fix it — saying which you prefer and why.

counter_readings.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
counter_readings(step, calls) → listBuild one counter that counts in `step`s and return its first `calls` readings.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.