Un generador ordinario es un iterador: produces valores, los consumes desde fuera, y la comunicación es unidireccional. Pero el protocolo completo del generador incluye tres métodos que invierten esa dirección —send(), throw() y close()— y convierten cualquier función con yield en una corrutina primitiva: código que puede pausarse, recibir datos externos, y reaccionar a señales de control.
El mecanismo central es que yield no es solo una instrucción de salida; es una expresión que tiene un valor de retorno. Ese valor no lo determina el generador, lo determina quien lo reanuda. Cuando escribes x = yield algo, estás diciendo dos cosas al mismo tiempo: “emite algo hacia fuera” y “cuando me reanuden, asigna a x lo que me pasen”. next(gen) equivale exactamente a gen.send(None), que es por eso el primer send() debe ser send(None): el generador aún no ha llegado a ningún yield activo desde el que pueda recibir nada.
gen.throw(TipoExcepcion, valor, traceback) es más quirúrgico de lo que parece. No lanza la excepción en el frame del llamador —la inyecta dentro del generador, en la línea exacta donde está pausado. El generador puede atraparla con try/except y continuar, o dejarla propagar hacia arriba. Esto es la base sobre la que se construyeron frameworks como Tornado y la primera versión de asyncio antes de async/await.
gen.close() es throw(GeneratorExit) con garantías adicionales: si el generador no atrapa GeneratorExit o lo atrapa y hace return, Python considera el cierre limpio. Si el generador atrapa GeneratorExit y luego ejecuta otro yield, obtienes RuntimeError. Los bloques finally se ejecutan igual, lo que hace que close() sea el mecanismo de limpieza determinista para recursos adquiridos dentro del generador.
Juntos, estos tres métodos te dan comunicación bidireccional completa: datos de entrada vía send(), señales de error vía throw(), y shutdown controlado vía close(). Ignorar este contrato —por ejemplo, hacer send(valor) antes del primer yield, o ceder después de atrapar GeneratorExit— rompe el protocolo de formas que Python detecta explícitamente.
from __future__ import annotations
import logging
from collections.abc import Generator
from typing import Optional
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s")
log = logging.getLogger(__name__)
class ParseError(ValueError):
"""Error recuperable en el parser incremental."""
def incremental_parser() -> Generator[Optional[dict], Optional[str], str]:
"""
Corrutina que acumula fragmentos de texto y emite registros parseados.
Protocolo:
- send(str) → fragmento de entrada; yield devuelve un dict si completó
un registro, o None si aún acumula.
- throw(ParseError) → descarta el buffer actual y continúa limpio.
- close() → libera recursos y termina.
El valor de retorno (StopIteration.value) indica cuántos registros procesó.
"""
buffer: list[str] = []
records_parsed = 0
log.debug("Parser inicializado, esperando fragmentos")
try:
while True:
# yield tiene dos roles simultáneos:
# - emite el resultado actual hacia fuera
# - recibe el próximo fragmento desde send()
fragment: Optional[str] = yield (_try_parse(buffer) if buffer else None)
if fragment is None:
# send(None) después del primer avance: no hay dato, continuar
continue
buffer.append(fragment)
log.debug("Buffer actual: %r", "".join(buffer))
# Intentar parsear; si completa un registro, limpiar buffer
if _is_complete("".join(buffer)):
records_parsed += 1
log.debug("Registro #%d completado", records_parsed)
buffer.clear()
except ParseError as exc:
# throw(ParseError) llega aquí; descartamos el buffer y seguimos
log.warning("Error recuperado: %s — buffer descartado", exc)
buffer.clear()
# Re-entrar al bucle devolviendo None al llamador que lanzó el error
# Para continuar, necesitamos un nuevo yield antes de volver al while.
# La forma correcta es usar 'yield from' o simplemente dejar que el
# GeneratorExit/StopIteration maneje el cierre si no se continúa.
# Aquí optamos por propagar: el parser se vuelve no-recuperable.
raise
except GeneratorExit:
# close() llega aquí como GeneratorExit
log.debug("Cierre solicitado; buffer pendiente: %r", "".join(buffer))
# NO hacer yield aquí — provocaría RuntimeError
return f"cerrado limpiamente tras {records_parsed} registros"
finally:
# finally se ejecuta tanto en close() como en return normal
log.debug("Liberando recursos del parser")
return f"{records_parsed} registros procesados" # valor en StopIteration
def _is_complete(text: str) -> bool:
"""Un 'registro' termina con punto y coma."""
return text.endswith(";")
def _try_parse(buffer: list[str]) -> Optional[dict]:
"""Convierte el buffer en un dict si está completo."""
text = "".join(buffer)
if _is_complete(text):
return {"raw": text.rstrip(";")}
return None
# ── demostración ──────────────────────────────────────────────────────────────
def main() -> None:
parser = incremental_parser()
# Primer avance obligatorio: send(None) equivale a next()
# Esto ejecuta el generador hasta el primer yield
first_result = parser.send(None)
print(f"Inicio: {first_result}") # None — buffer vacío
# Enviamos fragmentos; el generador acumula
print(parser.send("hola")) # None — incompleto
print(parser.send(", mundo")) # None — incompleto
result = parser.send(";") # completa el registro
print(f"Registro parseado: {result}")
# Enviamos datos corruptos y luego inyectamos un error recuperable.
# En este diseño el parser no es recuperable post-throw, pero el
# mecanismo queda demostrado:
parser2 = incremental_parser()
parser2.send(None)
parser2.send("dato_malo")
try:
parser2.throw(ParseError, ParseError("fragmento inválido"))
except ParseError:
print("ParseError propagado correctamente")
# Cierre limpio del primer parser
try:
parser.close() # inyecta GeneratorExit; ejecuta finally
except StopIteration as exc:
# close() no propaga StopIteration, pero si el generador retorna,
# Python 3 simplemente ignora el valor de retorno en close().
print(exc.value)
if __name__ == "__main__":
main()
Qué está pasando realmente
La línea fragment: Optional[str] = yield (...) es el núcleo del patrón. Cada vez que el llamador ejecuta parser.send(dato), Python hace tres cosas en secuencia: reanuda el frame del generador, hace que esa expresión yield evalúe a dato, y ejecuta el generador hasta el siguiente yield. El resultado del nuevo yield es lo que send() devuelve al llamador. Es una danza sincronizada: por cada send() hay exactamente un yield, y viceversa.
La restricción del primer send(None) no es arbitraria. Cuando el generador acaba de crearse, su frame nunca ha ejecutado. No existe ningún yield suspendido esperando un valor. Si pasaras un valor no-None, ¿dónde iría? Python lanza TypeError en lugar de descartarlo silenciosamente —un contrato explícito que evita bugs de inicialización difíciles de rastrear.
throw() respeta los try/except del generador. Esto es lo que permite a una corrutina manejar errores de su propio dominio sin exponerlos al llamador. En el ejemplo, ParseError es recuperable dentro del generador porque existe un except ParseError; si dejáramos pasar la excepción, propagaría normalmente hacia el llamador. Esta dualidad —el generador puede absorber o relanzar— es exactamente la semántica que asyncio usa internamente para cancelar tareas (CancelledError es un throw de GeneratorExit-equivalente en las corrutinas nativas).
El bloque finally en el generador se ejecuta independientemente de cómo termine: return, StopIteration natural, o close(). Esto hace que los generadores sean seguros para RAII-style resource management en Python —abres una conexión dentro del generador, la cierras en finally, y close() garantiza que se ejecute incluso si el llamador abandona la iteración a mitad.
La anotación de tipo Generator[YieldType, SendType, ReturnType] hace explícito el contrato bidireccional. YieldType es lo que emite, SendType es lo que acepta vía send(), y ReturnType es el valor en StopIteration.value cuando termina. Si usas Optional[str] como SendType, estás documentando que send(None) es válido —lo cual siempre es cierto, pero especificarlo evita confusión.
Errores que debes conocer
Error: llamar a send(valor_no_none) en un generador recién creado, antes del primer yield.
# ❌ Wrong
def gen():
x = yield 1
yield x * 2
g = gen()
g.send(42) # TypeError: can't send non-None value to a just-started generator
# ✅ Right
g = gen()
g.send(None) # avanza hasta el primer yield
g.send(42) # ahora sí: x = 42, emite 84
El primer send(None) (o next()) es parte del protocolo: inicializa el frame y lo pausa en el primer yield. Solo entonces existe un yield activo capaz de recibir un valor.
Error: hacer yield dentro del bloque except GeneratorExit, que convierte un cierre limpio en RuntimeError.
# ❌ Wrong
def resource_gen():
try:
while True:
yield "dato"
except GeneratorExit:
yield "limpiando" # RuntimeError: generator ignored GeneratorExit
# ✅ Right
def resource_gen():
try:
while True:
yield "dato"
except GeneratorExit:
pass # o hacer limpieza sin yield
finally:
print("recursos liberados") # aquí sí, sin yield
GeneratorExit señala que el generador debe terminar —cualquier intento de continuar produciendo valores viola el contrato y Python lo detecta inmediatamente.
Error: capturar la excepción inyectada por throw() pero olvidar que el generador queda pausado en el mismo yield si no avanza.
# ❌ Wrong — asume que throw() avanza automáticamente al siguiente yield
def fragile():
try:
yield 1
except ValueError:
pass
yield 2
g = fragile()
next(g) # pausa en yield 1
g.throw(ValueError) # el except lo captura; el generador continúa hasta yield 2
# Hasta aquí correcto. El error es conceptual: throw() SÍ avanza si el
# generador maneja la excepción y llega a otro yield.
# El bug real ocurre cuando no hay yield siguiente:
def dead_end():
try:
yield 1
except ValueError:
return # StopIteration después del throw
g = dead_end()
next(g)
g.throw(ValueError) # lanza StopIteration — hay que anticiparlo
# ✅ Right
try:
g.throw(ValueError)
except StopIteration:
pass # el generador terminó limpiamente al manejar el error
throw() puede resultar en tres outcomes: el generador produce el siguiente valor (retorna ese valor), el generador termina (StopIteration), o la excepción propaga sin capturarse. Los tres son legítimos y todos hay que manejarlos.
Control de versión: este comportamiento es estable desde Python 2.5 (PEP 342) y no ha cambiado en Python 3.x.
N° 110