Skip to main content

Python Closures Intro Practice Problems & Exercises

Practice: Closures Intro

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

Easy

#1Your First ClosureEasy
closurefree-variablefunction-factorybasics

Create two simple closure factories and verify that each returned function captures its own independent copy of the enclosing variable.

Python
def make_greeter(name):
    def greet():
        return f"Hello, {name}!"
    return greet

def make_adder(n):
    def add(x):
        return x + n
    return add

greet_alice = make_greeter("Alice")
greet_bob = make_greeter("Bob")

print(f"greet_alice: {greet_alice()}")
print(f"greet_bob: {greet_bob()}")

add5 = make_adder(5)
add10 = make_adder(10)

print(f"add5(3): {add5(3)}")
print(f"add10(3): {add10(3)}")
print(f"add5 and add10 are different objects: {add5 is not add10}")
Solution
greet_alice: Hello, Alice!
greet_bob: Hello, Bob!
add5(3): 8
add10(3): 13
add5 and add10 are different objects: True

How it works:

  • make_greeter("Alice") creates a new scope where name = "Alice". The inner greet function captures name as a free variable. When make_greeter returns, the scope is destroyed, but the value of name is preserved in a cell object attached to greet.
  • make_greeter("Bob") creates a completely separate scope with name = "Bob", producing an independent closure.
  • Same pattern for make_adder: each call creates a new cell holding the specific value of n.
  • The two closures are different function objects (add5 is not add10 is True), each with their own __closure__ cells.
Expected Output
greet_alice: Hello, Alice!
greet_bob: Hello, Bob!
add5(3): 8
add10(3): 13
add5 and add10 are different objects: True
Hints

Hint 1: A closure is an inner function that captures a variable from its enclosing scope. The captured variable is called a free variable.

Hint 2: Each call to the outer function creates a new scope with its own value, so `make_greeter("Alice")` and `make_greeter("Bob")` produce independent closures.

#2Identifying Free VariablesEasy
free-variablelocal-variableglobalscope

Classify each variable as local, free, or global. In the code below, determine which category x, y, and z belong to from the perspective of inner.

Python
z = "global_z"

def outer():
    x = "enclosing_x"
    def inner():
        y = "local_y"
        return x + y + z
    return inner

fn = outer()

# Inspect inner's variable classification
print(f"inner free vars: {fn.__code__.co_freevars}")
print(f"x is free (not local, not global): {'x' in fn.__code__.co_freevars}")
print(f"y is local to inner: {'y' in fn.__code__.co_varnames}")
print(f"z is global: {'z' in globals()}")
Solution
inner free vars: ('x',)
x is free (not local, not global): True
y is local to inner: True
z is global: True

Variable classification from inner's perspective:

VariableCategoryWhy
xFree variableDefined in outer (enclosing scope), referenced in inner, stored in a closure cell
yLocal variableDefined by assignment inside inner, listed in co_varnames
zGlobal variableDefined at module level, accessed via LOAD_GLOBAL bytecode

Only x appears in co_freevars because it is the only variable that comes from an enclosing function scope. z is accessed via the global namespace (LEGB rule: the G), not through a closure cell. y is a plain local variable created by assignment within inner.

Expected Output
inner free vars: ('x',)
x is free (not local, not global): True
y is local to inner: True
z is global: True
Hints

Hint 1: A free variable is one that is used inside a function but defined in an enclosing (not global) scope. It is neither a local variable nor a global.

Hint 2: Use `__code__.co_freevars` to see free variables, `__code__.co_varnames` for locals, and check the global namespace with `"z" in globals()`.

#3Inspecting __closure__ and Cell ObjectsEasy
__closure__cell-contentsco_freevarsintrospection

Inspect the closure internals of a function that captures two free variables. Also verify that a plain function (no enclosing scope) has __closure__ set to None.

Python
def make_multiplier(factor, label):
    def multiply(x):
        return x * factor
    multiply.label = label
    return multiply

triple = make_multiplier(3, "triple")

# Inspect closure
print(f"Has closure: {triple.__closure__ is not None}")
print(f"Number of cells: {len(triple.__closure__)}")
print(f"Free var names: {triple.__code__.co_freevars}")

for i, name in enumerate(triple.__code__.co_freevars):
    val = triple.__closure__[i].cell_contents
    print(f"cell {i} ({name}): {val}")

print(f"triple(10): {triple(10)}")

