Previous | Tutorial index | Next

Tutorial 9: Closures – Functions That Remember Their Enclosing Scope

Learning Objectives

Overview

In Python, functions are first‑class citizens – they can be assigned to variables, passed as arguments, and returned from other functions. A closure is a powerful concept that arises when an inner (nested) function remembers and continues to have access to variables from its enclosing (outer) function, even after that outer function has finished executing. This allows functions to “carry along” some state without using global variables. Closures are the foundation for decorators and are also used in functional programming, callbacks, and factory functions. In this tutorial, you will learn how closures work, how to create them, and how to use nonlocal to modify captured variables.

1. What Is a Closure?

A closure occurs when:

  1. There is a nested function (a function defined inside another function).
  2. The nested function references variables that are defined in the enclosing (outer) function.
  3. The outer function returns the nested function (or otherwise makes it available outside).

When you call the outer function, it returns the inner function, which “closes over” (captures) the variables it needs from the outer function’s scope. Those variables remain alive even after the outer function has returned.

1.1 Simple Example

def make_multiplier(factor): def multiply(x): return x * factor return multiply double = make_multiplier(2) triple = make_multiplier(3) print(double(10)) # 20 print(triple(10)) # 30

2. How Closures Work – The Mechanics

When Python compiles a nested function that references a variable from an outer scope, it creates a closure cell for that variable. The variable is stored in a special cell object that lives on the heap, not on the stack. This cell is shared between the outer and inner functions. Even after the outer function exits, the cell persists because the inner function holds a reference to it.

You can inspect a closure using the __closure__ attribute of the inner function:

def make_adder(n): def add(x): return x + n return add add5 = make_adder(5) print(add5.__closure__) # (<cell at 0x...: int object at 0x...>,) print(add5.__closure__[0].cell_contents) # 5

Each cell holds the captured value.

3. Lexical Scoping and the LEGB Rule

Python’s scope resolution follows the LEGB rule:

A closure is possible because the inner function can access variables in the enclosing scope (the “E” in LEGB). This access is resolved at runtime through the closure cells.

4. The nonlocal Keyword – Modifying Captured Variables

By default, if you try to assign to a variable from an enclosing scope, Python treats it as a new local variable in the inner function, thus breaking the closure. To modify a captured variable, you must declare it as nonlocal.

def counter(): count = 0 def increment(): nonlocal count # Without this, count would be local to increment count += 1 return count return increment c = counter() print(c()) # 1 print(c()) # 2

Without nonlocal, count += 1 would create a new local variable count inside increment (and raise UnboundLocalError when trying to read before assignment, because count is not defined locally). nonlocal tells Python to use the variable from the nearest enclosing scope that is not global.

nonlocal can also be used to modify variables in any enclosing (non‑global) scope, even multiple levels up.

5. Practical Use Cases for Closures

5.1 Factory Functions

As shown, you can create functions that are parameterized with some configuration:

def make_adder(n): return lambda x: x + n # also a closure using lambda add10 = make_adder(10) print(add10(5)) # 15

5.2 Maintaining Private State

Closures can encapsulate state without exposing it as global variables, similar to objects with private attributes:

def bank_account(initial_balance=0): balance = initial_balance def deposit(amount): nonlocal balance balance += amount return balance def withdraw(amount): nonlocal balance if amount > balance: raise ValueError("Insufficient funds") balance -= amount return balance def get_balance(): return balance return deposit, withdraw, get_balance dep, wit, bal = bank_account(100) print(dep(50)) # 150 print(wit(30)) # 120 print(bal()) # 120

Here, balance is not accessible from outside, providing data hiding.

5.3 Callbacks and Event Handlers

Closures are often used to store extra data for callbacks:

def make_button_handler(message): def handler(): print(f"Button clicked: {message}") return handler button_handlers = { 'save': make_button_handler("Save clicked"), 'delete': make_button_handler("Delete clicked"), } # Later, call handler['save']()

5.4 Function Composition and Pipelines

Closures can be used to build pipelines where each step remembers its parameters:

def compose(f, g): def h(x): return f(g(x)) return h def add1(x): return x + 1 def square(x): return x * x add1_then_square = compose(square, add1) print(add1_then_square(3)) # 16 ( (3+1)^2 = 16 )

5.5 Parameterized Function Factories

Useful when you need many functions that differ by a parameter:

def make_power(exponent): def power(base): return base ** exponent return power square = make_power(2) cube = make_power(3) print(square(5)) # 25 print(cube(5)) # 125

6. Closures vs. Classes

Closures and classes can both encapsulate state. Use closures when:

Use classes when you need many methods, inheritance, or more structure.

7. Common Pitfalls and Best Practices

def create_multipliers(): multipliers = [] for i in range(3): multipliers.append(lambda x: x * i) return multipliers for m in create_multipliers(): print(m(2)) # Output: 4, 4, 4 (all use i=2)

Fix: Bind the current value by creating a default argument:

def create_multipliers(): multipliers = [] for i in range(3): multipliers.append(lambda x, i=i: x * i) # i=i captures the value return multipliers # Now: 0, 2, 4

