Iteradores, iterables y la función next()

Iteradores, iterables y la función next()

Si leíste o escuchaste hablar de iteradores u objetos iterables, cosa bastante frecuente en Python, o tuviste contacto con códigos que hacían uso de las funciones next() o iter() y te interesa saber de qué se trata todo eso, este es el artículo indicado. Los iteradores y objetos iterables (o simplemente iterables) se usan todo el tiempo, aunque a menudo de forma indirecta, y constituyen la base del bucle for y de funcionalidades más complejas como los generadores (yield) y las tareas asincrónicas (async).

Tanto los iteradores como los iterables son objetos de Python. Así, para dar una primera definición formal de estos conceptos, diremos que un iterador es un objeto que implementa el método __next__(), mientras que un iterable es un objeto que implementa el método __iter__() (en Python los métodos cuyos nombres comienzan y terminan con doble guión bajo son conocidos como métodos mágicos). Esto implica que si a es un iterador y b un objeto iterable, las llamadas a.__iter__() y b.__next__() son necesariamente válidas. En cuanto a su funcionalidad, la tarea de un iterador es obtener elementos de un contenedor, que es cualquier tipo de dato que permita almacenar más de un valor, como las listas, tuplas y diccionarios. Un contenedor es iterable si hay un iterador que es capaz de obtener sus elementos.

Para comprender mejor estas definciones formales y funcionales, será enriquecedor identificar cuál es el problema que iteradores e iterables vienen a resolver. Para ello, supongamos que somos Guido van Rossum, el creador de Python, a fines de la década del 80 y que estamos a punto de inventar uno de los lenguajes más populares del mundo. Llegó la hora de implementar la funcionalidad del bucle for. Como sabemos, la palabra reservada for sirve para recorrer uno por uno los elementos de un contenedor, por ejemplo:

# Imprimir cada uno de los elementos de la lista en una nueva línea.
lista = ["A", "B", "C", "D"]
for elemento in lista:
    print(elemento)

Si tenemos que implementar un código que interprete un bucle for como este, sin entrar en demasiado detalle acerca de cómo funciona el intérprete de Python, lo más lógico sería programarlo con un bucle while. El intérprete oficial de Python está escrito en C (de ahí el nombre «CPython»), y en este lenguaje el while es exactamente igual que el while de Python. Así que tomémonos la licencia de escribirlo también en Python, resultando en algo como esto:

# Implementación del código anterior usando un while.
lista = ["A", "B", "C", "D"]
i = 0
while i < len(lista):
    elemento = lista[i]
    print(elemento)  # Cuerpo del bucle "for".
    i += 1

Parece entonces que la lógica interna del bucle for para recorrer los elementos de un contenedor sería la siguiente: (a) crear un contador que comience en cero (línea 3), (b) obtener el elemento en esa posición y asignárselo a la variable indicada (línea 5), (c) ejecutar el cuerpo del bucle (línea 6), (d) aumentar el contador en una unidad. Luego el proceso vuelve a empezar desde el paso (b) hasta que el contador sea igual a la cantidad de elementos (línea 4).

¡Excelente! Ya implementamos el bucle for en nuestro aún-por-crearse Python. No obstante, solo lo hemos probado con una lista. ¿Funciona con una tupla?

tupla = ("A", "B", "C", "D")
i = 0
while i < len(tupla):
    elemento = tupla[i]
    print(elemento)  # Cuerpo del bucle "for".
    i += 1

Funciona con una tupla. ¿Funciona con un diccionario?

diccionario = {"a": 1, "b": 2, "c": 3}
i = 0
while i < len(diccionario):
    elemento = diccionario[i]
    print(elemento)  # Cuerpo del bucle "for".
    i += 1

No funciona. En seguida arroja la siguiente excepción:

Traceback (most recent call last):
    [...]
    elemento = diccionario[i]
KeyError: 0

Esto ocurre porque nuestro código trabaja internamente con un contador para obtener cada uno de los elementos. Pero las claves de los diccionarios no necesariamente serán números, como en este caso, que son cadenas ("a", "b", "c").

¿Funciona con conjuntos (sets)?

conjunto = {"A", "B", "C", "D"}
i = 0
while i < len(conjunto):
    elemento = conjunto[i]
    print(elemento)  # Cuerpo del bucle "for".
    i += 1

Tampoco funciona, pues los conjuntos ni siquiera soportan la sintaxis conjunto[i], habida cuenta de que los elementos no están ordenados. En su lugar, para obtener un elemento debería usarse conjunto.pop().

