Iteradores y generadores
Introducción
Los iteradores y generadores son herramientas fundamentales en Python para trabajar con secuencias de datos de manera eficiente. Estas construcciones nos permiten procesar colecciones de elementos uno a uno, sin necesidad de cargar todos los datos en memoria simultáneamente. Esto resulta especialmente útil cuando trabajamos con grandes volúmenes de información o con secuencias potencialmente infinitas.
En este artículo exploraremos qué son los iteradores, cómo funcionan internamente, cómo crear nuestros propios iteradores personalizados y cómo los generadores nos ofrecen una forma simplificada y elegante de crear iteradores en Python.
¿Qué son los iteradores?
En Python, un iterador es un objeto que representa una secuencia de datos y permite recorrerla elemento por elemento. Los iteradores implementan el protocolo de iteración, que consta de dos métodos especiales:
__iter__()
: Devuelve el propio objeto iterador.__next__()
: Devuelve el siguiente elemento de la secuencia, o lanza la excepciónStopIteration
cuando no quedan más elementos.
Cuando usamos un bucle for
en Python, estamos aprovechando este protocolo de iteración internamente.
Iterables vs. Iteradores
Es importante distinguir entre iterables e iteradores:
- Iterable: Un objeto que implementa el método
__iter__()
y puede devolver un iterador. Ejemplos: listas, tuplas, diccionarios, cadenas. - Iterador: Un objeto que implementa los métodos
__iter__()
y__next__()
.
Veamos un ejemplo de cómo Python utiliza iteradores internamente:
# Bucle for con una lista (un iterable)
numeros = [1, 2, 3, 4, 5]
for numero in numeros:
print(numero)
# Equivalente a:
numeros = [1, 2, 3, 4, 5]
iterador = iter(numeros) # Obtiene el iterador de la lista
try:
while True:
numero = next(iterador) # Obtiene el siguiente elemento
print(numero)
except StopIteration:
pass # No hay más elementos
Creando nuestros propios iteradores
Podemos crear nuestros propios iteradores implementando una clase con los métodos __iter__()
y __next__()
:
class ContadorReverso:
"""Un iterador que cuenta de manera regresiva hasta cero."""
def __init__(self, inicio):
self.contador = inicio
def __iter__(self):
return self
def __next__(self):
if self.contador <= 0:
raise StopIteration
self.contador -= 1
return self.contador + 1
# Usando nuestro iterador personalizado
for numero in ContadorReverso(5):
print(numero) # Imprime: 5, 4, 3, 2, 1
En este ejemplo, ContadorReverso
es un iterador que comienza desde un número dado y cuenta hacia atrás hasta 1.
Iteradores infinitos
También podemos crear iteradores que generen secuencias infinitas. En estos casos, debemos tener cuidado de no intentar consumir toda la secuencia con funciones como list()
:
class ContadorInfinito:
"""Un iterador que cuenta hasta el infinito."""
def __init__(self, inicio=0):
self.numero = inicio
def __iter__(self):
return self
def __next__(self):
valor_actual = self.numero
self.numero += 1
return valor_actual
# Usando el iterador infinito (con precaución)
contador = ContadorInfinito(1)
for _ in range(5): # Limitamos a 5 iteraciones
print(next(contador)) # Imprime: 1, 2, 3, 4, 5
Introducción a los generadores
Crear clases iteradoras puede ser algo verboso. Python ofrece una solución más elegante mediante generadores. Un generador es una función especial que produce una secuencia de resultados en lugar de un único valor.
La característica clave de los generadores es que usan la palabra clave yield
en lugar de return
. Cuando se llama a un generador, devuelve un objeto iterador, pero el cuerpo de la función no se ejecuta inmediatamente. La ejecución comienza cuando se llama al método next()
del iterador.
def contador_reverso(inicio):
"""Un generador que cuenta de manera regresiva hasta cero."""
while inicio > 0:
yield inicio
inicio -= 1
# Usando el generador
for numero in contador_reverso(5):
print(numero) # Imprime: 5, 4, 3, 2, 1
Observa cómo el generador contador_reverso
es mucho más conciso que la clase ContadorReverso
que vimos antes, pero logra exactamente lo mismo.
Estado de los generadores
Cada vez que un generador produce un valor con yield
, guarda su estado interno (variables locales y posición en el código) y devuelve el control al llamador. Cuando se solicita el siguiente valor, el generador reanuda su ejecución desde donde la dejó:
def generador_ejemplo():
print("Primera ejecución")
yield 1
print("Segunda ejecución")
yield 2
print("Tercera ejecución")
yield 3
# Usando el generador para ver su comportamiento
gen = generador_ejemplo()
print(next(gen)) # Imprime: Primera ejecución, 1
print(next(gen)) # Imprime: Segunda ejecución, 2
print(next(gen)) # Imprime: Tercera ejecución, 3
Expresiones generadoras
Así como tenemos comprensión de listas, Python también ofrece expresiones generadoras. Son similares a las comprensiones de listas pero devuelven un iterador en lugar de crear una lista completa en memoria:
# Comprensión de lista (crea toda la lista en memoria)
cuadrados_lista = [x**2 for x in range(10)]
print(cuadrados_lista) # Imprime: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
# Expresión generadora (crea un iterador, no calcula todos los valores de inmediato)
cuadrados_gen = (x**2 for x in range(10))
print(cuadrados_gen) # Imprime algo como: <generator object <genexpr> at 0x...>
# Iterando sobre la expresión generadora
for cuadrado in cuadrados_gen:
print(cuadrado) # Imprime cada cuadrado uno por uno
La principal ventaja de las expresiones generadoras es la eficiencia de memoria, ya que no necesitan almacenar todos los valores a la vez.
Generadores para procesamiento de datos
Los generadores son excelentes para procesar grandes volúmenes de datos, como leer archivos línea por línea:
def leer_archivo_grande(nombre_archivo):
"""Lee un archivo grande línea por línea."""
with open(nombre_archivo, 'r') as archivo:
for linea in archivo:
yield linea.strip()
# Para usar este generador (si tuviéramos un archivo llamado 'datos.txt'):
# for linea in leer_archivo_grande('datos.txt'):
# print(linea)
Generadores como pipelines de datos
Podemos encadenar generadores para crear pipelines de procesamiento de datos:
def leer_numeros(nombre_archivo):
"""Lee números de un archivo, uno por línea."""
with open(nombre_archivo, 'r') as archivo:
for linea in archivo:
yield int(linea.strip())
def numeros_pares(numeros):
"""Filtra solo los números pares."""
for numero in numeros:
if numero % 2 == 0:
yield numero
def duplicar(numeros):
"""Duplica cada número."""
for numero in numeros:
yield numero * 2
# Encadenando generadores para crear un pipeline de procesamiento
# Si tuviéramos un archivo 'numeros.txt' con un número por línea:
# pipeline = duplicar(numeros_pares(leer_numeros('numeros.txt')))
# for resultado in pipeline:
# print(resultado)
Métodos de envío y finalización
Los generadores también tienen métodos adicionales que permiten una comunicación bidireccional:
send()
: Envía un valor al generador y continúa la ejecución hasta el siguienteyield
.throw()
: Lanza una excepción dentro del generador.close()
: Cierra el generador, finalizando su ejecución.
def eco():
"""Un generador que hace eco de los valores que recibe."""
valor = None
while True:
# 'yield valor' devuelve valor y espera recibir un nuevo valor
# que se asigna a 'valor'
valor = yield valor
print(f"Recibido: {valor}")
# Usando el generador con send()
generador = eco()
next(generador) # Inicializa el generador (llega al primer yield)
print(generador.send("Hola")) # Envía "Hola" y obtiene el mismo valor de vuelta
print(generador.send("Mundo")) # Envía "Mundo" y obtiene el mismo valor de vuelta
generador.close() # Cierra el generador
El decorador @contextlib.contextmanager
Los generadores también se pueden usar para implementar administradores de contexto mediante el decorador @contextlib.contextmanager
:
from contextlib import contextmanager
@contextmanager
def archivo_temporal(nombre_archivo, modo='w'):
"""Un administrador de contexto que cierra automáticamente el archivo."""
archivo = open(nombre_archivo, modo)
try:
yield archivo # Proporciona el archivo al bloque with
finally:
archivo.close() # Asegura que el archivo se cierre
# Uso del administrador de contexto basado en generador
# with archivo_temporal('temporal.txt') as archivo:
# archivo.write('Datos temporales')
# # El archivo se cierra automáticamente al salir del bloque with
Ventajas de los generadores sobre las listas
- Eficiencia de memoria: Los generadores calculan valores sobre la marcha, sin almacenar toda la secuencia en la memoria.
- Evaluación perezosa: Los elementos se calculan solo cuando se necesitan, lo que ahorra tiempo de procesamiento.
- Secuencias infinitas: Pueden representar secuencias infinitas, algo imposible con listas.
- Composición: Pueden componerse fácilmente para crear pipelines de procesamiento.
# Comparación de memoria y tiempo entre lista y generador
# Con lista (todo en memoria a la vez)
import sys
def numeros_lista(n):
return [i for i in range(n)]
lista = numeros_lista(1000000)
print(f"La lista ocupa {sys.getsizeof(lista) / 1024 / 1024:.2f} MB")
# Con generador (un valor a la vez)
def numeros_generador(n):
for i in range(n):
yield i
generador = numeros_generador(1000000)
print(f"El generador ocupa {sys.getsizeof(generador) / 1024:.2f} KB")
La diferencia de memoria es considerable, especialmente para secuencias grandes.
Resumen
Los iteradores y generadores son herramientas fundamentales en Python para trabajar con secuencias de datos de manera eficiente. Los iteradores implementan el protocolo de iteración con los métodos __iter__()
y __next__()
, mientras que los generadores ofrecen una forma simplificada y más elegante de crear iteradores mediante la palabra clave yield
.
Estas construcciones son especialmente útiles cuando necesitamos procesar grandes volúmenes de datos con un bajo consumo de memoria, trabajar con secuencias potencialmente infinitas o crear pipelines de procesamiento encadenados. Los generadores permiten la evaluación perezosa, calculando los valores solo cuando son necesarios, lo que mejora el rendimiento en muchas situaciones.
A medida que avances en tu aprendizaje de Python, encontrarás que los iteradores y generadores son componentes clave de la programación funcional y del procesamiento de datos eficiente. Dominar estas herramientas te permitirá escribir código más elegante, eficiente y escalable.