26- Python Iterators en python




Python Iterators

🚶‍♂️ Python Iterators: Recorriendo Elementos de Forma Secuencial

En Python, un **iterator** es un objeto que permite recorrer los elementos de una colección de datos (como listas, tuplas, diccionarios, conjuntos, cadenas, etc.) uno por uno. Los iteradores proporcionan una forma eficiente de acceder a los elementos de una colección sin necesidad de cargar toda la colección en la memoria al mismo tiempo. Esto es especialmente útil cuando se trabaja con conjuntos de datos grandes.

¿Cómo Funcionan los Iterators?

Los iteradores en Python se basan en dos métodos principales:

  • __iter__(): Este método se llama en un objeto iterable para obtener el iterador. Si el objeto ya es un iterador, el método __iter__() devuelve el propio objeto.
  • __next__(): Este método se llama en el iterador para obtener el siguiente elemento de la secuencia. Cuando no hay más elementos disponibles, el método __next__() debe levantar una excepción llamada StopIteration.

Ejemplo Sencillo de Iteración Manual:

Veamos un ejemplo de cómo podemos iterar manualmente a través de una lista utilizando los métodos __iter__() y __next__().

      
mi_lista = [1, 2, 3]

# Obtener el iterador de la lista
mi_iterador = iter(mi_lista)

# Acceder a los elementos utilizando next()
print(next(mi_iterador)) # Output: 1
print(next(mi_iterador)) # Output: 2
print(next(mi_iterador)) # Output: 3

# Intentar acceder al siguiente elemento levantará StopIteration
try:
    print(next(mi_iterador))
except StopIteration:
    print("¡Fin de la iteración!")
      
     

En este ejemplo, la función incorporada iter() se utiliza para obtener el iterador de la lista mi_lista. Luego, la función incorporada next() se utiliza para obtener cada elemento del iterador secuencialmente. Una vez que se han recorrido todos los elementos, llamar a next() de nuevo levanta la excepción StopIteration, indicando que no hay más elementos.

Iterators y los Bucles for:

La belleza de los iteradores radica en que los bucles for en Python los utilizan internamente. Cuando escribimos un bucle for para iterar sobre una colección, Python automáticamente obtiene un iterador para esa colección y llama a next() en cada iteración hasta que se levanta la excepción StopIteration, momento en el que el bucle finaliza.

      
mi_lista = [10, 20, 30]
for elemento in mi_lista:
    print(elemento)
# Output:
# 10
# 20
# 30
      
     

En este caso, Python detrás de escena hace algo similar a lo que hicimos manualmente en el ejemplo anterior.

En el siguiente tema, exploraremos la diferencia entre iterators e iterables.

Iterator vs Iterable

🔄 Iterator vs Iterable: No Son Exactamente lo Mismo

En Python, un **iterable** es cualquier objeto que puede devolver sus miembros uno a la vez. Ejemplos comunes de iterables incluyen listas, tuplas, cadenas, diccionarios y conjuntos. Técnicamente, un iterable es un objeto que implementa el método __iter__(), el cual devuelve un objeto iterator.

Un **iterator**, por otro lado, es el objeto actual que realiza la iteración. Es un objeto que recuerda su estado mientras se recorren los elementos y sabe cómo obtener el siguiente valor. Un iterator debe implementar dos métodos: __iter__() (que devuelve el propio objeto iterator) y __next__() (que devuelve el siguiente elemento y levanta StopIteration cuando no hay más).

En Resumen:

  • Iterable: Un objeto del cual se puede obtener un iterator. Piensa en él como una colección que puedes recorrer.
  • Iterator: Un objeto que te permite recorrer un iterable. Recuerda el punto actual en la iteración.

Analogía: Un Libro y un Lector

Una analogía útil es pensar en un libro y un lector:

  • El libro es el **iterable**. Contiene la información (los elementos) que se pueden leer secuencialmente. Puedes volver al principio del libro y leerlo de nuevo.
  • El **lector** es el **iterator**. Mantiene el seguimiento de la página en la que se encuentra actualmente. El lector avanza página por página (llamando a __next__()) hasta que llega al final del libro (cuando se levanta StopIteration). Una vez que el lector termina el libro, necesita un nuevo "lector" (un nuevo iterator) para leerlo de nuevo desde el principio.

Ejemplo para Ilustrar la Diferencia:

      
mi_lista = [1, 2, 3]

# mi_lista es un iterable
print(type(mi_lista)) # Output: <class 'list'>

# Obtener el iterator de mi_lista
mi_iterador = iter(mi_lista)
print(type(mi_iterador)) # Output: <class 'list_iterator'>

# Podemos iterar sobre el iterator
print(next(mi_iterador)) # Output: 1
print(next(mi_iterador)) # Output: 2
print(next(mi_iterador)) # Output: 3

# Si intentamos obtener otro iterator de mi_iterador, obtenemos el mismo objeto
otro_iterador = iter(mi_iterador)
print(otro_iterador is mi_iterador) # Output: True

