Cuando defines una función dentro de otra, Python no solo crea la función interna — la conecta al scope léxico en el que nació. Si esa función interna referencia variables del scope externo, Python crea un closure: un objeto función que lleva consigo una captura viva de esas variables, incluso después de que la función externa haya terminado de ejecutarse.
El mecanismo concreto: cada función tiene un atributo __closure__ que, cuando no es None, contiene una tupla de celdas (cell objects). Cada celda envuelve una variable capturada. No es una copia del valor en el momento de la creación — es una referencia a la celda, que a su vez apunta al objeto actual. Esa distinción importa muchísimo y es la raíz del bug más famoso de los closures.
¿Cuándo usarlos? Cuando necesitas una función configurable en tiempo de construcción sin el overhead de una clase. Una factory que devuelve funciones especializadas es el caso canónico. Si el estado crece en complejidad — múltiples variables, métodos que lo modifican, necesidad de introspección — una clase es más honesta. El closure empieza a doler cuando alguien tiene que debuggearlo seis meses después y descubre que el estado vive en celdas opacas en lugar de atributos con nombre.
El error que rompe a la gente la primera vez: escribir [lambda: i for i in range(5)] esperando cinco lambdas que devuelvan 0, 1, 2, 3, 4. Todas devuelven 4. La razón es exactamente lo que acabamos de decir — no capturan el valor de i en cada iteración, capturan la celda que contiene i. Cuando el bucle termina, esa celda contiene 4, y todas las lambdas leen de la misma celda. La solución clásica es lambda i=i: i: el argumento default se evalúa en el momento de la definición, fijando el valor actual como un valor en el frame de la función, fuera del mecanismo de closure.
from typing import Callable
# ── Factory de multiplicadores ─────────────────────────────────────────
def make_multiplier(factor: float) -> Callable[[float], float]:
"""Devuelve una función que multiplica su argumento por `factor`."""
def multiply(value: float) -> float:
return value * factor # `factor` vive en __closure__, no en locals()
return multiply
# ── Inspección del closure ─────────────────────────────────────────────
double = make_multiplier(2.0)
triple = make_multiplier(3.0)
print(double.__closure__) # (<cell at 0x...>,)
print(double.__closure__[0].cell_contents) # 2.0
print(triple.__closure__[0].cell_contents) # 3.0
print(double(10)) # 20.0
print(triple(10)) # 30.0
# Cada llamada a make_multiplier crea un frame nuevo → celda nueva → closure distinto.
assert double.__closure__[0] is not triple.__closure__[0]
# ── El bug del closure en bucle ────────────────────────────────────────
buggy = [lambda: i for i in range(5)]
print([f() for f in buggy]) # [4, 4, 4, 4, 4] ← todas leen la misma celda
fixed = [lambda i=i: i for i in range(5)]
print([f() for f in fixed]) # [0, 1, 2, 3, 4] ← valor capturado en default
# ── Closure con estado mutable: contador ──────────────────────────────
def make_counter(start: int = 0) -> tuple[Callable[[], int], Callable[[], None]]:
"""
Devuelve (get, increment).
Usamos una lista porque los closures en Python 3 pueden leer variables
del enclosing scope pero no reasignarlas sin `nonlocal`.
Con `nonlocal` el código es más claro; aquí mostramos ambas opciones.
"""
count = start
def get() -> int:
return count
def increment() -> None:
nonlocal count # sin esto, `count += 1` lanzaría UnboundLocalError
count += 1
return get, increment
get, inc = make_counter(10)
inc()
inc()
print(get()) # 12
Desglose del código
La factory make_multiplier es el closure en su forma más limpia. Cada invocación crea un nuevo frame de ejecución con su propio factor. Cuando make_multiplier retorna, ese frame desaparece, pero la celda que contiene factor sobrevive porque multiply la referencia. Por eso double y triple son funciones independientes con su propio estado — verificado con el assert que confirma que sus celdas son objetos distintos en memoria.
La comparación buggy vs fixed ilustra la diferencia entre captura por referencia a celda (comportamiento por defecto) y captura por valor (argumento default). El argumento default i=i no es un truco oscuro — es usar el mecanismo de evaluación de defaults de Python, que ocurre en el momento de la definición de la función, para fijar el valor. En producción, si necesitas esto en cantidad, considera functools.partial o una función auxiliar nombrada; las lambdas con defaults empiezan a ser difíciles de leer rápido.
El contador muestra cuándo un closure empieza a tensionarse. Necesitamos nonlocal para poder reasignar count; sin él, Python trataría count como variable local de increment y el count += 1 fallaría en el acceso de lectura previo a la asignación. Si el contador necesitara también un método reset, o serialización, o logging, ya estarías construyendo una clase a mano con peor ergonomía.
Errores que debes conocer
Error: captura tardía en bucle — todas las funciones leen la variable de iteración al momento de la llamada, no al momento de la creación.
# ❌ Wrong handlers = [lambda: i * 2 for i in range(4)] print([h() for h in handlers]) # [6, 6, 6, 6] # ✅ Right handlers = [lambda i=i: i * 2 for i in range(4)] print([h() for h in handlers]) # [0, 2, 4, 6]
El default fija el valor de i en cada iteración porque los argumentos default se evalúan cuando se define la función, no cuando se llama.
Error: intentar reasignar una variable del enclosing scope sin nonlocal, lo que hace que Python la trate como local y lance UnboundLocalError.
# ❌ Wrong
def make_toggle():
state = False
def toggle():
state = not state # Python ve asignación → local → lee antes de escribir → error
return state
return toggle
# ✅ Right
def make_toggle():
state = False
def toggle():
nonlocal state
state = not state
return state
return toggle
nonlocal le indica al compilador que state no es una variable local de toggle, sino que debe buscarse en el enclosing scope más cercano donde exista.
Error: asumir que el closure aísla el estado cuando captura un objeto mutable — el closure guarda la referencia, no una copia del objeto.
# ❌ Wrong — sorpresa al mutar la lista externa después de crear el closure
def make_summer(values: list) -> Callable[[], int]:
return lambda: sum(values)
data = [1, 2, 3]
total = make_summer(data)
data.append(100)
print(total()) # 106, no 6 — el closure ve la lista modificada
# ✅ Right — capturar una copia si quieres semántica de snapshot
def make_summer(values: list) -> Callable[[], int]:
snapshot = list(values)
return lambda: sum(snapshot)
La celda apunta al objeto lista, no a su contenido en el momento de la creación; si necesitas un snapshot, tienes que crearlo explícitamente.
N° 62