Cuando escribes for x in algo:, Python ejecuta una pequeña coreografía detrás de escena que involucra dos protocolos distintos. La mayoría de la gente los confunde porque las listas parecen funcionar igual que los iteradores, hasta que no funcionan.
Un iterable es cualquier objeto que implementa __iter__(). Ese método no hace el trabajo de iterar — simplemente fabrica y devuelve un iterador. Un iterador es el objeto que sí lleva el estado: implementa __next__(), recuerda su posición actual, y en cada llamada avanza una posición o lanza StopIteration cuando se agota.
La distinción importa porque describe dos responsabilidades distintas: “puedo ser recorrido” (iterable) versus “estoy recorriéndome ahora mismo” (iterador). Una lista es iterable porque puede producir iteradores, pero la lista en sí no tiene estado de posición — puedes recorrerla diez veces y siempre empieza desde el principio. Un iterador sí tiene ese estado, y cuando se agota, se agota para siempre.
El protocolo completo exige que un iterador también implemente __iter__ retornando self. Esto no es capricho — hace que un iterador sea un iterable válido, lo que significa que puedes pasarlo directamente a un for, a zip, a map, o a cualquier cosa que espere un iterable. Si el iterador no implementara __iter__, rompería esa cadena.
El error más revelador es pasar el mismo iterador a dos bucles for: el segundo no ve nada. Con una lista no pasa, porque cada for llama a __iter__() y obtiene un iterador fresco.
from __future__ import annotations
from typing import Iterator
class CountUp:
"""Iterable que produce enteros desde `start` hasta `stop` (exclusivo)."""
def __init__(self, start: int, stop: int) -> None:
self.start = start
self.stop = stop
def __iter__(self) -> "CountUpIterator":
# Cada llamada fabrica un iterador nuevo con su propio estado.
return CountUpIterator(self.start, self.stop)
class CountUpIterator:
"""Iterator con estado propio — recuerda dónde va."""
def __init__(self, current: int, stop: int) -> None:
self.current = current
self.stop = stop
def __iter__(self) -> "CountUpIterator":
# Retornar self permite usarlo donde se espere un iterable.
return self
def __next__(self) -> int:
if self.current >= self.stop:
raise StopIteration # señal de agotamiento, no un error
value = self.current
self.current += 1
return value
# ── Demostración de la distinción ────────────────────────────────────
counter = CountUp(0, 4) # iterable, sin estado de posición
print("Primer recorrido:", list(counter)) # [0, 1, 2, 3]
print("Segundo recorrido:", list(counter)) # [0, 1, 2, 3] ← fresco
it = iter(counter) # creamos UN iterador explícito
print("it recorrido 1:", list(it)) # [0, 1, 2, 3]
print("it recorrido 2:", list(it)) # [] ← ya se agotó
# ── Lo mismo con una lista nativa ────────────────────────────────────
nums = [10, 20, 30]
it_a = iter(nums)
it_b = iter(nums) # iterador independiente sobre la misma lista
next(it_a) # consume 10 de it_a
print(list(it_a)) # [20, 30]
print(list(it_b)) # [10, 20, 30] ← it_b no se vio afectado
Qué está pasando en cada pieza
CountUp solo sabe dos cosas: desde dónde empezar y dónde parar. No tiene current porque no es su responsabilidad. Cada vez que alguien le pide un iterador —ya sea un for, un list(), o un iter() explícito— construye un CountUpIterator nuevo con su propio current. Eso es exactamente lo que hace una lista internamente.
CountUpIterator lleva current como estado mutable. Fíjate en que __iter__ devuelve self — no un objeto nuevo. Si devolviera un objeto nuevo, el iterador estaría mintiendo sobre su identidad y romperías contratos como poder pasar el mismo iterador a zip(it, it) para obtener pares consecutivos.
StopIteration se lanza, no se retorna. Es la señal del protocolo que for captura internamente para saber cuándo parar. No es una excepción de error en el sentido usual — es parte del contrato.
En la demostración, list(counter) funciona dos veces porque cada llamada a list() invoca iter(counter), que llama a counter.__iter__(), que construye un CountUpIterator fresco. Pero it = iter(counter) crea uno solo, y cuando list(it) lo agota, el estado interno current == stop ya no vuelve atrás.
Errores que debes conocer
Error: hacer que el iterable y el iterador sean el mismo objeto implementando __next__ directamente en la clase contenedora.
# ❌ Wrong
class BadCounter:
def __init__(self, stop):
self.current = 0
self.stop = stop
def __iter__(self):
return self # se retorna a sí mismo como iterador
def __next__(self):
if self.current >= self.stop:
raise StopIteration
v = self.current
self.current += 1
return v
bc = BadCounter(3)
print(list(bc)) # [0, 1, 2]
print(list(bc)) # [] ← el estado ya estaba agotado
# ✅ Right
class GoodCounter:
def __init__(self, stop):
self.stop = stop
def __iter__(self):
return GoodCounterIterator(self.stop) # estado fresco cada vez
class GoodCounterIterator:
def __init__(self, stop):
self.current = 0
self.stop = stop
def __iter__(self):
return self
def __next__(self):
if self.current >= self.stop:
raise StopIteration
v = self.current
self.current += 1
return v
Separar el contenedor del iterador garantiza que cada recorrido empiece limpio, igual que una lista.
Error: asumir que iter() sobre una lista crea una copia de la lista.
# ❌ Wrong nums = [1, 2, 3] it = iter(nums) nums.append(4) # el desarrollador cree que it no verá el 4 print(list(it)) # [1, 2, 3, 4] ← sí lo ve; it apunta a la lista original # ✅ Right nums = [1, 2, 3] it = iter(nums.copy()) # snapshot en el momento nums.append(4) print(list(it)) # [1, 2, 3]
iter() crea un iterador con una referencia al objeto original, no una copia — si la lista muta, el iterador refleja esa mutación.
N° 107