Generadores e iteradores en Python explicados con ejemplos

D
DanisCh
14 min de lectura
Generadores e iteradores en Python explicados con ejemplos
Python

Cuando aprendes Python por primera vez, los bucles for y las listas parecen suficientes para casi todo. Pero a medida que tus programas crecen y empiezas a trabajar con grandes volúmenes de datos, con ficheros enormes o con secuencias que no tienen un final definido, descubres que cargar todo en memoria a la vez es un problema real. Ahí es donde los generadores e iteradores se vuelven indispensables.

En esta guía vas a entender qué hay por debajo de cada bucle for que has escrito, cómo funcionan los iteradores, qué son los generadores, cuándo usarlos y por qué son una de las características más elegantes de Python.

El protocolo de iteración: qué ocurre dentro de un bucle for

Cuando escribes for elemento in coleccion, Python no hace magia. Sigue un protocolo preciso llamado protocolo de iteración, basado en dos métodos especiales:

  • __iter__(): devuelve el objeto iterador.
  • __next__(): devuelve el siguiente elemento. Cuando no hay más elementos, lanza StopIteration.
# Lo que Python hace internamente con un bucle for
numeros = [1, 2, 3]

# Este bucle:
for n in numeros:
    print(n)

# Es equivalente a esto:
iterador = iter(numeros)       # llama a numeros.__iter__()
while True:
    try:
        n = next(iterador)     # llama a iterador.__next__()
        print(n)
    except StopIteration:
        break                  # fin de la iteración
# Puedes usar iter() y next() directamente
numeros = [10, 20, 30]
it = iter(numeros)

print(next(it))   # 10
print(next(it))   # 20
print(next(it))   # 30
print(next(it))   # StopIteration ← aquí se acaba

# next() acepta un valor por defecto para evitar la excepción
print(next(it, "fin"))   # "fin" en lugar de StopIteration

Un objeto es iterable si tiene __iter__(). Un objeto es un iterador si tiene tanto __iter__() como __next__(). Todos los iteradores son iterables, pero no todos los iterables son iteradores. Una lista es iterable pero no es un iterador: iter(lista) devuelve un objeto iterador separado.

Crear un iterador personalizado con una clase

Antes de llegar a los generadores, conviene ver cómo se implementa un iterador "a mano" con una clase. Esto ayuda a entender exactamente lo que los generadores simplifican.

# Iterador que genera los primeros N números de la serie de Fibonacci
class Fibonacci:
    def __init__(self, limite):
        self.limite = limite
        self.a = 0
        self.b = 1
        self.contador = 0

    def __iter__(self):
        return self   # el propio objeto es el iterador

    def __next__(self):
        if self.contador >= self.limite:
            raise StopIteration

        valor = self.a
        self.a, self.b = self.b, self.a + self.b
        self.contador += 1
        return valor


# Usar el iterador en un bucle
for n in Fibonacci(8):
    print(n, end=" ")
# 0 1 1 2 3 5 8 13

# También funciona con next()
fib = Fibonacci(3)
print(next(fib))   # 0
print(next(fib))   # 1
print(next(fib))   # 1
print(next(fib))   # StopIteration

Funciona, pero es verboso. Hay que gestionar el estado manualmente (las variables a, b y contador), implementar dos métodos, y el código que describe la lógica de generación está mezclado con el código de infraestructura del iterador. Los generadores resuelven todo esto.

Generadores: iteradores con yield

Un generador es una función que usa la palabra clave yield en lugar de return. Cuando llamas a una función generadora, no ejecuta el cuerpo de la función: devuelve un objeto generador. Cada vez que pides el siguiente elemento con next(), la función se ejecuta hasta el próximo yield, devuelve ese valor y pausa su ejecución conservando todo su estado (variables locales, posición de ejecución). La próxima llamada a next() reanuda desde donde se pausó.

# La misma serie de Fibonacci, ahora como generador
def fibonacci(limite):
    a, b = 0, 1
    for _ in range(limite):
        yield a              # pausa aquí, devuelve 'a', conserva el estado
        a, b = b, a + b      # se ejecuta en la siguiente llamada a next()


# Usar el generador
for n in fibonacci(8):
    print(n, end=" ")
# 0 1 1 2 3 5 8 13

# O manualmente
gen = fibonacci(3)
print(next(gen))   # 0
print(next(gen))   # 1
print(next(gen))   # 1
print(next(gen))   # StopIteration

El código es más limpio y más corto. No hay clase, no hay gestión manual de estado, no hay StopIteration explícito (se lanza automáticamente cuando la función termina). La lógica de generación es lo único que está en el código.

Cómo funciona yield por dentro

def contador(inicio, fin):
    print(f"[inicio del generador, inicio={inicio}]")
    n = inicio
    while n <= fin:
        print(f"[antes de yield, n={n}]")
        yield n
        print(f"[después de yield, continuando con n={n}]")
        n += 1
    print("[fin del generador]")


gen = contador(1, 3)

print("Antes del primer next()")
print(next(gen))
print("Entre next() y next()")
print(next(gen))
print(next(gen))

# Salida:
# Antes del primer next()
# [inicio del generador, inicio=1]
# [antes de yield, n=1]
# 1                               ← next() retorna aquí y se pausa
# Entre next() y next()
# [después de yield, continuando con n=1]
# [antes de yield, n=2]
# 2
# [después de yield, continuando con n=2]
# [antes de yield, n=3]
# 3

Esto ilustra el punto clave: yield no solo devuelve un valor, suspende la ejecución de la función completa hasta la siguiente llamada a next(). Las variables locales y el punto de ejecución se conservan entre llamadas.

La ventaja principal: eficiencia de memoria

La diferencia entre una lista y un generador no es solo sintáctica: es fundamental en cuanto a uso de memoria.

import sys

# Lista: crea todos los valores en memoria a la vez
lista_millones = [x ** 2 for x in range(1_000_000)]
print(f"Lista:     {sys.getsizeof(lista_millones):,} bytes")   # ~8.448.728 bytes (~8 MB)

# Generador: no crea ningún valor hasta que se pide
gen_millones = (x ** 2 for x in range(1_000_000))
print(f"Generador: {sys.getsizeof(gen_millones):,} bytes")     # 104 bytes

# El generador siempre ocupa lo mismo independientemente del tamaño de la secuencia
gen_billones = (x ** 2 for x in range(1_000_000_000_000))
print(f"Generador billones: {sys.getsizeof(gen_billones):,} bytes")  # 104 bytes
# Caso real: leer un archivo de logs de 5 GB línea a línea
# ❌ MAL: carga el archivo entero en memoria
def contar_errores_mal(ruta):
    with open(ruta) as f:
        lineas = f.readlines()   # 5 GB en RAM
    return sum(1 for linea in lineas if "ERROR" in linea)


# ✅ BIEN: el generador lee una línea a la vez, memoria constante
def leer_lineas(ruta):
    with open(ruta) as f:
        for linea in f:
            yield linea.strip()

def contar_errores_bien(ruta):
    return sum(1 for linea in leer_lineas(ruta) if "ERROR" in linea)

# En ambos casos el resultado es idéntico, pero la versión con generador
# usa unos pocos kilobytes de memoria sin importar el tamaño del archivo

Expresiones generadoras

Al igual que las listas tienen su comprensión de listas con corchetes, los generadores tienen las expresiones generadoras con paréntesis. Son equivalentes a una función generadora simple.

# Comprensión de lista: crea la lista completa en memoria
cuadrados_lista = [x**2 for x in range(10)]

# Expresión generadora: lazy, genera los valores bajo demanda
cuadrados_gen = (x**2 for x in range(10))

# Son equivalentes en resultado pero diferentes en memoria
print(list(cuadrados_gen))   # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

# Uso directo en funciones que aceptan iterables
total  = sum(x**2 for x in range(1000))          # sin paréntesis extra
maximo = max(len(p) for p in ["python", "es", "genial"])
hay_par = any(n % 2 == 0 for n in [1, 3, 5, 6])

# Filtrado combinado con transformación
pares_grandes = (x for x in range(100) if x % 2 == 0 if x > 50)
print(list(pares_grandes))   # [52, 54, 56, ..., 98]

yield from: delegar en otro generador

yield from permite que un generador delegue la producción de valores a otro iterable o generador. Simplifica la composición de generadores y es más eficiente que un bucle for con yield.

# Sin yield from: bucle explícito
def aplanar_manual(listas):
    for sublista in listas:
        for elemento in sublista:
            yield elemento

# Con yield from: más limpio y eficiente
def aplanar(listas):
    for sublista in listas:
        yield from sublista   # delega la iteración a cada sublista

matriz = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
print(list(aplanar(matriz)))   # [1, 2, 3, 4, 5, 6, 7, 8, 9]


# yield from funciona con cualquier iterable
def combinar(*iterables):
    for iterable in iterables:
        yield from iterable

print(list(combinar([1, 2], "ab", (3, 4))))
# [1, 2, 'a', 'b', 3, 4]


# Composición de generadores para pipelines de datos
def leer_numeros(texto):
    for linea in texto.strip().split("\n"):
        yield linea.strip()

def filtrar_vacios(lineas):
    yield from (linea for linea in lineas if linea)

def convertir_a_int(lineas):
    for linea in lineas:
        try:
            yield int(linea)
        except ValueError:
            pass   # ignorar líneas no numéricas

texto = """
10
20
abc
30

40
"""

# Pipeline: leer → filtrar → convertir
numeros = convertir_a_int(filtrar_vacios(leer_numeros(texto)))
print(list(numeros))   # [10, 20, 30, 40]

Generadores infinitos

Una de las características más potentes de los generadores es que pueden representar secuencias infinitas. Como los valores se generan bajo demanda, no hay problema de memoria aunque la secuencia no tenga fin.

from itertools import islice

# Generador de números naturales sin fin
def naturales():
    n = 1
    while True:
        yield n
        n += 1

# Tomar solo los primeros 5
print(list(islice(naturales(), 5)))   # [1, 2, 3, 4, 5]


# Generador de potencias de 2
def potencias_de_2():
    n = 1
    while True:
        yield n
        n *= 2

# Los primeros 10
print(list(islice(potencias_de_2(), 10)))
# [1, 2, 4, 8, 16, 32, 64, 128, 256, 512]


# Generador de IDs únicos incrementales (útil en aplicaciones)
def generador_id(prefijo="ID"):
    contador = 1
    while True:
        yield f"{prefijo}-{contador:04d}"
        contador += 1

gen_id = generador_id("USR")
print(next(gen_id))   # USR-0001
print(next(gen_id))   # USR-0002
print(next(gen_id))   # USR-0003

El módulo itertools: herramientas para iteradores

Python incluye el módulo itertools con funciones que crean iteradores eficientes para combinaciones habituales. Conocerlas evita reinventar la rueda.

from itertools import (
    count, cycle, repeat,
    chain, islice, takewhile, dropwhile,
    groupby, product, combinations, permutations
)

# count(inicio, paso): enteros infinitos desde inicio
for n in islice(count(10, 2), 5):
    print(n, end=" ")   # 10 12 14 16 18

# cycle(iterable): repite el iterable infinitamente
colores = cycle(["rojo", "verde", "azul"])
for _ in range(7):
    print(next(colores), end=" ")
# rojo verde azul rojo verde azul rojo

# chain: encadena varios iterables como si fueran uno
total = list(chain([1, 2], [3, 4], [5, 6]))
print(total)   # [1, 2, 3, 4, 5, 6]

# takewhile: toma elementos mientras se cumpla la condición
print(list(takewhile(lambda x: x < 5, [1, 2, 3, 4, 5, 6, 1, 2])))
# [1, 2, 3, 4]

# dropwhile: descarta mientras se cumple, luego toma todo
print(list(dropwhile(lambda x: x < 5, [1, 2, 3, 4, 5, 6, 1, 2])))
# [5, 6, 1, 2]

# groupby: agrupar elementos consecutivos iguales
from itertools import groupby
datos = sorted([("A", 1), ("B", 2), ("A", 3), ("B", 4)], key=lambda x: x[0])
for clave, grupo in groupby(datos, key=lambda x: x[0]):
    print(clave, list(grupo))
# A [('A', 1), ('A', 3)]
# B [('B', 2), ('B', 4)]

# combinations y permutations
print(list(combinations("ABC", 2)))    # [('A','B'), ('A','C'), ('B','C')]
print(list(permutations("ABC", 2)))    # [('A','B'), ('A','C'), ('B','A'), ...]
print(list(product([0, 1], repeat=3))) # todas las combinaciones de 3 bits

Comunicación bidireccional con send()

Los generadores pueden recibir valores desde fuera usando el método send(). Esto los convierte en una forma de implementar corrutinas simples: el generador puede tanto producir valores como recibirlos.

# send() envía un valor al generador, que lo recibe como resultado de yield
def acumulador():
    total = 0
    while True:
        valor = yield total   # yield devuelve total hacia afuera Y recibe el siguiente valor
        if valor is None:
            break
        total += valor

gen = acumulador()
next(gen)         # inicializar el generador (avanzar hasta el primer yield)

print(gen.send(10))   # 10
print(gen.send(20))   # 30
print(gen.send(5))    # 35
print(gen.send(15))   # 50


# Caso de uso real: generador que filtra y transforma según instrucciones externas
def procesador_log():
    nivel_minimo = "INFO"
    niveles = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3}

    while True:
        linea = yield
        if linea is None:
            return

        # Recibir instrucción de cambio de nivel
        if isinstance(linea, str) and linea.startswith("SET_LEVEL:"):
            nivel_minimo = linea.split(":")[1]
            continue

        nivel, mensaje = linea
        if niveles.get(nivel, 0) >= niveles.get(nivel_minimo, 0):
            print(f"[{nivel}] {mensaje}")


proc = procesador_log()
next(proc)   # inicializar

proc.send(("DEBUG",   "mensaje de debug"))   # filtrado (nivel < INFO)
proc.send(("INFO",    "servidor iniciado"))  # [INFO] servidor iniciado
proc.send(("WARNING", "memoria al 80%"))     # [WARNING] memoria al 80%
proc.send("SET_LEVEL:WARNING")               # cambiar nivel mínimo
proc.send(("INFO",    "petición recibida"))  # filtrado (nivel < WARNING)
proc.send(("ERROR",   "conexión perdida"))   # [ERROR] conexión perdida

throw() y close(): controlar el ciclo de vida

# throw(): inyectar una excepción en el generador
def generador_robusto():
    try:
        while True:
            try:
                valor = yield
                print(f"Recibido: {valor}")
            except ValueError as e:
                print(f"Error manejado: {e}")
    except GeneratorExit:
        print("Generador cerrado limpiamente")


gen = generador_robusto()
next(gen)

gen.send(42)                          # Recibido: 42
gen.throw(ValueError, "dato inválido") # Error manejado: dato inválido
gen.send(100)                          # Recibido: 100
gen.close()                            # Generador cerrado limpiamente


# close() es útil para liberar recursos al terminar antes de time
def leer_archivo_chunked(ruta, tamanio=1024):
    with open(ruta, "rb") as f:
        while True:
            chunk = f.read(tamanio)
            if not chunk:
                break
            yield chunk
    # Al llegar aquí (o al llamar a close()), el with garantiza que el archivo se cierra

gen = leer_archivo_chunked("archivo_grande.bin")
primer_chunk = next(gen)
gen.close()   # el archivo se cierra aunque no hayamos llegado al final

Casos de uso reales

Pipeline de procesamiento de datos

import csv
from pathlib import Path

# Pipeline completo usando generadores encadenados
def leer_csv(ruta):
    with open(ruta, newline="", encoding="utf-8") as f:
        lector = csv.DictReader(f)
        yield from lector

def filtrar_activos(registros):
    for r in registros:
        if r.get("estado") == "activo":
            yield r

def enriquecer(registros):
    for r in registros:
        r["nombre_completo"] = f"{r['nombre']} {r['apellido']}".strip()
        r["salario_anual"] = float(r.get("salario_mensual", 0)) * 12
        yield r

def formatear(registros):
    for r in registros:
        yield {
            "nombre": r["nombre_completo"],
            "salario_anual": r["salario_anual"]
        }

# Construir el pipeline sin cargar nada en memoria hasta que se itera
def pipeline(ruta_csv):
    return formatear(enriquecer(filtrar_activos(leer_csv(ruta_csv))))

# Consumir el pipeline: cada registro se procesa uno a uno
for empleado in pipeline("empleados.csv"):
    print(f"{empleado['nombre']}: {empleado['salario_anual']:,.0f}€")

# Para guardar los resultados sin cargar todo en memoria:
with open("resultado.csv", "w") as f:
    for empleado in pipeline("empleados.csv"):
        f.write(f"{empleado['nombre']},{empleado['salario_anual']}\n")

Generador de páginas para APIs con paginación

import requests

def paginar_api(url_base, params=None, limite_paginas=None):
    """Generador que maneja la paginación de una API automáticamente."""
    params = params or {}
    pagina = 1

    while True:
        if limite_paginas and pagina > limite_paginas:
            break

        respuesta = requests.get(url_base, params={**params, "page": pagina})
        respuesta.raise_for_status()
        datos = respuesta.json()

        if not datos.get("resultados"):
            break

        yield from datos["resultados"]   # yield de cada elemento, no de la página completa
        pagina += 1

        if pagina > datos.get("total_paginas", 1):
            break


# Uso: iterar sobre todos los usuarios sin preocuparse por la paginación
for usuario in paginar_api("https://api.ejemplo.com/usuarios"):
    procesar(usuario)

Cuándo usar generadores y cuándo no

Usa un generador cuando:

  • Procesas grandes volúmenes de datos que no caben cómodamente en memoria.
  • La secuencia es potencialmente infinita o muy larga.
  • Solo necesitas iterar una vez sobre los datos.
  • Quieres crear pipelines de transformación perezosos (lazy).
  • Lees archivos grandes línea a línea o en bloques.

Usa una lista cuando:

  • Necesitas acceder a los elementos por índice (datos[5]).
  • Necesitas iterar sobre los datos múltiples veces.
  • Necesitas saber la longitud con len().
  • Los datos son pequeños y la diferencia de memoria es irrelevante.
  • Necesitas ordenar, invertir o hacer operaciones que requieren acceso aleatorio.
# Convertir un generador a lista cuando necesites múltiples iteraciones
gen = (x**2 for x in range(10))

# Un generador se agota después de iterar
print(list(gen))   # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
print(list(gen))   # []  ← el generador ya está agotado

# Si necesitas varias iteraciones, convierte a lista
cuadrados = list(x**2 for x in range(10))
print(sum(cuadrados))    # 285
print(max(cuadrados))    # 81
print(cuadrados[5])      # 25  ← acceso por índice

Resumen

  • Los iteradores implementan __iter__() y __next__(). Todo bucle for usa el protocolo de iteración por debajo.
  • Un generador es una función con yield que produce valores de uno en uno, pausando su ejecución entre llamadas y conservando el estado local.
  • La ventaja clave es la eficiencia de memoria: un generador ocupa siempre la misma cantidad de memoria independientemente del tamaño de la secuencia.
  • Las expresiones generadoras son la versión compacta: (expresion for elemento in iterable) con paréntesis en lugar de corchetes.
  • yield from delega la iteración a otro iterable y simplifica la composición de generadores.
  • Los generadores pueden representar secuencias infinitas sin problema.
  • itertools ofrece herramientas para combinaciones habituales: chain, islice, takewhile, groupby y muchas más.
  • send() permite comunicación bidireccional con el generador para implementar corrutinas simples.
  • Usa generadores para datos grandes o pipelines perezosos. Usa listas cuando necesites índices, múltiples iteraciones o len().
Etiquetas: Python

¿Te ha gustado esta entrada?

Compártela con tus compañeros para que también sigan aprendiendo.

Comunidad y Comentarios

0 COMENTARIOS

No hay comentarios todavía. Sé el primero en compartir tu opinión.

Escribe tu opinión
Respondiendo a