Si alguna vez has escrito un bucle for en Python, ya has usado iteradores — incluso sin saberlo. Pero cuando necesitas procesar grandes volúmenes de datos, crear flujos infinitos o simplemente escribir código más elegante, entender los generadores e iteradores se vuelve esencial.

En esta guía definitiva, aprenderás qué son los iteradores y generadores, cómo funcionan internamente y cómo usarlos para transformar la forma en que escribes Python. Con ejemplos prácticos, verás cómo estas herramientas pueden reducir el consumo de memoria, simplificar tu código y abrirte las puertas a patrones de programación más avanzados.

¿Qué Son los Iteradores?

En Python, un iterador es un objeto que implementa el protocolo de iteración: los métodos __iter__() y __next__(). El primero devuelve el propio iterador, y el segundo devuelve el siguiente elemento de la secuencia. Cuando no hay más elementos, __next__() lanza la excepción StopIteration.

Prácticamente todas las estructuras de datos en Python son iterables: listas, tuplas, diccionarios, conjuntos, cadenas. Cuando escribes for item in lista, Python internamente llama a iter(lista) para obtener un iterador y luego llama a next() en cada iteración.

lista = [10, 20, 30]
iterador = iter(lista)

print(next(iterador))  # 10
print(next(iterador))  # 20
print(next(iterador))  # 30
# print(next(iterador))  # StopIteration

Entender este mecanismo es el primer paso para crear tus propios iteradores personalizados y, más importante aún, para dominar los generadores — la forma más elegante de crear iteradores en Python.

El Protocolo de Iteración

Cualquier objeto que tenga los métodos __iter__ y __next__ se considera un iterador. El método __iter__ debe devolver el propio objeto iterador, y __next__ debe devolver el siguiente valor o lanzar StopIteration.

class Contador:
    def __init__(self, limite):
        self.limite = limite
        self.actual = 0

    def __iter__(self):
        return self

    def __next__(self):
        if self.actual >= self.limite:
            raise StopIteration
        valor = self.actual
        self.actual += 1
        return valor

for numero in Contador(5):
    print(numero)  # 0, 1, 2, 3, 4

Aunque funciona perfectamente, crear iteradores con clases es verboso. Aquí es donde entran los generadores.

¿Qué Son los Generadores?

Un generador es una función especial que usa la palabra clave yield en lugar de return. A diferencia de una función normal que se ejecuta y termina, una función generadora mantiene su estado entre llamadas — se "pausa" en cada yield y se reanuda desde donde se quedó en la siguiente llamada.

Cuando llamas a una función generadora, no se ejecuta inmediatamente. En su lugar, devuelve un objeto generador (que es un iterador). Cada vez que llamas a next() sobre él, la función se ejecuta hasta el siguiente yield y devuelve ese valor.

def contador_simple():
    print("Comenzando...")
    yield 1
    print("Continuando...")
    yield 2
    print("Finalizando...")
    yield 3

gen = contador_simple()
print(next(gen))  # Comenzando... 1
print(next(gen))  # Continuando... 2
print(next(gen))  # Finalizando... 3

Esa es la magia de los generadores: permiten la evaluación perezosa (lazy evaluation), es decir, los valores se computan bajo demanda, solo cuando se necesitan.

Yield vs Return: ¿Cuál es la Diferencia?

La diferencia fundamental entre yield y return es el comportamiento del estado:

  • return: termina la función permanentemente y devuelve un valor.
  • yield: pausa la función, guarda todo su estado (variables locales, posición en el código) y devuelve un valor. En la siguiente llamada, la función se reanuda exactamente donde se quedó.

Una función generadora puede tener múltiples yield e incluso puede mezclar yield con return. Cuando se usa return en un generador, termina la iteración lanzando StopIteration, y el valor pasado a return está disponible a través de la excepción (aunque esto rara vez se usa).

Para un análisis técnico completo, consulta la PEP 255 — Simple Generators, que introdujo oficialmente los generadores en Python 2.2.

Generator Expressions

Así como tienes list comprehensions para crear listas de forma concisa, existen las generator expressions para crear generadores. La sintaxis es casi idéntica, pero usa paréntesis en lugar de corchetes.

# List comprehension — crea una lista completa en memoria
cuadrados_lista = [x**2 for x in range(1000000)]  # ~ 8 MB de memoria

# Generator expression — crea un generador perezoso
cuadrados_gen = (x**2 for x in range(1000000))  # ~ 56 bytes de memoria