# A plain function has no closure
def plain_add(a, b):
    return a + b

print(f"No closure (plain function): {plain_add.__closure__ is None}")
Solution
Has closure: True
Number of cells: 2
Free var names: ('factor', 'label')
cell 0 (factor): 3
cell 1 (label): triple
triple(10): 30
No closure (plain function): True

Understanding the output:

  • triple.__closure__ is a tuple of two cell objects because multiply references two variables from its enclosing scope: factor and label.
  • triple.__code__.co_freevars is ('factor', 'label') — these names correspond positionally to the cells in __closure__.
  • triple.__closure__[0].cell_contents is 3 (the value of factor), and triple.__closure__[1].cell_contents is "triple" (the value of label).
  • plain_add has no enclosing scope variables, so __closure__ is None.

Note that label is captured even though it is only used to set an attribute on the function object, not inside the body of multiply. CPython captures any variable referenced in the inner function's scope during construction.

Expected Output
Has closure: True
Number of cells: 2
Free var names: ('factor', 'label')
cell 0 (factor): 3
cell 1 (label): triple
triple(10): 30
No closure (plain function): True
Hints

Hint 1: `__closure__` is a tuple of cell objects. Each cell has a `cell_contents` attribute holding the captured value.

Hint 2: `__code__.co_freevars` lists the variable names in the same order as the cells in `__closure__`.

#4Closure vs Global VariableEasy
closureglobalscopeLEGB

Predict the output and explain why one function has a closure and the other does not.

Python
message = "global"

def make_closure():
    message = "enclosing"
    def fn():
        return message
    return fn

def make_non_closure():
    def fn():
        return message
    return fn

fn1 = make_closure()
fn2 = make_non_closure()

print(f"fn1(): {fn1()}")
print(f"fn2(): {fn2()}")
print(f"fn1 has closure: {fn1.__closure__ is not None}")
print(f"fn2 has closure: {fn2.__closure__ is None}")
Solution
fn1(): enclosing
fn2(): global
fn1 has closure: True
fn2 has closure: False

Why the difference:

  • fn1 is a true closure. Inside make_closure, the local message = "enclosing" shadows the global. When fn references message, Python finds it in the enclosing scope (the E in LEGB) and creates a closure cell. fn1.__closure__ contains one cell holding "enclosing".

  • fn2 is NOT a closure. Inside make_non_closure, there is no local message. When fn references message, Python finds it in the global scope (the G in LEGB). No closure cell is needed — LOAD_GLOBAL handles this. fn2.__closure__ is None.

The LEGB rule determines where Python looks: Local, then Enclosing, then Global, then Built-in. A closure cell is only created when a variable is found in the Enclosing scope.

Expected Output
fn1(): enclosing
fn2(): global
fn1 has closure: True
fn2 has closure: False
Hints

Hint 1: A closure captures variables from an enclosing function scope. A function that only reads module-level globals does NOT have a closure.

Hint 2: Check `__closure__` — it will be `None` for a function that only accesses globals.


Medium

#5Late Binding Bug in LoopsMedium
late-bindingloop-variableclosure-bugshared-cell

Predict the output of the buggy loop closure. Then verify that all closures share the same cell object by comparing their __closure__ entries.

Python
funcs = []
for i in range(5):
    def f():
        return i
    funcs.append(f)

print("--- Buggy version ---")
for idx in range(5):
    print(f"funcs[{idx}](): {funcs[idx]()}")

print("--- All share same cell? ---")
cells = [f.__closure__[0] for f in funcs]
all_same = all(c is cells[0] for c in cells)
print(f"All closures reference the same cell: {all_same}")
Solution
--- Buggy version ---
funcs[0](): 4
funcs[1](): 4
funcs[2](): 4
funcs[3](): 4
funcs[4](): 4
--- All share same cell? ---
All closures reference the same cell: True

Why all return 4:

The for loop does not create a new scope per iteration. There is one variable i in the enclosing scope, and all five closures share a reference to the same cell object holding i. After the loop completes, i == 4. Every closure looks up i at call time, finding 4.

The key insight: closures capture variables by reference, not by value. The five function objects are distinct (funcs[0] is not funcs[1]), but they all point to the same cell. Calling cells[0] is cells[1] confirms this — it is True for every pair.

This is the most common closure bug in Python and appears frequently in interviews. The fix is to create a snapshot of the loop variable at definition time (see the next problem).

Expected Output
--- Buggy version ---
funcs[0](): 4
funcs[1](): 4
funcs[2](): 4
funcs[3](): 4
funcs[4](): 4
--- All share same cell? ---
All closures reference the same cell: True
Hints

Hint 1: All closures created inside the loop capture the same variable `i` — they hold a reference to the cell, not a snapshot of the value.

Hint 2: After the loop finishes, `i` is 4. When any closure is called, it looks up the current value of `i` in the shared cell.

#6Three Fixes for Late BindingMedium
late-binding-fixdefault-argumentfactory-functionfunctools-partial

Implement all three fixes for the late binding bug. Each approach should produce functions that correctly return 0, 1, 2, 3, 4 respectively.

Python
from functools import partial

# Fix 1: Default argument capture
fix1 = []
for i in range(5):
    def f(i=i):
        return i
    fix1.append(f)

print("--- Fix 1: default argument ---")
print(" ".join(str(f()) for f in fix1))

# Fix 2: Factory function
def make_f(val):
    def f():
        return val
    return f

fix2 = [make_f(i) for i in range(5)]

print("--- Fix 2: factory function ---")
print(" ".join(str(f()) for f in fix2))

# Fix 3: functools.partial
def return_val(val):
    return val

fix3 = [partial(return_val, i) for i in range(5)]

print("--- Fix 3: functools.partial ---")
print(" ".join(str(f()) for f in fix3))
Solution
--- Fix 1: default argument ---
0 1 2 3 4
--- Fix 2: factory function ---
0 1 2 3 4
--- Fix 3: functools.partial ---
0 1 2 3 4

How each fix works:

FixMechanismClosure involved?
def f(i=i)Default argument is evaluated at def-time; i becomes a local parameterNo — i is a local, not a free variable
make_f(val)Each call creates a new scope with its own val cellYes — val is a free variable in a new cell per call
partial(f, i)Binds i as a frozen argument at creation timeNo — value is stored in the partial object, not a cell

Which to prefer:

  • Default argument — simplest for quick fixes, but the extra parameter can confuse readers. Also changes the function's signature.
  • Factory function — most explicit and Pythonic. Clearly communicates intent. Best for production code.
  • functools.partial — cleanest when the function already exists and you just need to bind an argument.
Expected Output
--- Fix 1: default argument ---
0 1 2 3 4
--- Fix 2: factory function ---
0 1 2 3 4
--- Fix 3: functools.partial ---
0 1 2 3 4
Hints

Hint 1: Default argument `def f(i=i):` evaluates `i` at definition time, creating a local parameter with a snapshot value — no closure cell involved.

Hint 2: A factory function `def make_f(val):` creates a new scope per call, each with its own `val` cell. `functools.partial(f, i)` binds `i` immediately.

#7nonlocal for Stateful ClosuresMedium
nonlocalmutationshared-cellstateful-closure

Demonstrate the difference between assignment with and without nonlocal. Then build a counter with both increment and reset functions that share a cell.

Python
# Without nonlocal — assignment creates a new local
def without_nonlocal():
    x = 10
    def inner():
        x = 20  # new local, does NOT touch outer x
        print(f"inner x: {x}")
    inner()
    print(f"outer x: {x}")

print("--- Without nonlocal ---")
without_nonlocal()

# With nonlocal — assignment modifies the enclosing variable
def with_nonlocal():
    x = 10
    def inner():
        nonlocal x
        x = 20  # modifies outer x via shared cell
        print(f"inner x: {x}")
    inner()
    print(f"outer x: {x}")

print("--- With nonlocal ---")
with_nonlocal()

# Practical example: counter with reset
def make_counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
        return count
    def reset():
        nonlocal count
        count = 0
    return increment, reset

print("--- Counter demo ---")
inc, rst = make_counter()
print(inc())
print(inc())
print(inc())
rst()
print(f"After reset: {inc()}")
Solution
--- Without nonlocal ---
inner x: 20
outer x: 10
--- With nonlocal ---
inner x: 20
outer x: 20
--- Counter demo ---
1
2
3
After reset: 1

Without nonlocal:

The line x = 20 inside inner creates a brand-new local variable named x on inner's stack frame. This local x shadows the outer x. After inner() returns, outer's x is still 10 because it was never touched.

With nonlocal:

The nonlocal x declaration tells Python's compiler to emit STORE_DEREF instead of STORE_FAST. This writes the value 20 into the shared cell object that both with_nonlocal and inner reference. After inner() returns, the outer x is now 20.

Counter with shared cell:

Both increment and reset declare nonlocal count, so they share the same cell object. When reset sets count = 0, the cell's contents change to 0, and the next call to increment reads 0 from the cell, increments to 1, and returns 1.

Expected Output
--- Without nonlocal ---
inner x: 20
outer x: 10
--- With nonlocal ---
inner x: 20
outer x: 20
--- Counter demo ---
1
2
3
After reset: 1
Hints

Hint 1: Without `nonlocal`, assignment `x = 20` inside the inner function creates a new local variable, shadowing the outer one.

Hint 2: With `nonlocal x`, the assignment writes through the shared cell object, modifying the enclosing scope variable.

#8Closure Factory: Range ValidatorMedium
function-factoryclosurevalidationencapsulation

Build a make_range_validator factory that returns a validation function. The validator should return the value if in range, or raise ValueError otherwise. Inspect the closure cells to verify captured bounds.

Python
def make_range_validator(min_val, max_val):
    def validate(value):
        if not (min_val <= value <= max_val):
            raise ValueError(
                f"Value {value} outside [{min_val}, {max_val}]"
            )
        return value
    return validate

validate_age = make_range_validator(0, 150)
validate_pct = make_range_validator(0.0, 1.0)
validate_port = make_range_validator(1, 65535)

# Happy paths
print(f"validate_age(25): {validate_age(25)}")
print(f"validate_pct(0.75): {validate_pct(0.75)}")
print(f"validate_port(8080): {validate_port(8080)}")

# Error paths
for name, fn, bad_val in [
    ("validate_age", validate_age, 200),
    ("validate_pct", validate_pct, -0.1),
    ("validate_port", validate_port, 70000),
]:
    try:
        fn(bad_val)
    except ValueError as e:
        print(f"{name}({bad_val}): ERROR - {e}")

# Inspect closure cells
print(f"age free vars: {validate_age.__code__.co_freevars}")
for i, name in enumerate(validate_age.__code__.co_freevars):
    val = validate_age.__closure__[i].cell_contents
    print(f"age {name} captured: {val}")
Solution
validate_age(25): 25
validate_pct(0.75): 0.75
validate_port(8080): 8080
validate_age(200): ERROR - Value 200 outside [0, 150]
validate_pct(-0.1): ERROR - Value -0.1 outside [0.0, 1.0]
validate_port(70000): ERROR - Value 70000 outside [1, 65535]
age free vars: ('max_val', 'min_val')
age min captured: 0
age max captured: 150

How the factory works:

Each call to make_range_validator(min_val, max_val) creates a new scope with its own min_val and max_val cells. The inner validate function captures both as free variables. When the factory returns, these cells are attached to validate.__closure__.

  • validate_age has cells holding 0 and 150.
  • validate_pct has cells holding 0.0 and 1.0.
  • validate_port has cells holding 1 and 65535.

These are completely independent — modifying one validator's behavior (if it were possible) would not affect the others. This is the power of closures as lightweight, stateful objects.

Note on co_freevars ordering: CPython lists free variables in alphabetical order, so you see ('max_val', 'min_val') rather than the parameter order. The cells in __closure__ match this alphabetical ordering.

Expected Output
validate_age(25): 25
validate_pct(0.75): 0.75
validate_port(8080): 8080
validate_age(200): ERROR - Value 200 outside [0, 150]
validate_pct(-0.1): ERROR - Value -0.1 outside [0.0, 1.0]
validate_port(70000): ERROR - Value 70000 outside [1, 65535]
age free vars: ('max_val', 'min_val')
age min captured: 0
age max captured: 150
Hints

Hint 1: A function factory returns a new function customized by its arguments. The returned function captures those arguments as free variables in closure cells.

Hint 2: Each call to the factory creates independent cells. `validate_age` and `validate_port` have completely separate `min_val` and `max_val` values.


Hard

#9Encapsulation: Closure as a Lightweight ObjectHard
encapsulationclosure-vs-classstatefulnonlocaldispatch

Build a bank account using only closures — no classes. Support deposit, withdraw (with overdraft protection), balance, and history operations. Return a dispatch function that routes operations by name.

Python
def make_account(initial_balance=0):
    balance = initial_balance
    history = []

    def deposit(amount):
        nonlocal balance
        balance += amount
        history.append(f"deposit:{amount}")
        return balance

    def withdraw(amount):
        nonlocal balance
        if amount > balance:
            return None  # overdraft rejected
        balance -= amount
        history.append(f"withdraw:{amount}")
        return balance

    def get_balance():
        return balance

    def get_history():
        return list(history)  # return a copy

    ops = {
        "deposit": deposit,
        "withdraw": withdraw,
        "balance": get_balance,
        "history": get_history,
    }

    def dispatch(operation, *args):
        if operation not in ops:
            raise ValueError(f"Unknown operation: {operation}")
        return ops[operation](*args)

    return dispatch

# Usage
account = make_account()

print(f"balance: {account('deposit', 100)}")
print(f"balance: {account('deposit', 150)}")
print(f"balance: {account('withdraw', 50)}")
print(f"history: {account('history')}")

result = account("withdraw", 500)
print(f"Overdraft rejected: {result is None}")
print(f"balance after rejected: {account('balance')}")
Solution
balance: 100
balance: 250
balance: 200
history: ['deposit:100', 'deposit:150', 'withdraw:50']
Overdraft rejected: True
balance after rejected: 200

Closure-based encapsulation:

This pattern uses closures to achieve what a class would normally provide:

Class conceptClosure equivalent
self.balancebalance variable in enclosing scope
self.historyhistory list in enclosing scope
def deposit(self, amount)deposit inner function with nonlocal balance
account.deposit(100)account("deposit", 100) via dispatch

Key details:

  • balance requires nonlocal because deposit and withdraw reassign it (balance += amount is reassignment).
  • history does NOT require nonlocal because .append() mutates the existing list without reassigning the variable itself.
  • get_history returns list(history) — a copy — to prevent external code from mutating the internal history.
  • The dispatch dict ops is itself a closure variable, capturing all four inner functions.

When to use this pattern: When you need 1-3 operations on 1-2 pieces of state and a full class feels like overkill. Decorators, event handlers, and configuration builders are natural fits.

Expected Output
balance: 100
balance: 250
balance: 200
history: ['deposit:100', 'deposit:150', 'withdraw:50']
Overdraft rejected: True
balance after rejected: 200
Hints

Hint 1: Use a dict or multiple inner functions to expose different operations. A dispatch function that accepts an operation name is a clean pattern.

Hint 2: All inner functions share the same enclosing scope. Use `nonlocal` to mutate the balance. The history list is mutable, so you can append without `nonlocal`.

#10Closure-Based Memoization CacheHard
memoizationclosure-cachedecoratorperformance

Build a memoize decorator using closures. The decorator should cache results, expose the cache for inspection, and track how many times the original function was actually called (cache misses).

Python
def memoize(func):
    cache = {}
    call_count = [0]  # mutable container to track actual calls

    def wrapper(*args):
        if args not in cache:
            call_count[0] += 1
            cache[args] = func(*args)
        return cache[args]

    wrapper.cache = cache
    wrapper.call_count = call_count
    return wrapper

@memoize
def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)

# Compute fibonacci values
print(f"fib(10): {fib(10)}")
print(f"fib(30): {fib(30)}")
print(f"Call count: {fib.call_count[0]}")
print(f"Cache size: {len(fib.cache)}")
print(f"Cache contains fib(10): {(10,) in fib.cache}")
print(f"fib(10) from cache: {fib(10)}")
Solution
fib(10): 55
fib(30): 832040
Call count: 31
Cache size: 31
Cache contains fib(10): True
fib(10) from cache: 55

How the closure-based memoization works:

  1. memoize(fib) creates a new scope with cache = {} and call_count = [0].
  2. wrapper captures both as free variables in its closure cells.
  3. On each call, wrapper checks if the arguments are already in cache. If yes, return the cached result (cache hit). If no, call the original function, store the result, increment the counter (cache miss).
  4. wrapper.cache and wrapper.call_count expose the closure internals for debugging.

Why call count is 31 (not 31+):

  • fib(10) computes fib(0) through fib(10) — 11 unique calls.
  • fib(30) needs fib(11) through fib(30) — 20 more unique calls (0-10 are already cached).
  • Total: 31 unique function invocations. The second fib(10) call is a pure cache hit.

Why call_count uses a list: We use call_count = [0] instead of call_count = 0 to avoid needing nonlocal. Mutating call_count[0] += 1 modifies the list contents without reassigning the variable itself. Using nonlocal call_count with a plain integer would also work.

This is exactly how functools.lru_cache works conceptually, minus the LRU eviction policy.

Expected Output
fib(10): 55
fib(30): 832040
Call count: 31
Cache size: 31
Cache contains fib(10): True
fib(10) from cache: 55
Hints

Hint 1: The cache dict lives in the closure of the wrapper function. Each decorated function gets its own independent cache.

Hint 2: Track call count using a mutable container (list with one element) or `nonlocal` to count actual function invocations vs cache hits.

#11Closure Memory and LifetimeHard
memoryclosure-lifetimeweakrefgarbage-collectioncell-objects

Demonstrate that closures keep captured objects alive and that deleting the closure allows garbage collection. Then show that when two closures share a cell, the captured object stays alive until ALL closures referencing it are deleted.

Python
import weakref
import gc

class HeavyData:
    """A class we can weak-reference to track GC."""
    def __init__(self, size):
        self.items = list(range(size))
    def __len__(self):
        return len(self.items)

# --- Closure keeps data alive ---
print("--- Closure keeps data alive ---")

def make_accessor(data):
    def get_size():
        return len(data)
    return get_size

obj = HeavyData(1000)
weak = weakref.ref(obj)

accessor = make_accessor(obj)
del obj  # remove the direct reference
gc.collect()

print(f"Data alive while closure exists: {weak() is not None}")
print(f"Data size via closure: {accessor()}")

del accessor  # remove the closure
gc.collect()

print(f"After deleting closure, data collected: {weak() is None}")

# --- Shared cells between closures ---
print("--- Shared cells between closures ---")

def make_dual_accessor(data):
    def get_item(i):
        return data.items[i]
    def get_length():
        return len(data)
    return get_item, get_length

obj2 = HeavyData(1000)
weak2 = weakref.ref(obj2)

get_item, get_length = make_dual_accessor(obj2)
del obj2
gc.collect()

print(f"get_item(0): {get_item(0)}")
print(f"get_length(): {get_length()}")

del get_item
gc.collect()
print(f"Deleted get_item, get_length still works: {get_length() == 1000}")
print(f"Data still alive (held by get_length): {weak2() is not None}")

del get_length
gc.collect()
print(f"Deleted get_length too, data collected: {weak2() is None}")
Solution
--- Closure keeps data alive ---
Data alive while closure exists: True
Data size via closure: 1000
After deleting closure, data collected: True
--- Shared cells between closures ---
get_item(0): 0
get_length(): 1000
Deleted get_item, get_length still works: True
Data still alive (held by get_length): True
Deleted get_length too, data collected: True

Closure lifetime and garbage collection:

  1. Single closure: make_accessor(obj) captures data (which points to our HeavyData object) in a closure cell. After del obj, the only remaining reference to the HeavyData instance is inside the closure cell. The weak reference confirms the object is still alive. Only when we del accessor (destroying the closure and its cells) can the HeavyData be garbage collected.

  2. Shared cell between two closures: make_dual_accessor returns two closures that share the same cell for data. After del obj2, the object survives because both closures hold it via the shared cell. After del get_item, one closure is gone but get_length still holds the cell — the object survives. Only after del get_length removes the last reference to the cell is the HeavyData collected.

Production implications:

  • Closures stored in long-lived structures (event registries, global caches, class attributes) keep their captured objects alive indefinitely.
  • If a closure captures a large object (a dataset, a database connection, a loaded ML model), that object cannot be freed until every closure referencing its cell is deleted.
  • Mitigation strategies: capture only the specific fields you need (not the whole object), use weakref.ref for objects that should not be kept alive by the closure alone, and explicitly remove closures from registries when done.
Expected Output
--- Closure keeps data alive ---
Data alive while closure exists: True
Data size via closure: 1000
After deleting closure, data collected: True
--- Shared cells between closures ---
get_item(0): 0
get_length(): 1000
Deleted get_item, get_length still works: True
Data still alive (held by get_length): True
Deleted get_length too, data collected: True
Hints

Hint 1: A closure holds a reference to its cell objects. As long as any closure referencing a cell is alive, the value in that cell cannot be garbage collected.

Hint 2: Use `weakref.ref` to observe when the underlying object is actually collected. When the weak reference returns `None`, the object has been garbage collected.

© 2026 EngineersOfAI. All rights reserved.