Iteradores y Generadores

Este articulo  como varios de los que verán está enfocado al lenguaje de programación Python, pero tiene los conceptos generales que de bemos tener sobre iteradores para otros lenguajes. Así que hoy resolveremos preguntas como ¿Qué es un iterador? ¿Qué es un iterable? ¿Para qué se usan los iteradores? ¿Cómo usarlos? ¿Cual es la diferencia entre in iterador y un iterable? ¿Diferencia entre un iterador y un generador?

¿Qué es un iterador?

En resumidas cuentas, «Un iterador básicamente es un objeto que permite recorrer uno a uno los elementos almacenados en una estructura de datos, y operar con ellos». Un iterador muchas veces es llamado “cursor”, especialmente dentro del contexto de una base de datos.

En este sentio, un objeto tipo iterator es un objeto que representa un flujo de datos, el cual puede ser recorrido en un proceso iterativo, como un bucle for, dentro de una función map o filter, en la creación de una list comprehension o generador(que explicaremos luego), o en una comparación in.

Todo objeto iterator contiene implementado un método __next__() que es llamado en cada iteración devolviendo los sucesivos elementos del flujo de datos cada vez. El flujo de datos del objeto no tiene por qué estar guardado en memoria, sino que puede ser generado en tiempo real en cada iteración.

El objeto iterator guarda un estado interno para saber cuál fue el último elemento obtenido. Así, en la siguiente llamada al método __next__(), se obtendrá el siguiente elemento correcto. Cuando ya no quedan más elementos en el flujo de datos del iterator, la función __next__() lanza StopIteration. El estado interno no se reinicia automáticamente al llegar al final del flujo o al empezar a recorrerlo de nuevo. Es decir, sólo se puede recorrer una vez.

Además, tiene implementado el método __iter__() que devuelve el propio objeto iterator. Esto es necesario para poder implementar bucles con objetos iterator, como explicaremos después.

Así que.. ¿Qué es un iterable?

Es un tipo de objeto que devuelve sus elementos de uno en uno cada vez. Tiene implementado alguno de estos dos métodos:

  • __iter__() que devuelve un objeto iterator a partir de este objeto iterable.
  • __getitem__() que accede a cada uno de los elementos para índices empezando desde 0.

Un objeto iterable no tiene por qué tener definido el método __next__(), En cambio, al tener la obligación de implementar el método __iter__() o __getitem__(), puede ser utilizado como argumento para la función iter() y así recorrer el iterator resultante.

Ahora bien,

En Python existen diferentes estructuras de datos que pueden ser recorridas secuencialmente mediante el uso de bucles. Estos objetos llamados iteradores, básicamente, son secuencias, contenedores y ficheros de texto. Los «Contenedores» son objetos que tienen definidos el método __getitem__() (además de __setitem__() o __delitem__() si el contenedor es mutable) para acceder a los elementos del contenedor. Dependiendo que el contenedor sea una secuencia o un mapeador (mapping), el valor pasado por argumento a __getitem__() puede ser un índice o un objeto slicing para el primero, o una clave única para el segundo.

La función __getitem__() se ejecuta implícitamente al usar el operador [] con el valor argumento de acceso entre corchetes.

Las listas y tuplas son contenedores secuencia que además son iterables al tener implementado el método __iter__() y el método __getitem__() (para índices empezando desde 0). Pero en cambio no son iterator porque no contienen el método __next__(). Tanto Iterable como Iterator son clases abstractas definidas dentro del módulo collections las cuales contienen los métodos abstractos __iter__() o __next__(). Por lo tanto, una instancia de una clase heredada de Iterable debe implementar el método __iter__() (haciendo que cumpla una de las condiciones anteriores para iterables); y una instancia de una clase heredada de Iterator debe implementar los métodos __iter__() y __next__() cumpliendo las condiciones de iterator explicadas anteriormente.

En particular, en Python, los iteradores tienen que implementar un método next que debe devolver los elementos, de a uno por vez, comenzando por el primero. Y al llegar al final de la estructura, debe levantar una excepción de tipo StopIteration.

Es decir que las siguientes estructuras son equivalentes:

 
for elemento in secuencia:
    # hacer algo con elemento
 
iterador = iter(secuencia)
while True:
    try:
        elemento = iterador.next()
    except StopIteration:
        break
    # hacer algo con elemento

La declaración for/in se utiliza con frecuencia para recorrer los elementos de distintos tipos de iteradores: los caracteres de una cadena, los elementos de una lista o una tupla, las claves y/o valores de un diccionario e incluso las líneas de un archivo:

# Recorrer los caracteres de una cadena:

cadena = "Python"
for caracter in cadena:
    print(caracter)

# Recorrer caracteres de cadena anterior, en sentido inverso.
    