print(next(cuadrados_gen))  # 0
print(next(cuadrados_gen))  # 1
print(next(cuadrados_gen))  # 4

La diferencia de memoria es astronómica: la list comprehension asigna espacio para un millón de enteros de una sola vez, mientras que la generator expression crea un generador que ocupa unos pocos bytes y calcula cada valor bajo demanda.

Las generator expressions se introdujeron en Python 2.4 mediante la PEP 289 — Generator Expressions, que detalla la motivación y el diseño de esta característica.

Si quieres entender mejor las list comprehensions antes de migrar a generator expressions, consulta nuestra guía completa: List Comprehension en Python: Guía Completa.

La Expresión Yield From

Introducida en Python 3.3 (PEP 380), yield from permite que un generador delegue iteraciones a otro generador o iterable. Esto simplifica la composición de generadores y evita anidamientos verbosos.

def generador_principal():
    yield from [1, 2, 3]
    yield from range(4, 7)
    yield from "ab"

for valor in generador_principal():
    print(valor, end=" ")  # 1 2 3 4 5 6 a b

Sin yield from, tendrías que iterar manualmente sobre cada sub-iterador:

def generador_sin_yield_from():
    for item in [1, 2, 3]:
        yield item
    for item in range(4, 7):
        yield item
    for item in "ab":
        yield item

La expresión yield from también maneja automáticamente la comunicación bidireccional entre generadores — los valores enviados con send() se propagan al generador delegado, y las excepciones se transmiten correctamente.

Generadores Infinitos

Una de las aplicaciones más impresionantes de los generadores es la creación de secuencias infinitas. Como los valores se computan bajo demanda, puedes representar secuencias teóricamente infinitas sin consumir memoria infinita.

def fibonacci_infinito():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

fib = fibonacci_infinito()
for _ in range(10):
    print(next(fib), end=" ")  # 0 1 1 2 3 5 8 13 21 34

Con generadores infinitos, controlas exactamente cuántos valores consumir. El generador no sabe que es "infinito" — simplemente sigue produciendo valores mientras sigas llamando a next().

El módulo itertools de la biblioteca estándar ofrece diversas herramientas para trabajar con generadores e iteradores, incluyendo funciones como islice() que permite "rebanar" generadores infinitos.

Aplicaciones Prácticas

1. Procesamiento de Archivos Grandes

Los generadores brillan cuando necesitas procesar archivos más grandes que la memoria disponible. Un generador puede leer y procesar el archivo línea por línea:

def lineas_archivo(ruta):
    with open(ruta, 'r', encoding='utf-8') as archivo:
        for linea in archivo:
            yield linea.strip()

def lineas_con_palabra(lineas, palabra):
    for linea in lineas:
        if palabra in linea:
            yield linea

def contar_ocurrencias(lineas, palabra):
    return sum(1 for linea in lineas if palabra in linea)

# Uso encadenado — memoria constante
total = contar_ocurrencias(lineas_archivo("datos.csv"), "Python")
print(f"Ocurrencias: {total}")

Cada generador hace una sola cosa y la hace bien. El encadenamiento mantiene el uso de memoria constante — en ningún momento todo el archivo está en la RAM.

2. Pipelines de Datos

Los generadores son ideales para construir pipelines de procesamiento donde cada etapa es un generador independiente:

def leer_sensores():
    import random
    while True:
        yield random.uniform(20, 30)

def filtrar_valores(datos, minimo, maximo):
    for valor in datos:
        if minimo <= valor <= maximo:
            yield valor

def normalizar(datos, minimo, maximo):
    for valor in datos:
        yield (valor - minimo) / (maximo - minimo)

pipeline = normalizar(
    filtrar_valores(leer_sensores(), 22, 28),
    22, 28
)

for _ in range(5):
    print(f"{next(pipeline):.3f}")

3. Evaluación Perezosa en Data Science

En ciencia de datos, los generadores evitan cargar conjuntos de datos completos en memoria. Bibliotecas como pandas se integran bien con generadores para el procesamiento de datos en streaming.

def lotes(datos, tamano):
    """Divide un iterable en lotes de tamaño fijo."""
    lote = []
    for item in datos:
        lote.append(item)
        if len(lote) == tamano:
            yield lote
            lote = []
    if lote:
        yield lote

datos = range(100)
for i, lote in enumerate(lotes(datos, 10)):
    print(f"Lote {i}: {list(lote)}")
    if i >= 3:
        break

Iteración Bidireccional con send()

Los generadores no son solo productores unidireccionales de datos. El método send() permite enviar valores de vuelta al generador, que los recibe como el valor de la expresión yield. Esto convierte a los generadores en corutinas simples.

def corrector():
    print("Esperando corrección...")
    while True:
        valor = yield
        print(f"Corrigiendo: {valor ** 2}")

c = corrector()
next(c)  # Inicializa el generador
c.send(5)   # Corrigiendo: 25
c.send(10)  # Corrigiendo: 100

Aunque async/await ha reemplazado este patrón para corutinas modernas, entender send() es útil para depuración y para patrones específicos de comunicación entre generadores.

Comparación: Generadores vs Listas vs Iteradores

Característica Lista Iterador (clase) Generador
Memoria Alta (todo en RAM) Baja Bajísima
Reiterable No (un solo uso) No (un solo uso)
Acceso aleatorio Sí (índices) No No
Sintaxis [...] Verbosa Concisa
Lazy No
Puede ser infinito No

Cada enfoque tiene su lugar. Las listas son excelentes para colecciones pequeñas o cuando necesitas acceso aleatorio. Los iteradores son apropiados cuando necesitas control detallado. Los generadores son la elección ideal para la mayoría de los casos de procesamiento secuencial, combinando simplicidad con eficiencia.

Generadores en la Práctica: Ejemplo de Web Scraping

Veamos un ejemplo práctico que combina varios conceptos. Supón que necesitas extraer múltiples páginas de un sitio web y extraer información:

import time

def paginas_api(base_url, total_paginas):
    """Genera URLs de las páginas bajo demanda."""
    for i in range(1, total_paginas + 1):
        yield f"{base_url}?pagina={i}"

def descargar_paginas(urls):
    """Simula descarga (reemplaza con aiohttp o httpx)."""
    for url in urls:
        time.sleep(0.5)  # Simula latencia
        yield f"Datos de {url}"

def extraer_datos(respuestas):
    """Extrae información relevante de cada respuesta."""
    for respuesta in respuestas:
        yield {
            "fuente": respuesta,
            "items": len(respuesta),
            "timestamp": time.time()
        }

urls = paginas_api("https://api.ejemplo.com/datos", 100)
respuestas = descargar_paginas(urls)
datos = extraer_datos(respuestas)

# Consume solo 20 páginas, sin desperdicio
for i, item in enumerate(datos):
    if i >= 20:
        break
    print(f"{item['fuente']} - {item['items']} items")

Este patrón de pipeline con generadores mantiene el código limpio, modular y eficiente. Cada etapa se puede probar de forma independiente y el consumo de memoria es mínimo.

Si trabajas con web scraping, consulta también nuestro artículo sobre Lambda, Map, Filter y Reduce en Python — funciones que se integran perfectamente con generadores.

Consejos y Buenas Prácticas

1. Prefiere Generadores a Listas Grandes

Siempre que no necesites acceder a los datos más de una vez o necesites acceso aleatorio, prefiere generadores en lugar de listas. La diferencia de rendimiento y memoria es significativa, especialmente con grandes volúmenes.

2. Usa itertools para Composición

El módulo itertools ofrece funciones como chain(), zip_longest(), islice(), takewhile() y dropwhile() que se componen perfectamente con generadores. Combinar itertools con generator expressions es una de las formas más expresivas de escribir Python.

3. Cuidado con los Efectos Secundarios

Los generadores son ideales para computación pura — no para operaciones con efectos secundarios como escribir archivos o modificar variables globales. Si necesitas efectos secundarios, sé explícito y documéntalos.

4. No Te Excedas

Para colecciones pequeñas (unas pocas decenas de elementos), una lista simple es perfectamente aceptable. La optimización prematura es la raíz de todos los males — usa generadores cuando tenga sentido, no por dogma.

Referencias y Profundización

Para continuar tus estudios sobre generadores e iteradores, consulta estas fuentes confiables:

Conclusión

Los generadores e iteradores son herramientas fundamentales en el arsenal de cualquier desarrollador Python. Permiten escribir código más limpio, más eficiente y más expresivo, especialmente cuando se manejan grandes volúmenes de datos o flujos continuos.

El secreto está en la evaluación perezosa: computar solo lo que se necesita, cuando se necesita. Esto no solo ahorra memoria sino que también hace que el código sea más modular y comprobable.

Ahora que dominas estos conceptos, ¡practica! Crea tus propios generadores, experimenta con yield from, explora el módulo itertools y descubre cómo estas herramientas pueden transformar la calidad de tu código Python.

Para más contenido sobre Python, sigue explorando Universo Python!