AulaML Python · C++
Python intermedio · 38 min · Python avanzado

Iteradores y generadores

Producir valores uno a uno, gastar memoria constante y entender qué hace realmente un bucle for

Un bucle for no recorre listas: recorre iteradores. Entender el protocolo iter/next y escribir generadores con yield te permite procesar archivos enormes con memoria constante, construir pipelines de datos y leer código que antes parecía magia.

Lección 1 de 6 del módulo
Al terminar esta lección podrás
  • Explicar el protocolo de iteración y qué hace el for por dentro
  • Distinguir iterable, iterador y generador
  • Escribir generadores con yield y encadenarlos con yield from
  • Reconocer cuándo un generador ahorra memoria y cuándo no conviene
  • Corregir los errores típicos: generador agotado, indexar o pedir len

Antes necesitas: Bucles: while, for y control de iteraciones · Funciones

En esta lección
  1. ¿Qué es un iterador?
  2. ¿Para qué sirve trabajar con iteradores?
  3. ¿Cómo funciona por dentro? El protocolo de iteración
  4. Sintaxis: iter, next, yield y yield from
  5. Ejemplo básico: contar hacia atrás
  6. Ejemplo intermedio: el protocolo a mano
  7. Caso práctico: procesar datos grandes sin cargarlos
  8. Visualización: un generador suspendido
  9. Errores comunes
  10. Buenas prácticas
  11. Ejercicios
  12. Soluciones explicadas
  13. Resumen
  14. Evaluación

Escribes for x in datos y funciona con una lista, con un texto, con un archivo y con el resultado de range. Esa uniformidad no es casualidad: todos esos objetos cumplen el mismo contrato, el protocolo de iteración. Cuando lo entiendes, dejas de memorizar trucos y empiezas a construir tus propias fuentes de datos.

Los generadores son la forma más corta de cumplir ese contrato: una función normal con yield que produce valores a demanda en lugar de calcularlos todos de golpe. Con ellos puedes recorrer un archivo de diez gigabytes usando la misma memoria que uno de diez líneas.

¿Qué es un iterador?

Un iterable es un objeto que sabe fabricar un iterador: una lista, una tupla, un str, un dict, un archivo abierto. Un iterador es un objeto que sabe hacer dos cosas: devolver el siguiente elemento y avisar cuando no quedan. Además recuerda por dónde va, porque guarda su posición.

La diferencia importa. La lista es el álbum completo de fotos; el iterador es tu dedo señalando una foto. Puedes tener dos dedos en el mismo álbum, cada uno en una posición distinta, y avanzar uno sin mover el otro. Por eso dos bucles for sobre la misma lista no se estorban, pero dos bucles sobre el mismo generador sí: el generador es el dedo, no el álbum.

¿Para qué sirve trabajar con iteradores?

  • Memoria constante: procesar un archivo o una consulta de base de datos línea a línea sin cargarla entera.
  • Flujos infinitos o sin fin conocido: lecturas de un sensor, precios en tiempo real, resultados paginados de una API.
  • Pipelines: una etapa filtra, otra transforma, otra suma; cada una pide solo lo que necesita a la anterior.
  • Código más corto: sum(x * x for x in datos) expresa la intención sin crear una lista intermedia.
  • Machine Learning: los DataLoader de PyTorch y tf.data generan lotes a demanda; por debajo usan esta misma idea de pereza.

¿Cómo funciona por dentro? El protocolo de iteración

Todo empieza con dos métodos especiales. __iter__ devuelve un iterador; __next__ devuelve el siguiente valor o lanza StopIteration cuando se acaba. Un for es azúcar sintáctico sobre esas dos llamadas:

PYTHON Lo que el for hace de verdad
datos = [10, 20]

iterador = iter(datos)          # llama a datos.__iter__()
while True:
    try:
        valor = next(iterador)  # llama a iterador.__next__()
    except StopIteration:
        break                    # fin limpio, sin error visible
    print(valor)

Nunca ves ese except porque el for lo captura por ti: StopIteration no es un fallo, es la señal de «no hay más». Si la excepción escapara de un bucle, el programa terminaría con traza; dentro del for, simplemente se sale.

Un generador es un iterador fabricado por Python. Cuando llamas a una función que contiene yield, el cuerpo no se ejecuta: se crea un objeto generador que guarda la función, el punto de inicio y un marco de pila propio, con sus variables locales. Cada next() avanza hasta el siguiente yield, entrega el valor y congela el marco: las variables locales siguen vivas y la próxima llamada continúa en la línea siguiente al yield, no desde el principio.

Sintaxis: iter, next, yield y yield from

PYTHON Las cuatro piezas del tema
def tres_valores():
    yield 1
    yield 2
    yield 3

g = tres_valores()      # no ejecuta nada del cuerpo
print(next(g))          # 1
print(next(g))          # 2

for valor in g:
    print(valor)        # 3 (continúa donde iba)

# yield from delega en otro iterable
def con_encabezado(filas):
    yield "producto,unidades"
    yield from filas

yield from recorre el iterable indicado y va cediendo cada valor como si lo hicieras tú con un bucle, pero además propaga el final y los valores enviados con send(). Es la forma idiomática de encadenar generadores o de aplanar una estructura anidada.

Las expresiones generadoras son la versión de una línea: (x * x for x in range(10)). Se parecen a las comprensiones de lista, pero cambian los corchetes por paréntesis y producen un generador en vez de una lista. Cuando son el único argumento de una función puedes omitir el paréntesis: sum(x * x for x in datos).

Ejemplo básico: contar hacia atrás

Una función con yield que produce una secuencia y termina sola cuando el while deja de cumplirse.

PYTHON cuenta_atras.py
def cuenta_atras(inicio):
    while inicio > 0:
        yield inicio      # cede el valor y se suspende aquí
        inicio -= 1       # se ejecuta al pedir el siguiente

for n in cuenta_atras(3):
    print(n, end=" ")
print("¡Despegue!")

La salida es 3 2 1 ¡Despegue!. Fíjate en el orden: yield inicio entrega el valor y deja la función pausada antes de restar. Cuando el bucle pide el siguiente número, la ejecución continúa en inicio -= 1, vuelve a comprobar la condición y cede otra vez. Cuando inicio llega a 0, el while termina, la función acaba y el for recibe StopIteration.

Ejemplo intermedio: el protocolo a mano

Antes de usar generadores conviene manejar el protocolo directamente, porque es lo que verás en trazas de error y en código de bibliotecas.

PYTHON protocolo.py
numeros = iter([10, 20])
print(type(numeros).__name__)
print(next(numeros))
print(next(numeros))

try:
    next(numeros)
except StopIteration:
    print("StopIteration: no quedan elementos")

iter sobre una lista devuelve un list_iterator; sobre un generador devuelve el propio generador, porque ya es un iterador. Agotado el segundo elemento, el tercer next lanza StopIteration. En código real casi nunca llamas a next a mano: usas for, sum, max, any o zip, que lo hacen por ti y capturan el final.

Caso práctico: procesar datos grandes sin cargarlos

Supón un CSV con millones de ventas. Cargarlo con read() o con list(...) multiplica el uso de memoria; un pipeline de generadores lo recorre una vez y mantiene solo el total. Aquí el archivo se simula con io.StringIO, pero el código es idéntico si le pasas un archivo abierto.

PYTHON pipeline_ventas.py
import csv
import io

texto = "producto,unidades\n" + "".join(f"p{i},{i}\n" for i in range(1, 7))
datos = io.StringIO(texto)   # en un caso real: with open("ventas.csv") as datos

def filas(archivo):
    """Convierte cada línea del CSV en una tupla con tipos."""
    for fila in csv.DictReader(archivo):
        yield fila["producto"], int(fila["unidades"])

def mayores(pares, minimo):
    """Deja pasar solo los pares que cumplen el umbral."""
    for nombre, unidades in pares:
        if unidades >= minimo:
            yield nombre, unidades

total = 0
for nombre, unidades in mayores(filas(datos), 4):
    total += unidades
    print(nombre, unidades)

print("Total:", total)

La salida es p4 4, p5 5, p6 6 y Total: 15. Ninguna etapa construye una lista: filas pide una línea al lector de CSV, la convierte, la cede; mayores la acepta o la descarta; el bucle suma. Si el archivo tuviera un millón de filas, el consumo sería el mismo, porque en ningún momento existen todas a la vez.

La diferencia de tamaño es fácil de medir con sys.getsizeof, aunque el número exacto depende de la versión de Python:

PYTHON memoria.py
import sys

lista = [x * x for x in range(100_000)]
generador = (x * x for x in range(100_000))

print(f"lista: {sys.getsizeof(lista) / 1024:.0f} KB")
print(f"generador: {sys.getsizeof(generador)} bytes")

Verás cientos de kilobytes frente a poco más de cien bytes. Ojo: getsizeof no cuenta los objetos referenciados, así que la lista real ocupa bastante más. La conclusión se mantiene: la lista crece con los datos y el generador no.

Visualización: un generador suspendido

Avanza paso a paso y observa cómo el marco del generador se congela en el yield y se reanuda justo después, mientras el bucle del módulo espera el valor.

Ejecución visual ·Quién tiene el control en cada momento
1 / 6
1 def contar_hasta(n):
2 i = 1
3 while i <= n:
4 yield i
5 i += 1
6
7 for valor in contar_hasta(2):
8 print(valor)
def crea la función y guarda su código. El cuerpo no se ejecuta: todavía no hay ningún valor.
Variables

Sin variables en este paso.

Salida
—

Avanza paso a paso: observa la línea resaltada, las variables y la salida.

Errores comunes

SíntomaPor qué ocurreCómo se arregla
El segundo for no imprime nadaEl generador ya se agotó: es de un solo usoRecréalo con contar_hasta(3) o guarda list(...) si necesitas varios recorridos
TypeError: 'generator' object is not subscriptableIntentas g[0] como si fuera una listaUsa next(g) o materialízalo con list(g)
TypeError: object of type 'generator' has no len()Un generador no conoce su longitud de antemanoCuenta con sum(1 for _ in g) o pide el total por otro camino
TypeError: 'NoneType' object is not iterableLa función no tiene yield: devuelve NoneAñade el yield o devuelve una lista
Reutilizar un generador que ya se consumió
Incorrecto
PYTHON El segundo recorrido está vacío
g = (x * 2 for x in range(3))
print(list(g))   # [0, 2, 4]
print(list(g))   # []  el generador ya terminó
Correcto
PYTHON Una fábrica, dos recorridos
def dobles(n):
    for x in range(n):
        yield x * 2

print(list(dobles(3)))   # [0, 2, 4]
print(list(dobles(3)))   # [0, 2, 4]

Un generador es un iterador, no una colección: al agotarse se cierra y no se reinicia. Si necesitas recorrer varias veces, crea un generador nuevo cada vez (una función que lo fabrique) o guarda los resultados en una lista, aceptando el coste de memoria.

Buenas prácticas

  • Nombra los generadores como lo que producen (filas, lecturas, ventas_de), no como lo que hacen.
  • Documenta con una línea qué produce cada yield y si puede ser infinito.
  • Prefiere expresiones generadoras en llamadas como sum(...) o any(...); evita el list(...) intermedio.
  • Devuelve siempre un generador nuevo desde una función: no compartas el objeto entre partes del programa.
  • No mutiles una lista mientras la recorres; si necesitas modificarla, itera sobre una copia.
  • Mide antes de optimizar: para 1 000 elementos una lista es más simple y suele ir algo más rápido.

Ejercicios

Ejercicio 1

Sensor infinito con límite

Escribe un generador lecturas(inicio) que produzca temperaturas sin fin, subiendo 1.5 grados en cada paso, y usa itertools.islice para imprimir solo las cinco primeras. Comprueba que el generador no se calcula hasta que se pide cada valor.
Pista

Dentro del generador usa while True: y un yield por vuelta. islice(iterable, n) se detiene después de n elementos sin agotar la fuente.

Ejercicio 2

Pipeline de tres etapas

Parte de una lista de diccionarios de ventas con producto, sede, unidades y precio. Construye tres generadores encadenados: uno filtra por sede, otro calcula el importe de cada venta y otro acumula el total. Imprime los pares de la sede Lima y el total de esa sede.
Pista

Cada etapa recibe un iterable y hace for sobre él cediendo resultados. La última puede ser una función normal que consuma el generador con un acumulador.

Completar código Completa el generador de pares
def pares(limite):
    n = 0
    while n <= limite:
         n          # cede el valor y se suspende
        n += 2

for valor in pares(4):
    print()

La palabra que convierte una función en generador es `yield`. En el bucle, imprime la variable que recibe cada valor.

Soluciones explicadas

En el sensor, la clave es que while True no es un error: el generador está diseñado para no terminar, y la responsabilidad de cortar pasa al consumidor. islice pide exactamente cinco valores y deja el generador suspendido; si después lo reutilizaras, continuaría por la sexta lectura. Esa semántica de «una sola secuencia, un solo avance» es la que hay que tener clara.

En el pipeline, el detalle importante está en la última línea: total(importes(de_sede(ventas, "Lima"))) crea un pipeline nuevo, porque el anterior ya se consumió en el print. Si guardaras pipeline = importes(de_sede(ventas, "Lima")) y lo pasaras dos veces, el segundo resultado sería 0.0. Esta es la razón por la que los generadores se envuelven en funciones: la función es la fábrica, el generador es el producto.

Una alternativa más compacta para el total sería sum(monto for _, monto in importes(...)), que sustituye la función total por una expresión generadora. Es correcto y más corto; la versión con función se lee mejor cuando la lógica de reducción crece.

Resumen

  • Un iterable fabrica iteradores con __iter__; un iterador avanza con __next__ y termina con StopIteration.
  • El for es iter() + next() repetido + captura de StopIteration.
  • Una función con yield devuelve un generador; su cuerpo no corre hasta el primer next().
  • yield entrega un valor y congela el marco: las variables locales sobreviven entre llamadas.
  • Los generadores gastan memoria constante y sirven para archivos grandes y flujos infinitos.
  • Son de un solo uso: agotados, hay que crear otro o materializar con list().
  • yield from delega en otro iterable y encadena etapas de un pipeline.

Evaluación

Evaluación 1

¿Qué hace exactamente yield valor dentro de una función?

Evaluación 2

Tienes g = (x for x in range(3)) y ejecutas list(g) dos veces. ¿Qué obtienes?

Evaluación 3

¿Cuál de estas estructuras mantiene un uso de memoria constante al recorrer un millón de registros?

Tus notas

¿Ya lo entendiste?

Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.