📝 Quiz – Check Your Understanding

  1. What is a closure?

    Answer(B) A function that references variables from its enclosing scope and is returned.
  2. Which keyword must you use to modify a variable from an enclosing scope?

    Answer(B) `nonlocal`
  3. Given the following code, what is the output?

    def outer(): x = 10 def inner(): return x return inner f = outer() print(f())
    Answer(A) `10`
  4. True or False: A closure can capture variables from multiple enclosing scopes.

    AnswerTrue
  5. What is the purpose of __closure__ attribute?

    Answer(B) To store the captured variables and their values.
  6. What will be the output of this code?

    def make_inc(): count = 0 def inc(): count += 1 return count return inc f = make_inc() print(f())
    Answer(C) `UnboundLocalError` – because `count += 1` makes `count` local without `nonlocal`.
  7. How can you fix the code in question 6?

    Answer(B) Use `nonlocal count`
  8. What is a common use of closures in GUI programming?

    Answer(B) As callbacks that remember state.
  9. Given def make_adder(n): return lambda x: x + n, what does make_adder(5)(10) return?

    Answer(B) `15`
  10. What is the late binding trap in closures?

    Answer(A) Captured variables are evaluated late, causing shared values.

💻 Exercises – Practice Makes Perfect

Exercise 1: Simple Closure
Write a function make_counter(initial) that returns a closure that increments and returns the counter.

Sample Solution ```python def make_counter(initial): count = initial def counter(): nonlocal count count += 1 return count return counter

c = make_counter(5) print(c()) # 6 print(c()) # 7

</details> **Exercise 2: Parameterized Greeter** Write a function `greeter(greeting)` that returns a function that takes a name and returns a full greeting. <details><summary>Sample Solution</summary> ```python def greeter(greeting): def greet(name): return f"{greeting}, {name}!" return greet hello = greeter("Hello") print(hello("Alice")) # Hello, Alice!

Exercise 3: Caching with Closures
Write a closure that caches results of slow_square(x) (use time.sleep(1) to simulate slowness).

Sample Solution ```python import time def cached_square(): cache = {} def square(x): if x not in cache: time.sleep(1) cache[x] = x*x return cache[x] return square

sq = cached_square() print(sq(5)) # takes 1s print(sq(5)) # instant

</details> **Exercise 4: Multi‑level Closure** Write a function `outer(x)` that returns a function `middle(y)` that returns a function `inner(z)` that returns `x + y + z`. <details><summary>Sample Solution</summary> ```python def outer(x): def middle(y): def inner(z): return x + y + z return inner return middle result = outer(1)(2)(3) print(result) # 6

Exercise 5: Closure with nonlocal
Write a function average_maker() that returns a closure that takes a number and returns the running average.

Sample Solution ```python def average_maker(): total = 0 count = 0 def avg(num): nonlocal total, count total += num count += 1 return total / count return avg

avg = average_maker() print(avg(10)) # 10.0 print(avg(20)) # 15.0 print(avg(30)) # 20.0

</details> --- ### 🏠 Homework – Deeper Thinking #### Short Answer Questions **1. Memoization with Closure** Write a closure `memoize(func)` that caches results. Test with a slow Fibonacci function. <details><summary>Sample Answer</summary> ```python def memoize(func): cache = {} def wrapper(arg): if arg not in cache: cache[arg] = func(arg) return cache[arg] return wrapper def fib(n): if n < 2: return n return fib(n-1) + fib(n-2) fib = memoize(fib) print(fib(40)) # fast!

2. Function Registry
Create a closure that maintains a registry of functions (register and get).

Sample Answer ```python def registry_maker(): reg = {} def register(name, func): reg[name] = func def get(name): return reg.get(name) return register, get

register, get = registry_maker() register("add", lambda a,b: a+b) print(get("add")(3,4)) # 7

</details> **3. Partial Application using Closures** Implement `partial(func, *args)` that returns a closure that partially applies arguments. <details><summary>Sample Answer</summary> ```python def partial(func, *args): def wrapper(*more_args, **kwargs): return func(*(args + more_args), **kwargs) return wrapper def add(a, b, c): return a + b + c add5 = partial(add, 5) add5_plus = add5(10) print(add5_plus(3)) # 18

Essay Questions

4. State Machine using Closures
Write a function make_state_machine(initial_state) that returns transition(event) and get_state() functions.

Sample Answer ```python def make_state_machine(initial_state): state = initial_state transitions = { ('idle', 'start'): 'running', ('running', 'stop'): 'idle', ('running', 'pause'): 'paused', ('paused', 'resume'): 'running', } def transition(event): nonlocal state key = (state, event) if key in transitions: state = transitions[key] def get_state(): return state return transition, get_state

trans, get = make_state_machine('idle') trans('start') print(get()) # running trans('pause') print(get()) # paused

</details> **5. Closure with Custom Iterator** Write a closure that returns a function that yields the next value each call (like a counter). Compare to a generator. <details><summary>Sample Answer</summary> ```python def make_counter(initial=0): count = initial def next_value(): nonlocal count result = count count += 1 return result return next_value counter = make_counter(5) print(counter()) # 5 print(counter()) # 6 # Generator version: def gen_counter(initial=0): count = initial while True: yield count count += 1 g = gen_counter(5) print(next(g)) # 5 print(next(g)) # 6

Homework Hints

def registry_maker(): reg = {} def register(name, func): reg[name] = func def get(name): return reg.get(name) return register, get

Summary

In this tutorial, you have learned:

Closures are a fundamental building block for advanced Python techniques, particularly decorators, which we will cover in the next tutorial (Part 2). Mastering closures will enable you to write more flexible, maintainable, and expressive code.

Next Steps: In Tutorial 10, we will dive into Decorators – functions that modify or enhance other functions using closures.

Happy closing!

Previous | Tutorial index | Next