Generadores e Iteradores en Python: Optimiza Memoria y Rendimiento

👤 Admin 📅 5 de noviembre, 2025 ⏱ 16 min 🏷 Python Avanzado

¿Qué son los Iteradores?

Un iterador es un objeto que implementa los métodos __iter__() y __next__(), permitiendo iterar sobre una secuencia de valores uno a la vez.

Protocolo de Iteración

# Cualquier objeto iterable puede usarse en un for
lista = [1, 2, 3, 4, 5]

for elemento in lista:
    print(elemento)

# Lo que realmente hace Python:
iterador = iter(lista)  # Llama a __iter__()
try:
    while True:
        elemento = next(iterador)  # Llama a __next__()
        print(elemento)
except StopIteration:
    pass  # Fin de la iteración

Crear un Iterador Personalizado

class Contador:
    def __init__(self, limite):
        self.limite = limite
        self.actual = 0
    
    def __iter__(self):
        # Retorna el objeto iterador (self)
        return self
    
    def __next__(self):
        if self.actual < self.limite:
            self.actual += 1
            return self.actual
        else:
            raise StopIteration

# Usar el iterador
contador = Contador(5)
for numero in contador:
    print(numero)  # 1, 2, 3, 4, 5

¿Qué son los Generadores?

Los generadores son una forma más simple de crear iteradores usando la palabra clave yield. Son funciones que pueden pausar su ejecución y reanudarla más tarde.

Crear un Generador Simple

# Función normal que retorna lista (usa mucha memoria)
def numeros_lista(n):
    resultado = []
    for i in range(n):
        resultado.append(i ** 2)
    return resultado

# Generador (eficiente en memoria)
def numeros_generador(n):
    for i in range(n):
        yield i ** 2

# Usar el generador
gen = numeros_generador(5)
print(next(gen))  # 0
print(next(gen))  # 1
print(next(gen))  # 4

# O iterar con for
for numero in numeros_generador(5):
    print(numero)

Ventajas de los Generadores

import sys

# Lista (carga todo en memoria)
lista = [i ** 2 for i in range(1000000)]
print(f"Tamaño lista: {sys.getsizeof(lista)} bytes")

# Generador (genera valores bajo demanda)
generador = (i ** 2 for i in range(1000000))
print(f"Tamaño generador: {sys.getsizeof(generador)} bytes")

# El generador es MUCHO más pequeño en memoria

Yield vs Return

# return - termina la función
def funcion_normal():
    return 1
    return 2  # Nunca se ejecuta
    return 3

print(funcion_normal())  # 1

# yield - pausa la función
def funcion_generadora():
    yield 1
    yield 2
    yield 3

gen = funcion_generadora()
print(next(gen))  # 1
print(next(gen))  # 2
print(next(gen))  # 3

Ejemplos Prácticos de Generadores

1. Leer Archivo Grande Línea por Línea

def leer_archivo_grande(nombre_archivo):
    with open(nombre_archivo, 'r') as archivo:
        for linea in archivo:
            yield linea.strip()

# No carga todo el archivo en memoria
for linea in leer_archivo_grande('archivo_grande.txt'):
    # Procesar línea por línea
    if 'buscar' in linea:
        print(linea)

2. Secuencia de Fibonacci

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

# Generar primeros 10 números de Fibonacci
fib = fibonacci()
for i in range(10):
    print(next(fib))
# 0, 1, 1, 2, 3, 5, 8, 13, 21, 34

3. Números Primos Infinitos

def numeros_primos():
    def es_primo(n):
        if n < 2:
            return False
        for i in range(2, int(n ** 0.5) + 1):
            if n % i == 0:
                return False
        return True
    
    n = 2
    while True:
        if es_primo(n):
            yield n
        n += 1

# Obtener primeros 20 primos
primos = numeros_primos()
for i in range(20):
    print(next(primos), end=' ')
# 2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71

4. Procesamiento de Datos en Lotes

def procesar_en_lotes(datos, tamaño_lote):
    for i in range(0, len(datos), tamaño_lote):
        yield datos[i:i + tamaño_lote]

# Procesar 1 millón de registros en lotes de 1000
datos = range(1000000)

for lote in procesar_en_lotes(list(datos), 1000):
    # Procesar cada lote
    print(f"Procesando lote con {len(lote)} elementos")
    # ... tu lógica aquí

Generator Expressions

# List comprehension - crea lista completa
cuadrados_lista = [x ** 2 for x in range(1000000)]

# Generator expression - genera bajo demanda
cuadrados_gen = (x ** 2 for x in range(1000000))

# Usar con sum (eficiente)
total = sum(x ** 2 for x in range(1000))
print(total)

# Filtrar con generator expression
pares = (x for x in range(100) if x % 2 == 0)
print(list(pares)[:10])  # Primeros 10

Send() - Enviar Valores al Generador

def contador_controlado():
    contador = 0
    while True:
        incremento = yield contador
        if incremento is not None:
            contador += incremento
        else:
            contador += 1

gen = contador_controlado()
print(next(gen))        # 0
print(next(gen))        # 1
print(gen.send(10))     # 11 (suma 10)
print(next(gen))        # 12
print(gen.send(5))      # 17 (suma 5)

Generadores con Múltiples Yields

def pipeline_datos(datos):
    # Etapa 1: Filtrar
    print("Filtrando...")
    for item in datos:
        if item > 0:
            yield item

def transformar(datos):
    # Etapa 2: Transformar
    print("Transformando...")
    for item in datos:
        yield item ** 2

def agregar(datos):
    # Etapa 3: Agregar
    print("Agregando...")
    total = 0
    for item in datos:
        total += item
    yield total

# Pipeline completo (lazy evaluation)
entrada = [-2, -1, 0, 1, 2, 3, 4, 5]
resultado = agregar(transformar(pipeline_datos(entrada)))
print(list(resultado))  # [55]

Decoradores con Generadores

def contador_llamadas(func):
    def wrapper(*args, **kwargs):
        wrapper.llamadas += 1
        print(f"Llamada #{wrapper.llamadas}")
        return func(*args, **kwargs)
    wrapper.llamadas = 0
    return wrapper

@contador_llamadas
def numeros_pares(n):
    for i in range(n):
        if i % 2 == 0:
            yield i

gen = numeros_pares(10)
for num in gen:
    print(num)

Generadores Anidados - yield from

# Sin yield from
def generador_anidado():
    for i in range(3):
        yield i
    for j in range(3, 6):
        yield j

# Con yield from (más elegante)
def generador_con_yield_from():
    yield from range(3)
    yield from range(3, 6)

for num in generador_con_yield_from():
    print(num)  # 0, 1, 2, 3, 4, 5

# Ejemplo práctico: aplanar lista anidada
def aplanar(lista):
    for elemento in lista:
        if isinstance(elemento, list):
            yield from aplanar(elemento)
        else:
            yield elemento

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

Itertools - Generadores Incorporados

import itertools

# count() - contador infinito
contador = itertools.count(start=10, step=2)
for i, num in enumerate(contador):
    if i >= 5:
        break
    print(num)  # 10, 12, 14, 16, 18

# cycle() - ciclo infinito
colores = itertools.cycle(['rojo', 'verde', 'azul'])
for i, color in enumerate(colores):
    if i >= 7:
        break
    print(color)  # rojo, verde, azul, rojo, verde, azul, rojo

# repeat() - repetir valor
repetir = itertools.repeat('Python', 3)
print(list(repetir))  # ['Python', 'Python', 'Python']

# chain() - encadenar iterables
lista1 = [1, 2, 3]
lista2 = [4, 5, 6]
encadenado = itertools.chain(lista1, lista2)
print(list(encadenado))  # [1, 2, 3, 4, 5, 6]

# combinations() - combinaciones
letras = ['A', 'B', 'C']
combinaciones = itertools.combinations(letras, 2)
print(list(combinaciones))  # [('A', 'B'), ('A', 'C'), ('B', 'C')]

# permutations() - permutaciones
permutaciones = itertools.permutations(['A', 'B', 'C'], 2)
print(list(permutaciones))  # [('A', 'B'), ('A', 'C'), ('B', 'A'), ('B', 'C'), ('C', 'A'), ('C', 'B')]

Caso de Uso Real: Pipeline de Datos

def leer_logs(archivo):
    """Lee archivo de logs línea por línea"""
    with open(archivo, 'r') as f:
        for linea in f:
            yield linea.strip()

def filtrar_errores(lineas):
    """Filtra solo líneas con ERROR"""
    for linea in lineas:
        if 'ERROR' in linea:
            yield linea

def extraer_timestamp(lineas):
    """Extrae timestamp de cada línea"""
    for linea in lineas:
        # Asume formato: [2025-11-17 20:00:00] ERROR ...
        if linea.startswith('['):
            timestamp = linea[1:20]
            yield timestamp

def contar_errores_por_hora(timestamps):
    """Cuenta errores agrupados por hora"""
    conteo = {}
    for ts in timestamps:
        hora = ts[:13]  # 2025-11-17 20
        conteo[hora] = conteo.get(hora, 0) + 1
    return conteo

# Pipeline completo (procesa millones de líneas eficientemente)
# pipeline = leer_logs('sistema.log')
# pipeline = filtrar_errores(pipeline)
# pipeline = extraer_timestamp(pipeline)
# resultado = contar_errores_por_hora(pipeline)
# print(resultado)

Comparación de Rendimiento

import time
import sys

def medir_tiempo_memoria(func, *args):
    start = time.time()
    resultado = func(*args)
    tiempo = time.time() - start
    memoria = sys.getsizeof(resultado)
    return tiempo, memoria, resultado

# Con lista (eager evaluation)
def procesar_lista(n):
    return [x ** 2 for x in range(n) if x % 2 == 0]

# Con generador (lazy evaluation)
def procesar_generador(n):
    return (x ** 2 for x in range(n) if x % 2 == 0)

n = 1000000

# Lista
t1, m1, l = medir_tiempo_memoria(procesar_lista, n)
print(f"Lista: {t1:.4f}s, {m1} bytes")

# Generador
t2, m2, g = medir_tiempo_memoria(procesar_generador, n)
print(f"Generador: {t2:.4f}s, {m2} bytes")

# El generador usa MUCHA menos memoria

Cuándo Usar Generadores

  • Grandes volúmenes de datos: Cuando no necesitas todos los datos en memoria
  • Secuencias infinitas: Fibonacci, números primos, etc.
  • Procesamiento de archivos: Archivos grandes línea por línea
  • Pipelines de datos: Transformaciones en cadena
  • Búsqueda early-exit: Cuando puedes detener la búsqueda antes de procesar todo

Buenas Prácticas

  • Documenta el comportamiento: Indica si el generador es infinito o finito
  • Usa nombres descriptivos: generar_fibonacci() en vez de gen()
  • Considera el contexto: No uses generadores si necesitas indexar o iterar múltiples veces
  • Aprovecha itertools: Usa las funciones integradas cuando sea posible
  • Testing: Convierte a lista para testing: list(mi_generador())

Conclusión

Los generadores e iteradores son herramientas poderosas para manejar datos eficientemente en Python. Permiten procesar grandes volúmenes de datos sin cargarlos completamente en memoria, hacer lazy evaluation, y crear secuencias infinitas elegantes. Dominar estos conceptos te convertirá en un programador Python más eficiente.