Sin embargo, en Python todos estos contenedores y muchos otros son soportados por el bucle for:

diccionario = {"a": 1, "b": 2, "c": 3}
conjunto = {"A", "B", "C", "D"}
rango = range(1, 10)

for clave in diccionario:
    print(clave)

for elemento in conjunto:
    print(elemento)

for numero in rango:
    print(numero)

De modo que deberíamos modificar nuestra implementación original para que considere no solo listas y tuplas, sino también diccionarios, conjuntos, rangos, y todos los otros objetos sobre los cuales podemos usar un bucle for. Aunque esto podría hacerse chequeando el tipo de dato del contenedor y ejecutando el código que corresponda para recorrerlo (a través de las claves si es un diccionario, usando pop() si es un conjunto, etc.), no sería una implementación muy inteligente. En efecto, además del aspecto organizativo del código (la implementación del for sería muy voluminosa si tuviese que contemplar cada uno de los casos), con esta solución Python solo sería capaz de recorrer los contenedores que hayan sido estimados al momento de programar el intérprete, sin la posibilidad de que los programadores creen sus propios objetos susceptibles de ser recorridos con un for (y esto es algo que de hecho Python permite).

Aquí es donde aparecen los iteradores. En lugar de que la implementación del for contemple cada contenedor habido y por haber, cada tipo de dato (listas, tuplas, diccionarios, rangos, conjuntos, cadenas, etc.) será responsable de proveer su propio iterador que contenga dentro de sí la lógica para recorrer sus elementos. Por eso dijimos al principio, y reiteramos ahora, que la tarea de un iterador es obtener elementos de un contenedor. El funcionamiento de un iterador es bastante sencillo: expone una función __next__() que retorna un elemento del contenedor o lanza la excepción StopIteration cuando no restan elementos por retornar. El iterador de una lista podría verse más o menos así:

class IteradorLista:

    def __init__(self, lista):
        self.lista = lista
        self.i = 0
    
    def __next__(self):
        if self.i >= len(self.lista):
            raise StopIteration
        item = self.lista[self.i]
        self.i += 1
        return item

Esta clase recibe como argumento una lista y retorna sus elementos en orden a través de cada llamada a __next__(). Cuando el contador interno indica que ya se han retornado todos los elementos, arroja StopIteration. Comprobémoslo:

lista = ["A", "B", "C", "D"]
iterador_lista = IteradorLista(lista)
print(iterador_lista.__next__())  # A
print(iterador_lista.__next__())  # B
print(iterador_lista.__next__())  # C
print(iterador_lista.__next__())  # D
print(iterador_lista.__next__())  # StopIteration

Por lo general, para obtener un elemento del contenedor, en lugar de invocar directamente a __next__(), se emplea la función incorporada next():

lista = ["A", "B", "C", "D"]
iterador_lista = IteradorLista(lista)
print(next(iterador_lista))  # A
print(next(iterador_lista))  # B
print(next(iterador_lista))  # C
print(next(iterador_lista))  # D
print(next(iterador_lista))  # StopIteration

(La función next() internamente invoca asimismo el método __next__() del iterador que recibe como argumento. La única diferencia es que next() soporta un segundo argumento que permite especificar un valor por defecto cuando el iterador lanza StopIteration).

Si establecemos que cada contenedor debe proveer su propio iterador para indicarle a un bucle for cómo debe recorrer sus elementos, la lógica del for ya no debe contemplar cada caso en particular y puede sencillamente limitarse a llamar a next() hasta que el iterador lance StopIteration. Así, siempre que Python se encuentre con un código como este:

lista = ["A", "B", "C", "D"]
for elemento in lista:
    print(elemento)

Internamente el intérprete ejecutará:

contenedor = ["A", "B", "C", "D"]
# Obtener un iterador para este contenedor.
iterador_contenedor = iter(contenedor)
while True:
    try:
        elemento = next(iterador_contenedor)
    except StopIteration:
        break
    print(elemento)  # Cuerpo del bucle "for".

Así, no importa qué tipo de dato tenga el contenedor, siempre y cuando provea su propio iterador, y esa es precisamente la definición de objeto iterable. Un objeto es iterable si implementa la función __iter__(), que debe retornar un iterador para ese objeto iterable. Pero en lugar de invocar directamente a contenedor.__iter__(), usamos, igual que en el caso de __next__(), la función incorporada iter().

Los contenedores incorporados de Python, tales como listas, tuplas, diccionarios, conjuntos, rangos, etc., todos proveen sus propios iteradores, de modo que todos ellos son objetos iterables. Podemos comprobarlo llamando a iter() sobre cada uno:

>>> lista = ["A", "B", "C", "D"]
>>> diccionario = {"a": 1, "b": 2, "c": 3}
>>> conjunto = {"A", "B", "C", "D"}
>>> rango = range(1, 10)
>>> iter(lista)
<list_iterator object at 0x000001A0913B9700>
>>> iter(diccionario)
<dict_keyiterator object at 0x000001A091411DB0>
>>> iter(conjunto)
<set_iterator object at 0x000001A091418340>
>>> iter(rango)
<range_iterator object at 0x000001A090A589D0>

list_iterator es simplemente una implenentación en C (en el intérprete oficial de Python) de nuestro IteradorLista. El código está incluso disponible en GitHub. El equivalente a nuestro IteradorLista.__init__() es el siguiente:

static PyObject *
list_iter(PyObject *seq)
{
    listiterobject *it;

    if (!PyList_Check(seq)) {
        PyErr_BadInternalCall();
        return NULL;
    }
    it = PyObject_GC_New(listiterobject, &PyListIter_Type);
    if (it == NULL)
        return NULL;
    /* Esto equivale a self.i = 0 */
    it->it_index = 0;
    Py_INCREF(seq);
    /* Esto a self.lista = lista */
    it->it_seq = (PyListObject *)seq;
    _PyObject_GC_TRACK(it);
    return (PyObject *)it;
}

Y el equivalente a IteradorLista.__next__():

static PyObject *
listiter_next(listiterobject *it)
{
    PyListObject *seq;
    PyObject *item;

    assert(it != NULL);
    seq = it->it_seq;
    if (seq == NULL)
        return NULL;
    assert(PyList_Check(seq));

    /* Equivalente a nuestro if self.i >= len(self.lista): */
    if (it->it_index < PyList_GET_SIZE(seq)) {
        /* item = self.lista[self.i] */
        item = PyList_GET_ITEM(seq, it->it_index);
        /* self.i += 1 */
        ++it->it_index;
        Py_INCREF(item);
        /* return item */
        return item;
    }

    it->it_seq = NULL;
    Py_DECREF(seq);
    return NULL;
}

Falta aquí únicamente el lanzamiento de StopIteration, que, puesto que es una lógica común a todos los iteradores, es realizado en otra parte del código de CPython.

dict_keyiterator, set_iterator y range_iterator hacen lo propio para diccionarios, conjuntos y rangos, respectivamente. Una implementación del set_iterator en Python podría verse así:

class IteradorConjunto:

    def __init__(self, conjunto):
        self.conjunto = conjunto
    
    def __next__(self):
        if not self.conjunto:
            raise StopIteration
        return self.conjunto.pop()

Si un objeto a es iterable, podemos obtener su iterador correspondiente vía iter(a). Si no es iterable, iter() lanza TypeError. Por ejemplo, los enteros no son iterables:

>>> a = 5
>>> iter(a)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'int' object is not iterable

Si un objeto no es iterable, por extensión tampoco podemos hacer un bucle for sobre él, pues ya sabemos que internamente Python intenta obtener un iterador cada vez que interpreta un for. Incluso la excepción es exactamente la misma:

>>> a = 5
>>> for n in a:
...     print(n)
...
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'int' object is not iterable

Si el objeto it es un iterador, luego podemos llamar a next(it) retieradamente para obtener cada uno de los elementos del contenedor al que está asociado. Una vez que se retornaron todos los elementos, las sucesivas llamadas a next(it) lanzan StopIteration. No hay forma de «reiniciar» un iterador para que vuelva a retornar elementos una vez lanzada StopIteration. En caso de volver a necesitar recorrer los elementos, simplemente obténgase otro iterador vía una nueva llamada a iter().

>>> lista = ["A", "B", "C"]
>>> it = iter(lista)
>>> next(it)
'A'
>>> next(it)
'B'
>>> next(it)
'C'
>>> next(it)  # Todos los elementos fueron retornaods.
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>> it = iter(lista)  # Volver a obtener otro iterador.
>>> next(it)
'A'

Un bucle for puede llamarse indistintamente sobre un iterable o un iterador. Los siguientes códigos son equivalentes:

lista = ["A", "B", "C", "D"]
# Aquí Python internamente obtiene el list_iterator
# vía iter(lista) y lo recorre.
for elemento in lista:
    print(elemento)

# Aquí obtenemos manualmente un iterador para la
# lista y luego lo recorremos.
iterador_lista = iter(lista)
for elemento in iterador_lista:
    print(elemento)

Nótese que, en el segundo caso, Python igualmente intentará llamar a iter(iterador_lista). Es por eso que los iteradores, además de implementar __next__(), deben también implementar __iter__() y retornar una referencia a sí mismos para que puedan ser utilizados en un for. Por consiguiente, nuestro IteradorLista, para considerarse un iterador según las normas exigidas por Python (expresadas en la documentación), debería ser:

class IteradorLista:

    def __init__(self, lista):
        self.lista = lista
        self.i = 0
    
    def __iter__(self):
        return self
    
    def __next__(self):
        if self.i >= len(self.lista):
            raise StopIteration
        item = self.lista[self.i]
        self.i += 1
        return item

Sería razonable preguntarse: ¿por qué iterables e iteradores son dos objetos diferentes? ¿No podría una lista implementar directamente __next__(), de modo que no fuera necesario primero obtener su iterador vía iter()? Una implementación tal es perfectamente plausible, pero tendría el efecto (no deseado) de que luego de recorrer una lista no sería posible volver a recorrerla (a menos que se la vuelva a crear). Hay objetos donde este es el comportamiento esperado, como los archivos, los cuales, una vez leído todo su contenido, no es posible volver a leerlos si no es cerrando y volviendo a abrirlos.

Hasta aquí la explicación sobre iteradores y objetos iterables. Aunque no vayamos efectivamente a crear nuestro propio intérprete de Python, es sumamente útil saber cómo implementa varios de los mecanismos que utilizamos a diario para poder crear códigos más pythonicos y eficientes. Veamos, ahora, algunos casos reales donde la utilización manual o creación de iteradores constituye una alternativa inteligente para solucionar un problema.

Ejemplo 1. Usando next() e iter()

Véase Factorización con números primos para un ejemplo que emplea las funciones next() e iter() para resolver un algoritmo de factorización.

Ejemplo 2. Implementando un iterador

Supongamos que tenemos un archivo nombres.txt que contiene un nombre en cada línea:

Juan
Sofía
Camila
Daniel

Si quisiéramos imprimir cada uno de esos nombres en pantalla, podríamos hacer:

with open("nombres.txt", encoding="utf8") as f:
    for nombre in f:
        # Remover el salto de línea e imprimir el nombre.
        print(nombre.strip())

f es un iterador que en cada llamada a next() retorna una línea del archivo, pero con el beneficio de no cargar el archivo completamente en memoria: cada línea es desechada de la memoria una vez leída la siguiente, lo cual posibilita leer archivos muy grandes. El método alternativo es f.readlines(), que devuelve una lista con todas las líneas cargadas en memoria.

Ahora bien, si en lugar de tener los nombres separados por saltos de línea los tuviéramos separados por guiones medios, el archivo se vería así:

Juan-Sofía-Camila-Daniel

¿Cómo podemos recorrer ahora este nuevo archivo para imprimir un nombre por línea? Desde luego, podemos cargar todo el contenido del archivo y separarlo vía split("-"):

with open("nombres.txt", encoding="utf8") as f:
    nombres = f.read().split("-")
    for nombre in nombres:
        print(nombre)

Esto puede funcionar para archivos pequeños como el nuestro, ¿pero qué ocurre si tenemos un archivo de varios gigas con miles de millones de nombres? Nuestro programa rápidamente consumiría toda la memoria RAM que tiene asignada. Ahora que conocemos los iteradores, podemos crear un iterador que se ocupe de leer los nombres separados por guiones uno por uno.

class IteradorNombres:

    def __init__(self, f):
        self.f = f
    
    def __iter__(self):
        return self
    
    def __next__(self):
        # Guarda los caracteres leídos hasta encontrar
        # un guión o el final del archivo.
        caracteres = []
        while True:
            # Leer carácter por carácter.
            c = self.f.read(1)
            # Si se obtiene un guión, retornar los caracteres
            # leídos hasta el momento.
            if c == "-":
                break
            # Si no hay más caracteres por leer...
            elif not c:
                if caracteres:
                    # ...y si quedaron caracteres por retornar, 
                    # retornarlos.
                    break
                else:
                    # ...y si no quedaros más caracteres por retornar,
                    # indicar que el iterador está agotado.
                    raise StopIteration
            caracteres.append(c)
        return "".join(caracteres)


with open("nombres.txt", encoding="utf8") as f:
    for nombre in IteradorNombres(f):
        print(nombre)



Deja una respuesta