for caracter in cadena[::-1]:
    print(caracter)

# Recorrer los elementos de una lista

lista = ['una', 'lista', 'es', 'un', 'iterable']
for palabra in lista:
    print(palabra)
    
# Recorrer los elementos de la lista anterior, al revés

for palabra in lista[::-1]:
    print(palabra)

# Obtener índice para recorrer todos los elementos de la lista

for indice in range(len(lista)):
    print (indice, lista[indice])

# Recorrer las claves de un diccionario

artistas = { 'Lorca' : 'Escritor', 'Goya' : 'Pintor'}
for clave, valor in artistas.items():
    print(clave,':',valor)
    
# Leer las líneas de un archivo de texto, una a una

for linea in open("datos.txt"):
    print(linea.rstrip())

La función iter()

La función iter() se suele emplear para mostrar cómo funciona en realidad un bucle implementado con for/in. Antes del inicio del bucle la función iter() retorna el objeto iterable con el método subyacente __iter__(). Una vez iniciado el bucle, el método __next__() permite avanzar, en cada ciclo, al siguiente elemento hasta alcanzar el último. Cuando el puntero se encuentra en el último elemento si se ejecuta nuevamente el método __next__() el programa produce la excepción StopIteration:

 


lista = [10, 100, 1000, 10000]
iterador = iter(lista)
try:
    while True:
        print(iterador.__next__())        

except StopIteration:
    print("Se ha alcanzado el final de la lista")

Implementando una clase para iterar cadenas
Los métodos __next__() y __iter__() permiten declarar clases para crear iteradores a medida.


# Declara clase para recorrer caracteres de cadena de texto 
# desde el último al primer carácter

class Invertir:
     def __init__(self, cadena):
         self.cadena = cadena
         self.puntero = len(cadena)
     def __iter__(self):
         return(self) 
     def __next__(self):
         if self.puntero == 0:
             raise(StopIteration)
         self.puntero = self.puntero - 1
         return(self.cadena[self.puntero])

# Declara iterable y recorre caracteres

cadena_invertida = Invertir('Iterable')
iter(cadena_invertida)

for caracter in cadena_invertida:
     print(caracter, end=' ')

# Devuelven caracteres que restan por iterar (ninguno):

print(list(cadena_invertida.__iter__()))  # []

La función range()

Cuando se desea ejecutar un bucle un número de veces determinado se suele utilizar la función range() que genera un rango de valores numéricos iterables que no necesitan ser almacenados en una lista o tupla.
for elemento in range(1, 11):
    print(elemento, end=' ')  # 1 2 3 4 5 6 7 8 9 10 

for elemento in range(10, 0, -1):
    print(elemento, end=' ')  # 10 9 8 7 6 5 4 3 2 1    

Generadores

Los generadores son una forma sencilla y potente de iterador. Un generador es una función especial que produce secuencias completas de resultados en lugar de ofrecer un único valor. En apariencia es como una función típica pero en lugar de devolver los valores con return lo hace con la declaración yield. Hay que precisar que el término generador define tanto a la propia función como al resultado que produce.

Una característica importante de los generadores es que tanto las variables locales como el punto de inicio de la ejecución se guardan automáticamente entre las llamadas sucesivas que se hagan al generador, es decir, a diferencia de una función común, una nueva llamada a un generador no inicia la ejecución al principio de la función, sino que la reanuda inmediatamente después del punto donde se encuentre la última declaración yield (que es donde terminó la función en la última llamada).

# Declara generador

def gen_basico():
    yield "uno"   
    yield "dos"
    yield "tres"
   
for valor in gen_basico():
    print(valor)  # uno, dos, tres

# Crea objeto generador y muestra tipo de objeto

generador = gen_basico()
print(generador)  # generator object gen_basico at 0x7f75ffad55e8
print(type(generador))  # class 'generator'

# Convierte a lista el objeto generador y muestra elementos

lista = list(generador)
print(lista)  # ['uno', 'dos', 'tres']
print(type(lista))  # class 'list'



El siguiente generador produce una sucesión de 10 valores numéricos a partir de un valor inicial. 
El valor final se obtiene sumando 10 al inicial y el bucle se ejecuta mientras el valor inicial es menor 
que el final. El ejemplo muestra como se almacenan los valores de las variables en cada ciclo y el punto donde se reanuda el bucle en cada llamada.

def gen_diez_numeros(inicio):
    fin = inicio + 10    
    while inicio < fin:
        inicio+=1
        yield inicio, fin

for inicio, fin in gen_diez_numeros(23):
    print(inicio, fin)


En un generador la declaración yield puede aparecer en varías líneas e incluso dentro de un bucle. El intérprete Python producirá una excepción de tipo StopIteration si encuentra el comando return durante la ejecución de un generador.

Deja un comentario