Cuando escribes for x in algo:, Python no llama a ninguna función mágica de indexación ni recorre una lista internamente. Lo que hace es seguir un protocolo de dos piezas que cualquier objeto puede implementar. Entender ese protocolo te da control total sobre cómo tus objetos participan en bucles, comprensiones, zip, map, y cualquier otra construcción que consuma secuencias.
La distinción que más se ignora
Un iterable es cualquier objeto que sabe cómo producir un iterador: implementa __iter__() y lo devuelve. Una lista es iterable. Un string es iterable. Pero ninguno de ellos es un iterador.
Un iterador es el objeto que lleva el estado de la iteración. Implementa __next__(), que cada vez que se llama devuelve el siguiente valor o lanza StopIteration cuando se acaba. Una vez agotado, está agotado para siempre.
La diferencia importa de forma muy concreta: si llamas iter() dos veces sobre una lista, obtienes dos iteradores independientes, cada uno con su propio cursor. Si llamas iter() dos veces sobre un iterador, obtienes el mismo objeto (porque los iteradores implementan __iter__ retornando self), y el segundo recorrido empieza donde el primero lo dejó, que probablemente sea el final.
nums = [1, 2, 3] a = iter(nums) b = iter(nums) print(next(a)) # 1 print(next(a)) # 2 print(next(b)) # 1 ← b es independiente, empezó de cero
Eso explica por qué el típico bug de “iterar dos veces un generador y la segunda vez no da nada” ocurre: el generador es su propio iterador, y no hay forma de rebobinarlo.
El bucle for por dentro
Cuando Python ejecuta for x in obj:, el bytecode hace exactamente esto:
- Llama a
iter(obj), que llama aobj.__iter__()y guarda el iterador. - En cada paso del bucle, llama a
next(iterador), que llama aiterador.__next__(). - Cuando
__next__lanzaStopIteration, el bucle termina limpiamente (Python captura esa excepción internamente).
Puedes verificarlo con dis.dis:
import dis
dis.dis("for x in obj: pass")
# GET_ITER ← llama iter()
# FOR_ITER ← llama next(), salta si StopIteration
No hay magia adicional. Cualquier objeto que respete el protocolo funciona.
Implementando el protocolo desde cero
from __future__ import annotations
from typing import Iterator
class CountDown:
"""Iterable que produce una cuenta regresiva desde `start` hasta 1."""
def __init__(self, start: int) -> None:
if start < 1:
raise ValueError("start debe ser >= 1")
self.start = start
def __iter__(self) -> Iterator[int]:
# Devuelve un iterador nuevo cada vez; permite múltiples recorridos
# sobre el mismo CountDown sin interferencia entre ellos.
return _CountDownIterator(self.start)
class _CountDownIterator:
"""Lleva el estado de una iteración concreta sobre CountDown."""
def __init__(self, current: int) -> None:
self._current = current
def __iter__(self) -> _CountDownIterator:
# Requisito del protocolo: un iterador debe devolver self aquí.
return self
def __next__(self) -> int:
if self._current < 1:
raise StopIteration
value = self._current
self._current -= 1
return value
# --- uso ---
cd = CountDown(5)
# Múltiples recorridos independientes porque CountDown crea un iterador nuevo
for n in cd:
print(n, end=" ") # 5 4 3 2 1
print()
for n in cd:
print(n, end=" ") # 5 4 3 2 1 ← empieza de cero, cd no se agotó
# Consumo manual
it = iter(cd)
print(next(it)) # 5
print(next(it)) # 4
# iter sobre el iterador devuelve el mismo objeto
print(it is iter(it)) # True
Lo que hace el diseño por piezas
Separar CountDown de _CountDownIterator es la decisión central. CountDown es el iterable (la descripción de qué producir), y _CountDownIterator es el estado de una iteración concreta. Gracias a esa separación, puedes tener dos bucles simultáneos sobre el mismo cd sin que se interfieran, exactamente como funciona una lista.
Si hubieras puesto __next__ directamente en CountDown y hecho que __iter__ retorne self, el objeto sería su propio iterador: conveniente de escribir, pero se agota con el primer recorrido y ya no es reutilizable.
El __iter__ en _CountDownIterator retornando self no es un capricho: es lo que permite que el iterador se use en cualquier contexto donde se espere un iterable, incluyendo for, zip, y comprensiones anidadas.
Generadores: el protocolo gratis
def count_down(start: int):
while start >= 1:
yield start
start -= 1
gen = count_down(3)
# Un generador ES su propio iterador
print(gen is iter(gen)) # True
for n in gen:
print(n) # 3 2 1
# Agotado: una segunda vuelta no produce nada
for n in gen:
print(n) # (silencio)
Cuando usas yield, Python genera automáticamente una clase con __iter__ y __next__ que mantienen el estado de ejecución de la función (el frame, las variables locales, el punto de suspensión). Es exactamente el mismo protocolo, sin la ceremonia. La contrapartida es que un generador es siempre su propio iterador, así que si necesitas múltiples recorridos independientes sobre la misma secuencia lógica, tienes que hacer lo que hicimos con CountDown: separar la fábrica del estado.
Errores que debes conocer
Error: hacer que el iterable sea su propio iterador poniendo __iter__ y __next__ en la misma clase, sin pensar en reutilización.
# ❌ Wrong
class CountDown:
def __init__(self, start):
self._current = start
def __iter__(self):
return self # retorna self → es su propio iterador
def __next__(self):
if self._current < 1:
raise StopIteration
self._current -= 1
return self._current + 1
cd = CountDown(3)
list(cd) # [3, 2, 1]
list(cd) # [] ← sorpresa: ya estaba agotado
# ✅ Right
class CountDown:
def __init__(self, start):
self.start = start
def __iter__(self):
return _CountDownIterator(self.start) # iterador fresco cada vez
Cada llamada a __iter__ crea un iterador nuevo con estado propio, así el iterable es reutilizable.
Error: olvidar __iter__ en el iterador, haciendo que no sea usable en contextos que llaman iter() explícitamente (como zip o itertools).
# ❌ Wrong
class MyIterator:
def __next__(self): # sin __iter__
...
list(zip(MyIterator(), [1, 2])) # TypeError: zip argument #1 must support iteration
# ✅ Right
class MyIterator:
def __iter__(self):
return self # esto es todo lo que hace falta
def __next__(self):
...
zip y la mayoría de funciones de la stdlib llaman iter() sobre sus argumentos antes de consumirlos, así que un iterador sin __iter__ falla fuera del for directo.
Error: lanzar StopIteration dentro de un generador pensando que terminará limpiamente la iteración.
# ❌ Wrong
def gen():
yield 1
raise StopIteration # en Python 3.7+ esto se convierte en RuntimeError
# ✅ Right
def gen():
yield 1
return # terminar la función es la forma correcta de señalar fin
Desde PEP 479 (activo por defecto en Python 3.7+), un StopIteration que escapa de un generador se convierte en RuntimeError para evitar bugs silenciosos donde una función llamada dentro del generador se agotaba inesperadamente.
N° 90