Al cronometrar cierre_de_caja() en la lección anterior quedó una pregunta en el aire: para sumar las ventas de hoy, la función lee ventas.csv entero y lo convierte en una lista. Con las cuatro ventas de esta semana da igual; con cinco años de historia de Papyrus, esa lista ocupa memoria para calcular... un solo número. Los generadores son la respuesta de Python a este despilfarro: producen los valores de uno en uno, bajo demanda, sin materializar nunca la colección completa. Esta lección desmonta el yield, el protocolo de iteración que hay debajo de cada for que has escrito desde el módulo 2, y monta con ello un pipeline de procesamiento de ventas.

Contenido

  1. El problema: cargarlo todo vs procesar bajo demanda
  2. yield: funciones que se pausan
  3. El protocolo de iteración: next() y StopIteration
  4. Generadores vs listas: la tabla de la verdad
  5. Expresiones generadoras
  6. Archivos grandes línea a línea: el generador natural
  7. Encadenar generadores: el pipeline de ventas
  8. yield from, brevemente
  9. itertools: islice y chain
  10. Cuándo lista y cuándo generador

El problema: cargarlo todo vs bajo demanda

Así podría sumar el día una versión ingenua del cierre (con el csv y el try mínimo de M6/M7):

import csv

def importes_del_dia(ruta, fecha):
    filas = list(csv.DictReader(ruta.open(encoding="utf-8")))  # ¡TODO en memoria!
    importes = []
    for fila in filas:
        if fila["fecha"] == fecha:
            importes.append(float(fila["importe"]))
    return importes

Dos listas completas (filas e importes) para acabar haciendo sum(). Si ventas.csv tiene un millón de filas, hay un millón de diccionarios vivos a la vez. La alternativa: producir cada importe justo cuando se necesita y olvidarlo justo después. Memoria constante, dé igual el tamaño del archivo.

yield: funciones que se pausan

Un generador se escribe como una función normal, pero con yield donde una función normal pondría return... con una diferencia radical de comportamiento:

def primeras_ventas():
    print("preparando la primera")
    yield ("2026-07-13", "Hamlet", 20.70)
    print("preparando la segunda")
    yield ("2026-07-13", "La Odisea", 13.00)
    print("no hay más")

g = primeras_ventas()
print(type(g))        # <class 'generator'> — ¡no se ha impreso NADA todavía!

Llamar a primeras_ventas() no ejecuta su cuerpo: devuelve un objeto generador, pausado en la primera línea. El cuerpo avanza solo cuando alguien pide un valor:

print(next(g))
# preparando la primera
# ('2026-07-13', 'Hamlet', 20.7)

print(next(g))
# preparando la segunda
# ('2026-07-13', 'La Odisea', 13.0)

next(g)
# no hay más
# Traceback ... StopIteration

Cada next() reanuda el cuerpo exactamente donde se quedó — con sus variables locales intactas, como un marcapáginas en un libro de la tienda — ejecuta hasta el siguiente yield, entrega el valor y vuelve a pausarse. Cuando el cuerpo termina, se lanza StopIteration.

sequenceDiagram
    participant C as Consumidor (for / next)
    participant G as Generador
    C->>G: next(g)
    activate G
    Note over G: ejecuta hasta el 1er yield
    G-->>C: entrega valor y SE PAUSA
    deactivate G
    C->>G: next(g)
    activate G
    Note over G: reanuda tras el yield,<br/>locales intactas
    G-->>C: siguiente valor, pausa
    deactivate G
    C->>G: next(g)
    activate G
    Note over G: el cuerpo termina
    G-->>C: StopIteration
    deactivate G

El protocolo de iteración

Esto no es un mecanismo aparte: es lo que el for hace por dentro desde el módulo 2. Un bucle for x in algo: llama a iter(algo), luego a next() repetidamente, y captura StopIteration para terminar limpiamente. Por eso un generador se recorre con for sin ver jamás la excepción:

for venta in primeras_ventas():
    print(venta)      # el for gestiona next() y StopIteration por ti

Listas, diccionarios, archivos, range: todos hablan este mismo protocolo. Los generadores son simplemente la forma más cómoda de crear objetos que lo hablen.

Generadores vs listas

def cuadrados_lista(n):
    return [i * i for i in range(n)]     # materializa n valores

def cuadrados_gen(n):
    for i in range(n):
        yield i * i                      # produce de uno en uno
Aspecto Lista Generador
Memoria Todos los elementos a la vez Un elemento cada vez (constante)
¿Cuándo se calcula? Al crearla (ansioso) Al pedir cada valor (perezoso)
Recorridos Tantos como quieras Uno solo — luego queda agotado
len(), indexar [i], in repetido No
Infinitos posibles No (memoria finita) Sí (produce mientras se pida)
Depurar con print() Fácil: se ve el contenido Solo se ve <generator object ...>

La fila más traicionera es la del recorrido único:

g = cuadrados_gen(4)
print(sum(g))    # 14
print(sum(g))    # 0  ← ¡agotado! No lanza error: simplemente ya no produce nada

Un generador es como el rollo de papel del ticket: se consume al usarlo. Si necesitas dos pasadas, crea el generador dos veces o materializa una lista.

Expresiones generadoras

Las comprensiones de listas de M2 tienen una hermana perezosa: la misma sintaxis con paréntesis en vez de corchetes:

importes = [float(f["importe"]) for f in filas]    # lista: materializa
importes = (float(f["importe"]) for f in filas)    # generador: bajo demanda

Y cuando la expresión es el único argumento de una función, los paréntesis de la función bastan. Así queda el corazón de un cierre de caja eficiente:

total = sum(float(f["importe"]) for f in filas if f["fecha"] == hoy)

Ninguna lista intermedia: sum() va pidiendo valores por el protocolo de iteración y el generador los produce de uno en uno. Es la forma idiomática de agregar (sum, max, min, any, all) sin coste de memoria.

Archivos línea a línea: el generador natural

Aquí conecta todo con M6: un archivo abierto ya es un generador de líneas. Cuando escribías for linea in f: no cargabas el archivo — pedías línea a línea. Envolverlo en un generador propio permite añadirle lógica:

from pathlib import Path

BASE = Path(__file__).parent

def leer_ventas(ruta: Path):
    """Produce las filas de ventas.csv de una en una, como dict."""
    import csv
    with ruta.open(encoding="utf-8", newline="") as f:
        for fila in csv.DictReader(f):
            yield fila                    # pausa aquí; el archivo sigue abierto

for fila in leer_ventas(BASE / "datos" / "ventas.csv"):
    print(fila["titulo"], fila["importe"])

Detalle importante: el with de dentro mantiene el archivo abierto mientras el generador esté vivo, y lo cierra cuando el cuerpo termina (o el generador se destruye). El consumidor no gestiona el archivo: solo pide filas.

Encadenar generadores: el pipeline de ventas

La verdadera potencia aparece al encadenar generadores: cada etapa consume del anterior y produce para el siguiente, como una cadena de montaje donde cada fila atraviesa toda la cadena antes de que entre la siguiente:

def parsear(filas):
    """dict → (fecha, titulo, importe float). Salta filas corruptas, como cierre_de_caja."""
    import logging
    logger = logging.getLogger(__name__)
    for fila in filas:
        try:
            yield fila["fecha"], fila["titulo"], float(fila["importe"])
        except (KeyError, ValueError):
            logger.warning("fila corrupta saltada: %r", fila)   # el WARNING de 07-05

def solo_del_dia(ventas, fecha):
    for f, titulo, importe in ventas:
        if f == fecha:
            yield f, titulo, importe

# Montaje del pipeline: leer → parsear → filtrar → sumar
filas   = leer_ventas(BASE / "datos" / "ventas.csv")
ventas  = parsear(filas)
del_dia = solo_del_dia(ventas, "2026-07-13")
total   = sum(importe for _, _, importe in del_dia)
print(f"Caja del día: {total:.2f} €")

Hasta la última línea no se ha leído ni una fila: montar el pipeline es gratis. Es sum() quien tira de la cadena: pide un valor a del_dia, que pide a ventas, que pide a filas, que lee una línea del disco. Memoria: una fila. Y cada etapa es pequeña, con nombre, testeable por separado — algo que apreciaremos en M9.

yield from, brevemente

Cuando un generador solo quiere delegar en otro iterable, yield from evita el bucle:

def ventas_de_ambos_archivos(ruta_actual, ruta_antigua):
    yield from leer_ventas(ruta_antigua)   # produce todo lo del primero...
    yield from leer_ventas(ruta_actual)    # ...y después todo lo del segundo

yield from iterable equivale a for x in iterable: yield x. Suficiente por ahora; tiene usos más profundos que no necesitamos.

itertools: dos herramientas útiles

La biblioteca estándar (M3) trae itertools, una caja de piezas para iteradores. Dos que encajan hoy:

from itertools import islice, chain

# islice: "rebanar" un generador (no se puede hacer g[:5])
primeras_cinco = islice(leer_ventas(ruta), 5)      # las 5 primeras filas, sin leer más

# chain: encadenar iterables — lo mismo que ventas_de_ambos_archivos, en una línea
todas = chain(leer_ventas(ruta_antigua), leer_ventas(ruta_actual))

Ambas devuelven, cómo no, iteradores perezosos. Cuando te sorprendas escribiendo lógica de iteración "de fontanería", mira primero si itertools ya la trae.

Cuándo lista y cuándo generador

  • Generador cuando: el dato se recorre una vez y se agrega (sum, informes del cierre), el origen es grande o de tamaño desconocido (ventas.csv, papyrus.log), o construyes un pipeline por etapas.
  • Lista cuando: necesitas len(), indexar, ordenar (sorted materializa de todos modos), recorrer varias veces, o el dato es pequeño y quieres verlo al depurar. El catálogo de Papyrus (4 libros) en dict[str, Libro] está perfecto como está: la pereza no aporta nada a 4 elementos.

Errores Comunes y Consejos

  • Reutilizar un generador agotado: la segunda pasada devuelve "nada" sin error. Si un sum() te da 0 inexplicable, comprueba si consumiste el generador antes.
  • Esperar que el cuerpo se ejecute al llamar: leer_ventas(ruta) no abre el archivo todavía; ni siquiera valida la ruta. El FileNotFoundError saltará en el primer next(), quizá lejos de la llamada — tenlo en cuenta al leer trazas (M7).
  • return con valor dentro de un generador: no "devuelve" al consumidor; termina el generador (el valor viaja escondido en StopIteration). Para producir valores, siempre yield.
  • Materializar sin querer: list(generador), sorted(generador) o ", ".join(generador) cargan todo en memoria. A veces es lo correcto — pero que sea una decisión, no un accidente.
  • Consejo: anota los generadores con los hints de 08-01 — la forma sencilla es Iterator[FilaVenta] (de typing o collections.abc): documenta que se consume una vez.
  • Consejo: si al depurar necesitas ver el contenido, list(islice(g, 10)) te enseña una muestra sin tragarte el archivo entero.

Ejercicios

  1. Escribe el generador importes_del_titulo(ruta, titulo) que produzca (yield) los importes (float) de todas las ventas de un título dado leyendo ventas.csv fila a fila, y úsalo con sum() para saber cuánto ha facturado Hamlet en total. Reutiliza leer_ventas.
  2. Ana quiere las 3 primeras ventas que superen 15.00 € para una promoción. Resuélvelo con una expresión generadora más islice, sin ninguna lista intermedia.
  3. Añade al pipeline de la lección una etapa con_iva_desglosado(ventas) que reciba tuplas (fecha, titulo, importe) y produzca (fecha, titulo, importe, iva) donde iva = round(importe - importe / 1.04, 2) (la parte de IVA_LIBROS contenida en el importe). Móntala entre parsear y solo_del_dia y explica por qué el orden de las etapas no cambia la memoria consumida.

Soluciones

  1. def importes_del_titulo(ruta, titulo):
        for fila in leer_ventas(ruta):
            if fila["titulo"] == titulo:
                yield float(fila["importe"])
    
    total_hamlet = sum(importes_del_titulo(BASE / "datos" / "ventas.csv", "Hamlet"))
    

    Dos generadores encadenados (leer_ventasimportes_del_titulo) y una agregación: memoria constante. Si alguna fila tiene "gratis" como importe, el float() lanzará — puedes protegerlo con el try mínimo de 07-02, como hace parsear.

  2. from itertools import islice
    
    grandes = (float(f["importe"]) for f in leer_ventas(ruta) if float(f["importe"]) > 15.00)
    tres_primeras = list(islice(grandes, 3))
    

    Lo elegante: en cuanto islice entrega la tercera, deja de pedir — el resto del archivo ni se lee. El list() final materializa solo 3 elementos, para poder mirarlos.

  3. def con_iva_desglosado(ventas):
        for fecha, titulo, importe in ventas:
            iva = round(importe - importe / 1.04, 2)
            yield fecha, titulo, importe, iva
    
    filas   = leer_ventas(BASE / "datos" / "ventas.csv")
    ventas  = con_iva_desglosado(parsear(filas))
    del_dia = solo_del_dia_4(ventas, "2026-07-13")   # adaptada a tuplas de 4
    

    La memoria no depende del número de etapas sino de cuántas filas están "en tránsito" a la vez: exactamente una, porque cada next() del consumidor arrastra una única fila por toda la cadena. Diez etapas seguirían usando memoria de una fila.

Conclusión

Los generadores cumplen la segunda promesa del cierre de M7: cierre_de_caja ya puede procesar las ventas sin cargarlas enteras en memoria, con yield pausando y reanudando la función, StopIteration cerrando el ciclo que el for gestiona desde siempre, y un pipeline leer → parsear → filtrar → sumar donde cada etapa hace una sola cosa. Y en leer_ventas quedó a la vista un viejo conocido trabajando en silencio: el with, que mantiene el archivo abierto justo mientras hace falta y lo cierra al acabar. Llevamos desde el módulo 6 usándolo a ciegas y prometiendo explicarlo. La próxima lección salda esa deuda: qué hace el with por dentro — el protocolo __enter__/__exit__ — y cómo escribir administradores de contexto propios, incluida una versión con yield que te resultará sospechosamente familiar.

Curso de Programación en Python

Módulo 1: Introducción a Python

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados