iteradores y generadores en Python iter next yield guía

Iteradores y generadores en Python — cómo funciona el for por dentro

Los iteradores y generadores en Python son de esos temas de FP2 que al principio parecen un concepto abstracto y difícil de visualizar, y que de repente hacen clic cuando entiendes que llevas usándolos desde el primer día sin saberlo. Cada vez que escribes un for en Python estás usando un iterador.

En este artículo vemos qué hay detrás de eso, cómo construir los tuyos propios y por qué los generadores son la forma más elegante de hacerlo.

Antes de empezar — qué diferencia un iterable de un iterador

Esta es la confusión más común en FP2 y la que más vale aclarar desde el principio, porque los dos términos suenan muy parecidos pero no son lo mismo.

Iterable — cualquier objeto que puedes recorrer con un for. Una lista, una cadena, un diccionario, un rango, todos son iterables. Lo que los hace iterables es que tienen un método __iter__() que devuelve un iterador.

Iterador — el objeto que realmente hace el recorrido, elemento a elemento. Tiene dos métodos: __iter__() que se devuelve a sí mismo, y __next__() que devuelve el siguiente elemento. Cuando no quedan más elementos lanza StopIteration.

La analogía que más me ayudó a entenderlo: un libro es el iterable: puedes leerlo. El dedo que va señalando la página actual es el iterador: sabe en qué punto estás y avanza uno a uno.

# Una lista es iterable pero no es un iterador
mi_lista = [1, 2, 3]
print(type(mi_lista))    # → <class 'list'>

# iter() convierte el iterable en iterador
mi_iterador = iter(mi_lista)
print(type(mi_iterador)) # → <class 'list_iterator'>

Las funciones iter() y next() — el bucle for por dentro

Cuando escribes un for en Python, Python no hace magia, ejecuta exactamente esto por debajo:

# Lo que TÚ escribes
for numero in [1, 2, 3]:
    print(numero)

# Lo que Python REALMENTE hace
lista = [1, 2, 3]
iterador = iter(lista)          # llama a lista.__iter__()
while True:
    try:
        numero = next(iterador) # llama a iterador.__next__()
        print(numero)
    except StopIteration:
        break                   # el iterador se agotó → sale del bucle

iter() llama al método __iter__() del objeto. next() llama al método __next__(). Cuando no hay más elementos __next__() lanza StopIteration y el while termina. Eso es todo lo que hay detrás de cada for que hayas escrito en FP1.

Puedes usar iter() y next() directamente:

numeros = [10, 20, 30]
it = iter(numeros)

print(next(it))    # → 10
print(next(it))    # → 20
print(next(it))    # → 30
print(next(it))    # → StopIteration — no hay más elementos

Fíjate en algo importante: una vez que el iterador se agota no vuelve al principio. Si quieres recorrer la lista otra vez necesitas crear un nuevo iterador con iter() otra vez.

Construir tu propio iterador — los métodos iter y next

Para que una clase sea un iterador tiene que implementar dos métodos mágicos: __iter__() y __next__(). Vamos a construir uno que genere una secuencia de números dentro de un rango, igual que el range() de Python pero hecho por nosotros:

class MiRango:
    def __init__(self, inicio=0, fin=0, paso=1):
        self.inicio = inicio
        self.fin = fin
        self.paso = paso

    def __iter__(self):
        self.actual = self.inicio    # reinicia al principio
        return self                  # se devuelve a sí mismo

    def __next__(self):
        if self.actual <= self.fin:
            resultado = self.actual
            self.actual += self.paso
            return resultado
        else:
            raise StopIteration      # señal de que se acabó

Ahora puedes usarlo exactamente igual que range():

for i in MiRango(1, 5):
    print(i)
# → 1, 2, 3, 4, 5

for i in MiRango(0, 10, 2):
    print(i)
# → 0, 2, 4, 6, 8, 10

# También puedes usar iter() y next() directamente
mi_rango = MiRango(5, 8)
it = iter(mi_rango)
print(next(it))    # → 5
print(next(it))    # → 6

La ventaja importante de los iteradores es que no generan todos los elementos a la vez en memoria, los producen uno a uno cuando se necesitan. Si tienes un millón de números, un iterador usa solo la memoria de un número en cada momento. Una lista de un millón de números ocupa toda esa memoria desde el principio.

El problema del iterador propio — iterable vs iterador

Fíjate en algo que puede dar problemas en FP2. La clase MiRango de arriba es iterador e iterable a la vez, __iter__ se devuelve a sí mismo. Eso significa que si intentas usarla en dos bucles anidados a la vez, el segundo bucle interfiere con el primero porque comparten el mismo estado (self.actual):

rango = MiRango(1, 3)

for i in rango:
    for j in rango:      # problema — comparten el mismo iterador
        print(i, j)

La solución que verás en el PDF de FP2 es separar el iterable del iterador, el iterable crea un iterador nuevo cada vez que se llama a __iter__(). Pero para los ejercicios de FP2 la versión simple (iterador que se devuelve a sí mismo) es suficiente.

Generadores — la forma pythónica de hacer iteradores

Construir un iterador con __iter__ y __next__ funciona pero es verboso. Los generadores son la respuesta de Python a eso, parecen funciones normales pero usan yield en vez de return:

def mi_rango_generador(inicio, fin, paso=1):
    actual = inicio
    while actual <= fin:
        yield actual          # pausa aquí y devuelve el valor
        actual += paso        # continúa desde aquí la próxima vez

Se usa exactamente igual que el iterador anterior:

for i in mi_rango_generador(1, 5):
    print(i)
# → 1, 2, 3, 4, 5

Por qué yield pausa la función en vez de terminarla

Esta es la parte que más cuesta visualizar en FP2, así que vamos despacio.

Cuando Python ejecuta return, la función termina, su estado desaparece y el valor se devuelve al que llamó. Cuando Python ejecuta yield, la función se pausa, guarda todo su estado (variables locales, punto de ejecución) y devuelve el valor. La próxima vez que se llama a next() la función continúa exactamente desde donde se quedó.

def mi_generador():
    print("Antes del primero")
    yield 1
    print("Antes del segundo")
    yield 2
    print("Antes del tercero")
    yield 3
    print("Se acabó")

gen = mi_generador()

print(next(gen))    # imprime "Antes del primero" → devuelve 1
print(next(gen))    # imprime "Antes del segundo" → devuelve 2
print(next(gen))    # imprime "Antes del tercero" → devuelve 3
print(next(gen))    # imprime "Se acabó" → StopIteration
Antes del primero
1
Antes del segundo
2
Antes del tercero
3
Se acabó
StopIteration

Fíjate en que print("Antes del primero") no se ejecuta cuando llamas a mi_generador(), solo cuando llamas a next() por primera vez. Llamar a la función no la ejecuta, solo crea el objeto generador. La ejecución empieza con el primer next().

La analogía que mejor funciona: imagina que el generador es un libro de recetas y yield es un marcapáginas. Cada vez que llegas a un yield pones el marcapáginas ahí y cierras el libro. La próxima vez que lo abres, continúas exactamente desde el marcapáginas, no desde el principio.

Generadores con bucles — el uso más común

El patrón más habitual en FP2 es un generador con un while o un for interno:

def rango_generador(inicio, fin, paso=1):
    actual = inicio
    while actual <= fin:
        yield actual
        actual += paso

# Uso en bucle for
for numero in rango_generador(3, 20, 3):
    print(numero)
# → 3, 6, 9, 12, 15, 18

# Uso con iter() y next() explícitos
gen = rango_generador(3, 20, 3)
while True:
    try:
        i = next(gen)
        print(i)
    except StopIteration:
        break

Los dos bloques producen exactamente la misma salida, el for usa iter() y next() por debajo automáticamente.

Generador vs lista — cuándo usar cada uno

Esta es la pregunta que más aparece en FP2 y en la que más vale tener la respuesta clara.

Usa una lista cuando:

  • Necesitas todos los elementos a la vez
  • Vas a recorrer la secuencia más de una vez
  • Necesitas acceder a elementos por índice (lista[3])
  • La secuencia es pequeña

Usa un generador cuando:

  • Los elementos se usan uno a uno y no se necesitan todos a la vez
  • La secuencia es muy larga o infinita
  • Quieres ahorrar memoria
  • Los elementos son costosos de calcular y solo necesitas algunos
# Lista — genera todos a la vez en memoria
lista_millones = [i * 2 for i in range(1_000_000)]
# ocupa ~8MB en memoria desde el principio

# Generador — genera uno a uno
def millones_generador():
    for i in range(1_000_000):
        yield i * 2
# ocupa solo unos bytes — da igual lo grande que sea
gen = millones_generador()

La regla práctica: si solo vas a recorrer la secuencia una vez de principio a fin, un generador es mejor. Si necesitas más flexibilidad, usa una lista.

Un generador infinito — algo imposible con una lista

Los generadores pueden ser infinito, algo que con una lista es imposible:

def numeros_naturales():
    n = 1
    while True:        # bucle infinito — el generador nunca lanza StopIteration
        yield n
        n += 1

gen = numeros_naturales()
print(next(gen))    # → 1
print(next(gen))    # → 2
print(next(gen))    # → 3
# puedes llamar a next() tantas veces como quieras

Úsalos con cuidado en bucles for, un for sobre un generador infinito nunca termina:

for n in numeros_naturales():
    if n > 10:
        break
    print(n)
# → 1, 2, 3, 4, 5, 6, 7, 8, 9, 10

Visualízalo con Python Tutor

Los generadores son donde Python Tutor más brilla, puedes ver exactamente cómo la función se pausa y retoma. Ve a pythontutor.com y pega este código:

def mi_generador():
    yield 1
    yield 2
    yield 3

gen = mi_generador()

a = next(gen)
b = next(gen)
c = next(gen)

print(a, b, c)

Ejecuta paso a paso y observa tres cosas: cuando llamas a mi_generador() la función no se ejecuta, solo se crea el objeto generador. Cuando llamas a next(gen) la función avanza hasta el yield, pausa y devuelve el valor. El estado de la función (el punto de ejecución) persiste entre llamadas a next(). Cada yield es como un punto de parada.

Luego añade print() entre los yield como hicimos antes y vuelve a ejecutar paso a paso, así verás el orden exacto de ejecución.

Resumen rápido

# ITERABLE vs ITERADOR
# Iterable → tiene __iter__() → puedes recorrerlo con for
# Iterador → tiene __iter__() y __next__() → produce elementos uno a uno

# iter() y next()
it = iter([1, 2, 3])    # crea iterador desde iterable
next(it)                 # devuelve 1, luego 2, luego 3
# cuando se acaba → StopIteration

# Lo que hace el for por dentro
for x in iterable:
    ...
# equivale a:
it = iter(iterable)
while True:
    try: x = next(it)
    except StopIteration: break

# ITERADOR PROPIO
class MiIterador:
    def __iter__(self):
        self.actual = self.inicio
        return self             # se devuelve a sí mismo

    def __next__(self):
        if condicion:
            resultado = self.actual
            self.actual += paso
            return resultado
        else:
            raise StopIteration # señal de fin

# GENERADOR — la forma pythónica
def mi_generador(inicio, fin):
    actual = inicio
    while actual <= fin:
        yield actual    # pausa + devuelve valor
        actual += 1     # continúa desde aquí la próxima vez

# yield vs return
# return → termina la función, el estado desaparece
# yield  → pausa la función, el estado persiste, continúa con next()

# CUÁNDO USAR CADA UNO
# Lista     → varios recorridos, acceso por índice, secuencia pequeña
# Generador → un recorrido, secuencia grande o infinita, ahorro de memoria

# ERRORES TÍPICOS
# 1. Confundir iterable con iterador → una lista es iterable, no iterador
# 2. Intentar reutilizar un iterador agotado → crear uno nuevo con iter()
# 3. for sobre generador infinito sin break → bucle infinito
# 4. Pensar que yield es como return → yield pausa, no termina

En el próximo artículo practicamos iteradores y generadores con programas reales, incluyendo un generador de números de Fibonacci infinito y un iterador para una lista encadenada.


Iterators and generators in Python — how the for loop really works

Iterators and generators in Python are one of those FP2 topics that feel abstract at first, and then suddenly click when you realise you’ve been using them since day one. Every for loop you’ve ever written uses an iterator. This article explains what’s behind that, how to build your own, and why generators are the most elegant way to do it.

Iterable vs iterator — the most important distinction

Iterable — any object you can loop over with for. A list, string, dictionary, range — all iterables. What makes them iterable is the __iter__() method that returns an iterator.

Iterator — the object that actually does the traversal, element by element. It has two methods: __iter__() which returns itself, and __next__() which returns the next element. When there are no more elements it raises StopIteration.

The analogy that helped me most: a book is the iterable — you can read it. The finger marking the current page is the iterator — it knows where you are and advances one step at a time.

my_list = [1, 2, 3]
print(type(my_list))         # → <class 'list'>

my_iterator = iter(my_list)
print(type(my_iterator))     # → <class 'list_iterator'>

iter() and next() — what the for loop actually does

When Python runs a for loop, it doesn’t do magic, it runs exactly this underneath:

# What YOU write
for number in [1, 2, 3]:
    print(number)

# What Python ACTUALLY does
lst = [1, 2, 3]
iterator = iter(lst)
while True:
    try:
        number = next(iterator)
        print(number)
    except StopIteration:
        break

iter() calls __iter__(). next() calls __next__(). When elements run out, StopIteration is raised and the loop ends. That’s everything behind every for loop you’ve written.

numbers = [10, 20, 30]
it = iter(numbers)

print(next(it))    # → 10
print(next(it))    # → 20
print(next(it))    # → 30
print(next(it))    # → StopIteration

Once exhausted, an iterator doesn’t reset. Create a new one with iter() to start again.

Building your own iterator

class MyRange:
    def __init__(self, start=0, end=0, step=1):
        self.start = start
        self.end = end
        self.step = step

    def __iter__(self):
        self.current = self.start
        return self

    def __next__(self):
        if self.current <= self.end:
            result = self.current
            self.current += self.step
            return result
        else:
            raise StopIteration

for i in MyRange(1, 5):
    print(i)
# → 1, 2, 3, 4, 5

The key advantage of iterators: they don’t generate all elements in memory at once, they produce them one by one on demand. A list of a million numbers occupies all that memory upfront. An iterator uses only the memory of one number at any moment.

Generators — the pythonic way to make iterators

Building iterators with __iter__ and __next__ works but it’s verbose. Generators are Python’s answer, they look like normal functions but use yield instead of return:

def range_generator(start, end, step=1):
    current = start
    while current <= end:
        yield current
        current += step

for i in range_generator(1, 5):
    print(i)
# → 1, 2, 3, 4, 5

Why yield pauses the function instead of ending it

This is the hardest part to visualise. When Python hits return, the function ends, its state disappears, the value is returned. When Python hits yield, the function pauses, saves all its state (local variables, execution point) and returns the value. Next time next() is called, the function resumes exactly where it stopped.

def my_generator():
    print("Before first")
    yield 1
    print("Before second")
    yield 2
    print("Before third")
    yield 3
    print("All done")

gen = my_generator()

print(next(gen))    # prints "Before first" → returns 1
print(next(gen))    # prints "Before second" → returns 2
print(next(gen))    # prints "Before third" → returns 3
print(next(gen))    # prints "All done" → StopIteration

Notice that calling my_generator() doesn’t execute the function, it only creates the generator object. Execution starts with the first next().

The best analogy: the generator is a recipe book and yield is a bookmark. Each time you reach a yield you place the bookmark and close the book. Next time you open it, you continue exactly from the bookmark, not from the beginning.

Generator vs list — when to use each

Use a list when:

  • You need all elements at once
  • You’ll loop over the sequence more than once
  • You need index access (list[3])
  • The sequence is small

Use a generator when:

  • Elements are consumed one by one
  • The sequence is very long or infinite
  • You want to save memory
  • Elements are expensive to compute
# List — all in memory at once
big_list = [i * 2 for i in range(1_000_000)]
# ~8MB from the start

# Generator — one at a time
def big_generator():
    for i in range(1_000_000):
        yield i * 2
# just a few bytes — regardless of how large

Practical rule: if you only loop through the sequence once from start to finish, a generator is better. If you need more flexibility, use a list.

Infinite generator — impossible with a list

def natural_numbers():
    n = 1
    while True:
        yield n
        n += 1

gen = natural_numbers()
print(next(gen))    # → 1
print(next(gen))    # → 2
# call next() as many times as you like

for n in natural_numbers():
    if n > 10:
        break
    print(n)
# → 1, 2, 3, 4, 5, 6, 7, 8, 9, 10

Visualise with Python Tutor

Generators are where Python Tutor shines most. Go to pythontutor.com and paste:

def my_generator():
    yield 1
    yield 2
    yield 3

gen = my_generator()

a = next(gen)
b = next(gen)
c = next(gen)

print(a, b, c)

Step through and observe: calling my_generator() doesn’t execute the function. Each next() advances to the next yield and pauses. The function’s state persists between calls. Then add print() statements between the yields and run again, you’ll see the exact execution order.

Quick summary

# ITERABLE vs ITERATOR
# Iterable  → has __iter__()          → can loop with for
# Iterator  → has __iter__() + __next__() → produces one element at a time

# iter() and next()
it = iter([1, 2, 3])
next(it)    # → 1, 2, 3 then StopIteration

# What for actually does
for x in iterable: ...
# equals: it = iter(iterable)
#         while True:
#             try: x = next(it)
#             except StopIteration: break

# YOUR OWN ITERATOR
class MyIterator:
    def __iter__(self):
        self.current = self.start
        return self
    def __next__(self):
        if condition:
            result = self.current
            self.current += step
            return result
        raise StopIteration

# GENERATOR
def my_gen(start, end):
    current = start
    while current <= end:
        yield current    # pause + return value
        current += 1     # resumes here next time

# yield vs return
# return → function ends, state gone
# yield  → function pauses, state persists, resumes with next()

# WHEN TO USE EACH
# List      → multiple loops, index access, small sequence
# Generator → single loop, large or infinite sequence, save memory

# COMMON MISTAKES
# 1. Confusing iterable with iterator
# 2. Reusing an exhausted iterator → create a new one with iter()
# 3. for over infinite generator without break → infinite loop
# 4. Thinking yield works like return → yield pauses, doesn't end

Publicaciones Similares

2 comentarios

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *