`yield`: Ejecución pausada y estado preservado en generadores

Cuando escribes yield dentro de una función, Python no trata esa función como una función normal. En el momento en que el intérprete ve yield en el cuerpo, convierte la función entera en una generator function: un callable que, al invocarse, no ejecuta ni una sola línea del cuerpo sino que devuelve un objeto generador listo para ser conducido desde fuera.

Eso rompe la intuición habitual. fibonacci() no corre fibonacci(); crea una máquina de estados suspendida. Cada llamada a next() sobre esa máquina reanuda la ejecución exactamente donde se quedó, corre hasta el siguiente yield, entrega el valor y vuelve a congelarse. Todo el estado local —variables locales, argumentos, el instruction pointer que marca en qué línea del bytecode estamos— vive dentro del objeto generador entre pausas. No hay copia, no hay reconstrucción: es el mismo frame de Python, suspendido en memoria.

¿Por qué funciona así y no como una función que devuelve una lista? Porque el modelo de ejecución de CPython representa cada llamada activa como un PyFrameObject. Normalmente ese frame se destruye al hacer return. Con un generador, el frame se desvincula del call stack pero no se destruye: el objeto generador mantiene una referencia fuerte a él. next() vuelve a conectar ese frame al hilo de ejecución y lo reanuda.

Esto importa cuando necesitas iteración perezosa: secuencias potencialmente infinitas, pipelines de procesamiento de datos, o cualquier cosa donde materializar todos los elementos a la vez sería prohibitivo en memoria. Lo que rompe si te equivocas es sutil: pensar que llamar la función empieza algo, asumir que el estado se resetea en cada iteración, o ignorar el ciclo de vida del StopIteration.

Cuando la función llega a un return (explícito o al caer del final del cuerpo), Python lanza StopIteration automáticamente. Si escribes return valor, ese valor no se pierde: queda en StopIteration.value, accesible si capturas la excepción directamente o si usas el generador como subgenerador con yield from.

from typing import Generator


def chunked_range(start: int, stop: int, chunk: int) -> Generator[list[int], None, int]:
    """Yield chunks of integers; return total count when exhausted."""
    total = 0
    current = start

    while current < stop:
        # Build only as many items as needed right now
        end = min(current + chunk, stop)
        batch = list(range(current, end))
        total += len(batch)
        current = end
        yield batch  # Execution freezes here; caller gets `batch`

    return total  # Becomes StopIteration.value


def pipeline(gen: Generator) -> None:
    total_items = 0
    try:
        while True:
            batch = next(gen)
            total_items += len(batch)
            print(f"chunk recibido: {batch}")
    except StopIteration as exc:
        # exc.value carries the return value from inside the generator
        reported = exc.value
        print(f"generador agotado — reportó {reported}, contamos {total_items}")


gen = chunked_range(0, 11, 4)

# The frame hasn't run yet; gen just wraps a frozen PyFrameObject
print(type(gen))          # <class 'generator'>
print(gen.gi_frame.f_lineno)  # Line of the 'while', not yet executed

pipeline(gen)

Qué ocurre realmente línea a línea

chunked_range(0, 11, 4) retorna instantáneamente con un objeto generador. El cuerpo —incluyendo la inicialización de total y current— no ha corrido todavía. Eso es crucial: si hubiera un print al principio de la función, no lo verías hasta el primer next().

La primera llamada a next(gen) dentro de pipeline reanuda el frame desde el inicio, inicializa las variables, entra al while, construye [0, 1, 2, 3] y llega al yield batch. Ahí CPython saca el frame del call stack, almacena el valor yielded y devuelve el control a pipeline con batch = [0, 1, 2, 3]. Las variables total, current, batch viven en el frame congelado.

La segunda llamada a next() reanuda justo después del yield, en la línea current = end. No reinicia nada. total ya vale 4, current ya vale 4. El generador sigue como si nunca hubiera parado.

Cuando current llega a 11 y el while termina, la función ejecuta return total. Python construye un StopIteration con value=11 y lo lanza. El except StopIteration as exc en pipeline lo captura y exc.value tiene el 11 que reportó el generador. Si no capturas la excepción y dejas que se propague, Python simplemente detiene el for loop —que es exactamente lo que hace el protocolo de iteración por defecto.

Un detalle que se ignora frecuentemente: gi_frame es None una vez que el generador se agota. Intentar inspeccionarlo después produce AttributeError en f_lineno. El frame fue liberado junto con todo su estado local —esa memoria ya no está.

Errores que debes conocer

Error: Llamar la generator function y asumir que el cuerpo ya corrió, olvidando que se necesita al menos un next().

# ❌ Wrong
def setup_and_yield(path: str):
    print(f"abriendo {path}")  # Developer expects this to run immediately
    yield open(path).read()

task = setup_and_yield("data.txt")
# Nothing printed yet — the developer thinks the file is already open
do_something_else()  # Meanwhile the file hasn't been touched

# ✅ Right
task = setup_and_yield("data.txt")
content = next(task)  # NOW the file opens and the print fires

El frame del generador no corre hasta el primer next(); cualquier efecto secundario de inicialización que pongas antes del primer yield se demora exactamente hasta ese momento.


Error: Iterar el mismo objeto generador dos veces esperando que se reinicie.

# ❌ Wrong
gen = chunked_range(0, 8, 3)
first_pass  = list(gen)   # Exhausts the generator
second_pass = list(gen)   # [] — StopIteration fires immediately

# ✅ Right
# Create a new generator object each time you need a fresh iteration
second_pass = list(chunked_range(0, 8, 3))

Un objeto generador es stateful y de un solo uso: una vez agotado, gi_frame es None y cada next() lanza StopIteration de inmediato. La generator function es el factory; el objeto generador es la instancia.


Error: Perder el return value del generador al iterarlo con for o list().

# ❌ Wrong — return value silently discarded
for batch in chunked_range(0, 11, 4):
    process(batch)
# total is gone; StopIteration.value never surfaced

# ✅ Right — drive it manually when you need the return value
gen = chunked_range(0, 11, 4)
try:
    while True:
        process(next(gen))
except StopIteration as exc:
    total = exc.value  # 11

El protocolo for captura StopIteration internamente y descarta el value; si necesitas ese dato, tienes que conducir el generador con next() explícito o componerlo con yield from en otro generador que lo propague.

109

Dejar un comentario

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

Scroll al inicio