# Una vez que el primer iterador se agota, el segundo también lo está
try:
    print(next(otro_iterador))
except StopIteration:
    print("¡El iterador ya se agotó!")

# Para iterar de nuevo sobre la lista, necesitamos un nuevo iterator
nuevo_iterador = iter(mi_lista)
print(next(nuevo_iterador)) # Output: 1
      
     

Este ejemplo muestra que:

  • Una lista es un iterable.
  • Al llamar a iter() en la lista, obtenemos un objeto iterator.
  • Un iterator puede ser iterado usando next().
  • El método __iter__() de un iterator devuelve el propio iterator.
  • Una vez que un iterator se ha agotado (ha levantado StopIteration), no se puede reiniciar. Necesitas crear un nuevo iterator a partir del iterable original para recorrer los elementos de nuevo.

En el siguiente tema, veremos cómo se recorre un iterator utilizando bucles.

Looping Through an Iterator

➡️ Looping Through an Iterator: La Forma Habitual de Recorrer

Aunque hemos visto cómo iterar manualmente a través de un iterator usando la función next(), la forma más común y Pythónica de recorrer los elementos de un iterator (y por extensión, un iterable) es utilizando bucles, principalmente el bucle for.

El Bucle for y los Iterators: Una Pareja Perfecta

Como mencionamos anteriormente, el bucle for está diseñado para trabajar directamente con iterables. Cuando se itera sobre un iterable con un bucle for, Python automáticamente:

  1. Obtiene un iterator del iterable llamando a su método __iter__().
  2. En cada iteración del bucle, llama al método __next__() del iterator para obtener el siguiente elemento.
  3. Continúa hasta que el método __next__() levanta la excepción StopIteration, momento en el que el bucle finaliza de manera elegante.

Todo este proceso ocurre "detrás de las escenas", lo que hace que el código de iteración sea muy limpio y fácil de leer.

Ejemplo de Iteración con for:

      
mi_tupla = (100, 200, 300)

for elemento in mi_tupla:
    print(elemento)
# Output:
# 100
# 200
# 300

mi_cadena = "Python"
for caracter in mi_cadena:
    print(caracter)
# Output:
# P
# y
# t
# h
# o
# n
      
     

En ambos casos, mi_tupla y mi_cadena son iterables. El bucle for automáticamente obtiene sus respectivos iterators y los recorre hasta el final.

Otros Bucles que Utilizan Iterators:

Aunque el bucle for es el más común para la iteración, otras construcciones en Python también utilizan el mecanismo de iterators internamente, como:

  • Comprensiones de listas, diccionarios y conjuntos:
    
    cuadrados = [x**2 for x in [1, 2, 3]] # Utiliza un iterator para [1, 2, 3]
          
  • Expresiones generadoras:
    
    generador_cuadrados = (x**2 for x in [1, 2, 3]) # Crea un iterable que genera valores bajo demanda
    for cuadrado in generador_cuadrados:
        print(cuadrado)
          
  • La función list(), tuple(), set(), etc.: Estas funciones pueden tomar un iterable como argumento y consumen su iterator para crear una nueva lista, tupla o conjunto.
    
    lista_desde_cadena = list("Hola") # Utiliza un iterator de la cadena
    print(lista_desde_cadena) # Output: ['H', 'o', 'l', 'a']
          

En resumen, el bucle for proporciona una forma concisa y legible de iterar sobre cualquier objeto que sea iterable, gracias al uso interno de los iterators.

En el siguiente tema, aprenderemos cómo crear nuestros propios iterators.

Create an Iterator

🛠️ Crear un Iterator Personalizado en Python

Para crear un iterator personalizado, necesitamos definir una clase que cumpla con el protocolo del iterator: implementar los métodos __iter__() y __next__().

Implementando __iter__():

El método __iter__() debe devolver el propio objeto iterator. Esto es necesario para que el objeto sea iterable y pueda ser utilizado en bucles for (que esperan obtener un iterator al llamar a iter() en el objeto).

Implementando __next__():

El método __next__() debe devolver el siguiente elemento de la secuencia. Cuando no haya más elementos disponibles, este método debe levantar la excepción StopIteration.

Ejemplo: Un Iterator que Genera Números Pares

Vamos a crear un iterator que genere una secuencia de números pares hasta un valor máximo especificado.

      
class GeneradorPares:
    def __init__(self, maximo):
        self.maximo = maximo
        self.numero = 0

    def __iter__(self):
        return self

    def __next__(self):
        self.numero += 2
        if self.numero > self.maximo:
            raise StopIteration
        return self.numero

# Crear un iterable (en este caso, la clase misma actúa como iterable y iterator)
pares_hasta_10 = GeneradorPares(10)

# Iterar sobre el objeto usando un bucle for
for par in pares_hasta_10:
    print(par)
# Output:
# 2
# 4
# 6
# 8
# 10

# Intentar iterar de nuevo no producirá nada porque el iterator ya se agotó
print("Intentando iterar de nuevo:")
for par in pares_hasta_10:
    print(par)
# (No hay output)
      
     

En este ejemplo:

  • La clase GeneradorPares se inicializa con un valor máximo.
  • El método __iter__() devuelve self, ya que esta clase actuará como su propio iterator.
  • El método __next__() incrementa el número actual en 2. Si el número supera el máximo, levanta StopIteration. En caso contrario, devuelve el número par actual.

Separando el Iterable del Iterator:

En muchos casos, es más común tener una clase iterable que crea un objeto iterator separado. Esto permite crear múltiples iteradores independientes para el mismo iterable.

      
class MiIterable:
    def __init__(self, data):
        self.data = data

    def __iter__(self):
        return MiIterador(self.data)

class MiIterador:
    def __init__(self, data):
        self.data = data
        self.index = 0

    def __iter__(self):
        return self

    def __next__(self):
        if self.index >= len(self.data):
            raise StopIteration
        value = self.data[self.index]
        self.index += 1
        return value

# Crear un iterable
mi_iterable = MiIterable([10, 20, 30])

# Obtener dos iteradores independientes
iterador1 = iter(mi_iterable)
iterador2 = iter(mi_iterable)

# Iterar sobre el primer iterador
print("Iterador 1:")
print(next(iterador1)) # Output: 10
print(next(iterador1)) # Output: 20

# Iterar sobre el segundo iterador (independiente)
print("\nIterador 2:")
print(next(iterador2)) # Output: 10
print(next(iterador2)) # Output: 20
print(next(iterador2)) # Output: 30

# Continuar con el primer iterador
print("\nIterador 1 (continuación):")
print(next(iterador1)) # Output: 30

# El segundo iterador ya se agotó
try:
    print(next(iterador2))
except StopIteration:
    print("¡Iterador 2 agotado!")
      
     

En esta versión, MiIterable es la clase iterable que simplemente almacena los datos y su método __iter__() crea y devuelve una instancia de MiIterador. La clase MiIterador es la que realmente implementa la lógica de la iteración y mantiene el estado (el índice actual).

En el siguiente tema, veremos la excepción StopIteration con más detalle.

StopIteration

🛑 StopIteration: Señalando el Fin de la Iteración

La excepción StopIteration es una excepción incorporada en Python que se utiliza para indicar que un iterator ha agotado todos sus elementos y no hay más valores para devolver.

Cuándo se Levanta StopIteration:

Dentro del método __next__() de un iterator, una vez que se han recorrido todos los elementos de la secuencia, la siguiente llamada a __next__() debe levantar la excepción StopIteration. Esto es fundamental para que los mecanismos de iteración de Python (como los bucles for) sepan cuándo detenerse.

El Papel de StopIteration en los Bucles:

Como mencionamos anteriormente, los bucles for funcionan internamente obteniendo un iterator de un iterable y llamando repetidamente a su método __next__(). El bucle continúa ejecutándose para cada valor devuelto por __next__(). Cuando __next__() levanta la excepción StopIteration, el bucle for detecta esta señal y finaliza su ejecución de manera controlada, sin generar un error visible para el usuario.

Ejemplo de StopIteration en un Iterator Personalizado:

Volvamos a nuestro ejemplo del GeneradorPares para ver cómo se levanta StopIteration.

      
class GeneradorPares:
    def __init__(self, maximo):
        self.maximo = maximo
        self.numero = 0

    def __iter__(self):
        return self

    def __next__(self):
        self.numero += 2
        if self.numero > self.maximo:
            raise StopIteration
        return self.numero

pares = GeneradorPares(6)

print(next(pares)) # Output: 2
print(next(pares)) # Output: 4
print(next(pares)) # Output: 6

try:
    print(next(pares))
except StopIteration:
    print("¡Se levantó StopIteration!") # Output: ¡Se levantó StopIteration!
      
     

En este código, después de que el iterator pares genera los números 2, 4 y 6, la siguiente llamada a next(pares) hace que el método __next__() evalúe la condición self.numero > self.maximo (que ahora es 8 > 6) como verdadera, lo que provoca que se levante la excepción StopIteration.

Manejo de StopIteration Manualmente:

Aunque los bucles for manejan automáticamente la excepción StopIteration, si estás utilizando la función next() directamente, debes estar preparado para capturar esta excepción utilizando un bloque try...except para evitar que tu programa termine inesperadamente.

      
mi_iterador = iter([1, 2])

print(next(mi_iterador)) # Output: 1
print(next(mi_iterador)) # Output: 2

try:
    print(next(mi_iterador))
except StopIteration:
    print("No hay más elementos.") # Output: No hay más elementos.
      
     

En Resumen:

  • StopIteration es una excepción que los iterators levantan para indicar que han llegado al final de la secuencia.
  • Los bucles for utilizan esta excepción para saber cuándo detener la iteración.
  • Si trabajas directamente con next(), debes manejar StopIteration para evitar errores.

Con esto, hemos cubierto los aspectos fundamentales de los iterators en Python. ¡Espero que esta lección haya sido informativa!




Publicar un comentario

0 Comentarios