First-Class Functions, Closures & Late Binding

Functions in Python are first-class objects: they can be passed as arguments, returned from other functions, assigned to variables, and stored in data structures. When an inner function references variables from an outer enclosing function, CPython creates a Closure using PyCellObject instances.

This chapter details CPython closure mechanics, PyCellObject memory pointers, the famous late-binding closure trap in loops, and function introspection via __closure__.


1. Closure Architecture & PyCellObject

A closure is a function object that retains access to variables from its lexical enclosing scope even after the outer function has finished executing and exited the stack.

CPython implements closures by wrapping captured free variables in heap-allocated Cell Objects (PyCellObject):

CPython Closure Memory Layout:

[ Outer Function Scope (def make_multiplier(factor)) ]
                         |
                         v Allocates PyCellObject on Heap
               [ PyCellObject @ 0x4A10 ]
               └── ob_ref: Pointer to factor (e.g. 5)
                         ^
                         |
[ Inner Function Scope (def multiply(x)) ]
├── func_closure: ( <PyCellObject @ 0x4A10>, )
└── Executed via LOAD_DEREF opcode

Because both the outer function and the inner closure point to the same PyCellObject on the heap, modifications to the cell variable are visible to both scopes.


2. Inspecting Closures (__closure__ & cell_contents)

You can inspect a function’s captured closure variables at runtime using the __closure__ attribute:

def make_counter(start=0):
    count = start

    def counter():
        nonlocal count
        count += 1
        return count

    return counter

c = make_counter(10)

# Inspect the closure cell
print(c.__closure__)                # (<cell at 0x7f9a...: int object at 0x...>,)
print(c.__closure__[0].cell_contents) # 10 (Current value inside the cell!)

c()  # Increment count
print(c.__closure__[0].cell_contents) # 11

3. The Late Binding Closure Trap in Loops

The most common closure bug in Python occurs when creating closures inside loops:

# THE TRAP: Functions bind to the VARIABLE, not the VALUE at creation time!
def create_multipliers():
    return [lambda x: x * i for i in range(4)]

multipliers = create_multipliers()
print([m(2) for m in multipliers])  # EXPECTED: [0, 2, 4, 6] -> ACTUAL: [6, 6, 6, 6]!

Why it Happens (Late Binding):

Python closures are late binding—inner functions look up the value of captured variables when the inner function is called, not when it is defined. When m(2) is invoked later, the loop has already completed, and i inside the shared PyCellObject equals 3. Every closure in the list reads i = 3!

Production Fixes:

  1. Default Argument Binding: Bind i as a default parameter at definition time (lambda x, i=i: x * i).
  2. functools.partial: Use partial(multiplier_func, i).

4. Production Trade-offs & Memory Retain Cycles

  • Cell Overhead: Each captured closure variable allocates a PyCellObject on the heap. Avoid capturing giant objects or entire self instances inside long-lived closures if only a primitive string or int is needed.
  • Garbage Collection: If a closure captures a reference to a container object that also references the closure function, a circular reference is created, requiring the Generational Cyclic GC to clean it up.
Display Options
Appearance
Text Size
100%