Closures: funciones que recuerdan su entorno léxico

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.

62

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio