`yield from`: delegación transparente a sub-generadores

Cuando compones generadores, la solución ingenua es iterar sobre el sub-generador y reenviar cada valor con yield. Funciona para el caso simple, pero en cuanto alguien llama a .send(), .throw() o .close() sobre el generador externo, esos mensajes se pierden: el bucle for no tiene forma de reenviarlos hacia adentro. yield from existe precisamente para resolver ese problema.

yield from no es azúcar sintáctica del bucle for. Es un protocolo completo de delegación bidireccional: los valores fluyen hacia afuera, pero también los mensajes del consumidor fluyen hacia adentro, atravesando el generador delegante como si no existiera. El generador que contiene el yield from se convierte en un intermediario transparente —o en la jerga del PEP 380, el delegating generator— y el generador al que delega es el subgenerator.

Hay un tercer actor poco visible: el valor que el subgenerador retorna con return (en un generador, eso es la excepción StopIteration.value) se convierte en el resultado de la expresión yield from en el delegante. Eso significa que puedes escribir result = yield from sub() y recuperar datos del subgenerador una vez que termina, algo imposible con un bucle for.

¿Cuándo lo usas? Siempre que estés construyendo pipelines de generadores que deben ser transparentes al protocolo de generadores: pipelines de procesamiento de streams, corutinas que componen trabajo asíncrono a mano, o cuando necesitas que close() propague correctamente para liberar recursos dentro del subgenerador.

Lo que se rompe si lo ignoras: si construyes delegación manual con for x in sub: yield x, las llamadas a .throw(exc) sobre el generador externo no llegan al subgenerador —se procesan en el delegante o lanzan la excepción ahí mismo. El subgenerador no tiene oportunidad de manejarla, y los bloques finally dentro de él pueden no ejecutarse correctamente cuando se llama a .close().

from typing import Generator
from contextlib import contextmanager


# Subgenerador que acumula valores y retorna su suma al terminar.
# El return en un generador se convierte en StopIteration.value.
def accumulate(label: str) -> Generator[str, float, float]:
    total = 0.0
    count = 0
    while True:
        try:
            value = yield f"[{label}] total={total:.2f}"
        except GeneratorExit:
            # close() llega aquí gracias a yield from; podemos limpiar.
            print(f"[{label}] cerrando, total final={total:.2f}")
            return total
        if value is None:
            # send(None) equivale al primer next(); no contabilizamos.
            continue
        total += value
        count += 1


# Generador delegante: orquesta dos subgeneradores en secuencia.
# El resultado de cada yield from es el valor de return del subgenerador.
def pipeline() -> Generator[str, float, dict]:
    result_a = yield from accumulate("A")  # delega hasta que A termina
    result_b = yield from accumulate("B")  # luego delega a B
    # Este return se convierte en el .value de StopIteration del pipeline.
    return {"A": result_a, "B": result_b}


def run_pipeline() -> None:
    gen = pipeline()

    # Avanzamos hasta el primer yield (protocolo estándar: send None primero).
    status = gen.send(None)
    print(status)

    # Enviamos valores al pipeline; yield from los reenvía al subgenerador activo.
    for value in [10.0, 20.0, 5.0]:
        status = gen.send(value)
        print(status)

    # Lanzamos una excepción al pipeline; yield from la reenvía a accumulate("A").
    try:
        status = gen.throw(ValueError, "valor inválido detectado")
        print(status)
    except ValueError as exc:
        # Si el subgenerador no maneja ValueError, sube hasta aquí.
        print(f"excepción no manejada en subgenerador: {exc}")
        # El generador está agotado tras una excepción no manejada; recreamos.
        gen = pipeline()
        gen.send(None)

    # Cerramos el pipeline; yield from propaga close() al subgenerador activo.
    try:
        gen.close()
    except StopIteration as exc:
        # close() no devuelve el valor de return; necesitamos send/throw para eso.
        print(f"resultado final: {exc.value}")


if __name__ == "__main__":
    # Demo completa con captura del valor de return.
    gen = pipeline()
    gen.send(None)

    sends = [("A", 100.0), ("A", 200.0), ("B", 50.0)]
    # Simulamos la transición entre subgeneradores enviando valores
    # y forzando la terminación del primero cerrando el generador
    # de forma controlada. En la práctica, el subgenerador terminaría
    # por su propia lógica (break interno, condición de fin, etc.).
    # Aquí usamos un subgenerador con límite explícito para demostrarlo.

    def accumulate_n(label: str, n: int) -> Generator[str, float, float]:
        """Versión con límite de n valores; retorna la suma."""
        total = 0.0
        for _ in range(n):
            value = yield f"[{label}] total={total:.2f}"
            if value is not None:
                total += value
        return total  # StopIteration.value = total

    def pipeline_n() -> Generator[str, float, dict]:
        result_a = yield from accumulate_n("A", 3)
        result_b = yield from accumulate_n("B", 2)
        return {"A": result_a, "B": result_b}

    gen2 = pipeline_n()
    status = gen2.send(None)
    print(status)

    values = [10.0, 20.0, 30.0, 40.0, 50.0]
    final_result = None
    for v in values:
        try:
            status = gen2.send(v)
            print(status)
        except StopIteration as exc:
            # El pipeline agotó ambos subgeneradores; aquí está el resultado.
            final_result = exc.value
            break

    print(f"resultado: {final_result}")
    # resultado: {'A': 60.0, 'B': 90.0}

Desglose del mecanismo

accumulate_n es un generador ordinario que yieldea el estado parcial y recibe valores vía .send(). Cuando el bucle for _ in range(n) termina, el return total lanza StopIteration(total) internamente.

Ahí entra lo clave: yield from accumulate_n("A", 3) en pipeline_n captura automáticamente ese StopIteration.value y lo asigna a result_a. El delegante no necesita envolver nada en try/except StopIteration; yield from hace esa captura por diseño (PEP 380, sección “Formal semantics”). Sin yield from, tendrías que implementar ~20 líneas del protocolo a mano para reproducir la misma semántica.

La transparencia de .throw() es el punto más sutil. Cuando llamas gen.throw(SomeError) sobre el delegante, yield from reenvía esa excepción al subgenerador activo mediante su propio .throw(). Si el subgenerador tiene un try/except que la maneja y continúa yieldando, el delegante nunca ve la excepción. Si el subgenerador no la maneja, se propaga al delegante. Eso significa que los recursos del subgenerador —sus finally, sus context managers internos— participan correctamente en la propagación de errores.

La propagación de .close() funciona igual: el delegante llama .close() sobre el subgenerador activo, que lanza GeneratorExit dentro de él. Esto es lo que garantiza que un with open(...) dentro de un subgenerador cierre el archivo cuando el consumidor abandona la iteración.

La conexión con async/await no es metafórica: asyncio en sus versiones tempranas usaba yield from directamente (@asyncio.coroutine + yield from). El await de PEP 492 es semánticamente yield from restringido a awaitables. La cadena de transparencia que yield from establece —valores hacia afuera, mensajes hacia adentro, resultado de retorno accesible— es exactamente el modelo de ejecución que hace posible que await suspenda y reanude corutinas a través de múltiples niveles de llamada sin que los intermediarios necesiten código extra.

Errores que debes conocer

Error: usar un bucle for para delegar cuando el consumidor puede llamar .throw() o .close(), con lo que esos mensajes no alcanzan al subgenerador.

# ❌ Wrong
def delegating_bad(sub_gen):
    for value in sub_gen:
        yield value
    # .throw() y .close() se procesan aquí, no en sub_gen.
    # El finally dentro de sub_gen puede no ejecutarse.

# ✅ Right
def delegating_good(sub_gen):
    yield from sub_gen
    # Transparencia total: send, throw y close llegan a sub_gen.

La diferencia es que yield from implementa el protocolo completo del PEP 380; el bucle for solo consume __next__.

Error: asumir que .close() sobre el delegante devuelve el valor de return del subgenerador.

# ❌ Wrong
gen = pipeline_n()
# ... consumo parcial ...
result = gen.close()  # result es siempre None; close() no retorna nada.

# ✅ Right
try:
    while True:
        gen.send(next_value())
except StopIteration as exc:
    result = exc.value  # aquí está el return del pipeline completo

close() lanza GeneratorExit y retorna None por especificación; el valor de return solo es accesible como StopIteration.value cuando el generador se agota de forma natural.

111

Dejar un comentario

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

Scroll al inicio