Curso
Los iteradores son objetos sobre los que se puede iterar. Son una característica habitual del lenguaje Python, muy útil para bucles y comprensiones de listas. Cualquier objeto que pueda generar un iterador se denomina iterable.
Crear un iterador conlleva bastante trabajo. Por ejemplo, la implementación de cada objeto iterador debe incluir los métodos __iter__() y __next__() . Además de este requisito, la implementación debe llevar un seguimiento del estado interno del objeto y lanzar una excepción StopIteration cuando ya no queden valores que devolver. Estas reglas se conocen como el protocolo de iteradores.
Implementar tu propio iterador es un proceso largo y no siempre necesario. Una alternativa más sencilla es usar un generador. Los generadores son un tipo especial de función que utiliza la palabra clave yield para devolver un iterador que se puede recorrer, un valor cada vez.
Saber identificar cuándo conviene implementar un iterador o usar un generador mejorará tus habilidades como programador de Python. En el resto de este tutorial, destacaremos las diferencias entre ambos para ayudarte a elegir la mejor opción en cada caso.
Glosario
|
Término |
Definición |
|
Iterable |
Objeto de Python que se puede recorrer en un bucle. Ejemplos de iterables: listas, conjuntos, tuplas, diccionarios, cadenas, etc. |
|
Iterador |
Objeto sobre el que se puede iterar. Por tanto, los iteradores contienen un número contable de valores. |
|
Generador |
Tipo especial de función que no devuelve un único valor: devuelve un objeto iterador con una secuencia de valores. |
|
Evaluación perezosa |
Estrategia de evaluación por la que ciertos objetos solo se generan cuando se necesitan. Por ello, en algunos círculos de desarrollo también se conoce como «call-by-need». |
|
Protocolo de iteradores |
Conjunto de reglas que se deben seguir para definir un iterador en Python. |
|
next() |
Función integrada que devuelve el siguiente elemento de un iterador. |
|
iter() |
Función integrada que convierte un iterable en un iterador. |
|
yield() |
Palabra clave de Python similar a return, salvo que |
Iteradores e iterables en Python
Los iterables son objetos capaces de devolver sus elementos uno a uno: se pueden recorrer. Las estructuras de datos integradas populares de Python, como listas, tuplas y conjuntos, son iterables. Otras estructuras como las cadenas y los diccionarios también lo son: una cadena puede iterarse carácter a carácter, y las claves de un diccionario se pueden recorrer. Como regla general, considera iterable a cualquier objeto que puedas usar en un bucle for.
Explorar iterables de Python con ejemplos
Dado lo anterior, podemos concluir que todos los iteradores también son iterables. Sin embargo, no todo iterable es necesariamente un iterador. Un iterable solo produce un iterador cuando se itera sobre él.
Para demostrarlo, vamos a instanciar una lista, que es un iterable, y a obtener un iterador llamando a la función integrada iter() sobre la lista.
list_instance = [1, 2, 3, 4]
print(iter(list_instance))
"""
<list_iterator object at 0x7fd946309e90>
"""
Aunque la lista por sí sola no es un iterador, al llamar a iter() se convierte en un iterador y se devuelve el objeto iterador.
Para demostrar que no todos los iterables son iteradores, vamos a crear la misma lista e intentar llamar a la función next(), que devuelve el siguiente elemento de un iterador.
list_instance = [1, 2, 3, 4]
print(next(list_instance))
"""
--------------------------------------------------------------------
TypeError Traceback (most recent call last)
<ipython-input-2-0cb076ed2d65> in <module>()
3 print(iter(list_instance))
4
----> 5 print(next(list_instance))
TypeError: 'list' object is not an iterator
"""
En el código anterior, puedes ver que intentar llamar a next() sobre la lista lanza un TypeError – aprende más sobre excepciones y manejo de errores en Python. Esto ocurre porque una lista es un iterable, no un iterador.
Explorar iteradores de Python con ejemplos
Por tanto, si el objetivo es iterar una lista, primero hay que obtener un objeto iterador. Solo entonces podremos avanzar por los valores de la lista.
# instanciar una lista
list_instance = [1, 2, 3, 4]
# convertir la lista en iterador
iterator = iter(list_instance)
# devolver elementos uno a uno
print(next(iterator))
print(next(iterator))
print(next(iterator))
print(next(iterator))
"""
1
2
3
4
"""
Python crea automáticamente un objeto iterador cuando intentas recorrer un objeto iterable.
# instanciar una lista
list_instance = [1, 2, 3, 4]
# recorrer la lista
for item in list_instance:
print(item)
"""
1
2
3
4
"""
Cuando se captura la excepción StopIteration, el bucle termina.
Los valores de un iterador solo se pueden recuperar de izquierda a derecha. Python no tiene una función previous() que permita retroceder en un iterador.
La naturaleza perezosa de los iteradores
Es posible definir varios iteradores basados en el mismo objeto iterable. Cada iterador mantiene su propio estado de progreso. Así, si defines varias instancias de iterador de un mismo iterable, puedes llegar al final con una mientras otra sigue al principio.
list_instance = [1, 2, 3, 4]
iterator_a = iter(list_instance)
iterator_b = iter(list_instance)
print(f"A: {next(iterator_a)}")
print(f"A: {next(iterator_a)}")
print(f"A: {next(iterator_a)}")
print(f"A: {next(iterator_a)}")
print(f"B: {next(iterator_b)}")
"""
A: 1
A: 2
A: 3
A: 4
B: 1
"""
Observa que iterator_b imprime el primer elemento de la serie.
Podemos decir que los iteradores tienen una naturaleza perezosa: cuando se crea un iterador, los elementos no se producen hasta que se solicitan. Es decir, los elementos de nuestra lista solo se devolverán cuando los pidamos explícitamente con next(iter(list_instance)).
No obstante, también puedes extraer todos los valores de un iterador de una vez llamando a un contenedor iterable integrado (p. ej., list(), set(), tuple()) sobre el objeto iterador para forzar que genere todos sus elementos a la vez.
# instanciar iterable
list_instance = [1, 2, 3, 4]
# producir un iterador a partir de un iterable
iterator = iter(list_instance)
print(list(iterator))
"""
[1, 2, 3, 4]
"""
No es recomendable con iteradores grandes porque obliga a generar todos los elementos y mantenerlos en memoria a la vez, lo que va en contra de la evaluación perezosa.
Cuando un conjunto de datos es demasiado grande para caber cómodamente en memoria, o cuando quieres iteración perezosa sin escribir una clase de iterador completa, un generador suele encajar mejor.
Generadores en Python
La alternativa más ágil a implementar un iterador es usar un generador. Aunque los generadores se parecen a las funciones de Python normales, no son lo mismo. Para empezar, un generador no devuelve elementos como tal. En su lugar, utiliza la palabra clave yield para generarlos sobre la marcha. Por eso decimos que un generador es una función especial que se apoya en la evaluación perezosa.
Los generadores no almacenan su contenido en memoria como lo haría un iterable típico. Por ejemplo, si quisiéramos calcular todos los divisores de un entero positivo, normalmente escribiríamos una función tradicional (puedes aprender más sobre las funciones en Python en este tutorial) así:
def factors(n):
factor_list = []
for val in range(1, n+1):
if n % val == 0:
factor_list.append(val)
return factor_list
print(factors(20))
"""
[1, 2, 4, 5, 10, 20]
"""
El código anterior devuelve la lista completa de divisores. Sin embargo, observa la diferencia cuando usamos un generador en lugar de una función tradicional de Python:
def factors(n):
for val in range(1, n+1):
if n % val == 0:
yield val
print(factors(20))
"""
<generator object factors at 0x7fd938271350>
"""
Como hemos usado yield en lugar de return, la función no termina tras ejecutarse. En esencia, le hemos dicho a Python que cree un objeto generador en lugar de una función tradicional, lo que permite mantener el estado del generador.
En consecuencia, es posible llamar a next() sobre el iterador perezoso para obtener los elementos de la serie uno a uno.
def factors(n):
for val in range(1, n+1):
if n % val == 0:
yield val
factors_of_20 = factors(20)
print(next(factors_of_20))
"""
1
"""
Otra forma de crear un generador es con una comprensión de generador. Las expresiones generadoras usan una sintaxis muy similar a la de las comprensiones de listas, pero con paréntesis en lugar de corchetes.
factor_gen = (val for val in range(1, 21) if 20 % val == 0)
print(list(factor_gen))
"""
[1, 2, 4, 5, 10, 20]
"""
Explorando la palabra clave yield en Python
La palabra clave yield controla el flujo de una función generadora. En lugar de salir de la función como ocurre con return, yield devuelve un valor pero recuerda el estado de sus variables locales.
El generador devuelto por la llamada a yield se puede asignar a una variable e iterar con la función next() – esto ejecutará la función hasta el primer yield que encuentre. Cuando se alcanza yield, la ejecución de la función se suspende y se guarda su estado. Así, podemos reanudar su ejecución cuando queramos.
La función continuará desde la llamada a yield. Por ejemplo:
def yield_multiple_statements():
yield "This is the first statement"
yield "This is the second statement"
yield "This is the third statement"
yield "This is the last statement. Don't call next again!"
example = yield_multiple_statements()
print(next(example))
print(next(example))
print(next(example))
print(next(example))
print(next(example))
"""
This is the first statement
This is the second statement
This is the third statement
This is the last statement. Don't call next again or else!
--------------------------------------------------------------------
StopIteration Traceback (most recent call last)
<ipython-input-25-4aaf9c871f91> in <module>()
11 print(next(example))
12 print(next(example))
---> 13 print(next(example))
StopIteration:
"""
En el ejemplo anterior, nuestro generador tiene cuatro llamadas a yield, pero intentamos llamar a next cinco veces, lo que provoca una excepción StopIteration. Ocurre porque el generador no es una serie infinita, así que llamarlo más veces de las previstas lo agota.
Resumen
En resumen, los iteradores son objetos que se pueden recorrer, y los generadores son funciones especiales que aprovechan la evaluación perezosa. Si implementas tu propio iterador, debes crear los métodos __iter__() y __next__(); en cambio, un generador se puede implementar usando la palabra clave yield en una función o en una comprensión de Python.
Puede que prefieras un iterador a medida frente a un generador cuando necesites un objeto con un comportamiento complejo de mantenimiento de estado o quieras exponer otros métodos más allá de __next__(), __iter__() y __init__(). Por otro lado, un generador puede ser preferible al trabajar con grandes volúmenes de datos, ya que no almacenan su contenido en memoria, o cuando no es necesario implementar un iterador completo.

FAQS
¿Cuál es la diferencia entre un iterador y un generador en Python?
Un iterador es cualquier objeto que implementa __iter__() y __next__(). Un generador es una forma más sencilla de crear un iterador usando una función con la palabra clave yield. Todos los generadores son iteradores, pero no todos los iteradores son generadores.
¿Cuándo debería usar un generador en lugar de una lista en Python?
Usa un generador para secuencias grandes o infinitas, o cuando te importe la eficiencia de memoria. Las listas guardan todos los elementos a la vez en memoria, mientras que los generadores producen un valor cada vez. Para conjuntos pequeños que reutilizarás, una lista suele ser suficiente.
¿Qué hace la palabra clave yield en Python?
La palabra clave yield convierte una función en un generador. En lugar de devolver y salir, yield pausa la función, devuelve un valor y recuerda su estado para reanudar la ejecución en la siguiente llamada.
¿Cómo se crea un generador en Python?
O bien escribes una función que use yield en lugar de return, o utilizas una expresión generadora: la misma sintaxis que una comprensión de lista pero con paréntesis, por ejemplo (x * 2 for x in range(10)).
¿Son más rápidos los generadores que los iteradores en Python?
No en velocidad pura, pero sí son más eficientes en memoria porque generan los valores bajo demanda. Para datos grandes eso suele traducirse en mejor rendimiento global; para conjuntos pequeños, la diferencia es mínima.