Cómo funciona el bucle for por dentro

Cuando escribes for x in mi_lista:, Python no hace nada mágico: ejecuta un protocolo bien definido de dos pasos que cualquier objeto puede implementar. Entender ese protocolo cambia cómo lees código, cómo diseñas clases, y por qué ciertos bugs aparecen exactamente cuando no los esperas.

El primer paso es obtener un iterador: Python llama iter(obj), que a su vez invoca obj.__iter__(). Ese método devuelve un objeto distinto (o el mismo, dependiendo del tipo) cuya única responsabilidad es recordar dónde estamos en la secuencia. El segundo paso es avanzar ese iterador: Python llama next(_iter), que invoca _iter.__next__(), y espera recibir el siguiente valor o la excepción StopIteration. Cuando llega StopIteration, el bucle termina limpiamente. No es un error; es la señal de parada acordada por el protocolo.

Ese “protocolo de iteración” es exactamente esto:

_iter = iter(obj)       # __iter__
while True:
    try:
        x = next(_iter) # __next__
    except StopIteration:
        break
    # cuerpo del bucle

El diseño es deliberado: separar el objeto iterable (que sabe producir un iterador) del iterador (que sabe avanzar) permite reutilizar el mismo iterable en múltiples bucles independientes. Una lista puede abrirse con tres for simultáneos porque cada llamada a iter(lista) crea un iterador nuevo con su propio cursor. Un generador, en cambio, es iterable e iterador al mismo tiempo — su __iter__ devuelve self — así que una vez agotado, no se puede reiniciar.

Lo que ocurre si modificas la lista durante la iteración es consecuencia directa de este mecanismo: el iterador de list guarda un índice entero interno. Si insertas o borras elementos, ese índice ya no corresponde a las posiciones que intuyes, y saltarás o repetirás elementos sin ninguna advertencia. CPython no lanza una excepción porque no hay un “lock” sobre la lista; simplemente avanzas un contador sobre una estructura que cambió bajo tus pies.

A nivel de bytecode, dis te lo muestra sin ambigüedad:

import dis

def ejemplo(items):
    for x in items:
        print(x)

dis.dis(ejemplo)

Verás GET_ITER (la llamada a iter()) y FOR_ITER (la llamada a next() + el salto al final del bucle si llega StopIteration). Son exactamente dos instrucciones que implementan los dos pasos del protocolo.

Ejemplo completo

from __future__ import annotations
import dis
from typing import Iterator


class Countdown:
    """Iterable que cuenta hacia atrás desde `start` hasta 1."""

    def __init__(self, start: int) -> None:
        self.start = start

    def __iter__(self) -> "CountdownIterator":
        # Cada llamada a iter() crea un iterador fresco con su propio estado.
        return CountdownIterator(self.start)


class CountdownIterator:
    def __init__(self, current: int) -> None:
        self._current = current

    def __iter__(self) -> "CountdownIterator":
        # Un iterador siempre devuelve self: es su propio iterador.
        return self

    def __next__(self) -> int:
        if self._current <= 0:
            raise StopIteration          # señal limpia, no error
        value = self._current
        self._current -= 1
        return value


# ── demostración del protocolo a mano ────────────────────────────────────

countdown = Countdown(3)

# Dos iteradores independientes sobre el mismo iterable
iter_a = iter(countdown)
iter_b = iter(countdown)

print(next(iter_a))  # 3
print(next(iter_a))  # 2
print(next(iter_b))  # 3  ← cursor propio, empieza desde el inicio

# El bucle for hace exactamente esto por debajo
for tick in countdown:
    print(f"tick {tick}")

# ── qué ve CPython ────────────────────────────────────────────────────────

def loop_bytecode(items):
    for x in items:
        pass

print("\n--- bytecode ---")
dis.dis(loop_bytecode)

# ── bug clásico: mutar lista durante iteración ────────────────────────────

numbers = [1, 2, 3, 4, 5]
_iter: Iterator[int] = iter(numbers)
numbers.pop(0)       # desplaza el índice interno del iterador

# El iterador ya arrancó en índice 0, pero la lista cambió;
# el primer next() devolverá lo que ahora está en posición 0: el 2.
print(next(_iter))   # 2, no 1

Qué está pasando en cada decisión

Countdown.__iter__ devuelve una instancia nueva de CountdownIterator cada vez. Eso es lo que hace que Countdown sea un iterable puro — puedes usarlo en tantos for como quieras y cada uno obtiene su propio estado. Si hubieras puesto el estado (self._current) directamente en Countdown y devuelto self desde __iter__, el segundo for empezaría desde donde terminó el primero.

CountdownIterator.__iter__ devuelve self. Esta línea parece redundante pero es obligatoria: el protocolo exige que cualquier iterador sea también iterable. Sin ella, no podrías hacer for x in iter(countdown) — Python siempre llama iter() primero, incluso sobre algo que ya es un iterador.

raise StopIteration en __next__ es el contrato. No devuelves None, no devuelves un centinela especial: lanzas exactamente esta excepción. El opcode FOR_ITER en CPython captura StopIteration de manera nativa y ejecuta el salto de salida del bucle — es la integración entre el protocolo Python y la VM.

El fragmento de numbers.pop(0) muestra el bug en cámara lenta. El iterador de lista almacena un entero i que empieza en 0. Al sacar el elemento 0, la lista se compacta y el entero sigue siendo 0 — apunta ahora al que antes era índice 1. El iterador no sabe que la lista cambió. Por eso la regla práctica es clara: si necesitas filtrar mientras iteras, construye una lista nueva con una comprensión o usa itertools.filterfalse; no mutes el iterable que estás recorriendo.

El bytecode que imprime dis.dis(loop_bytecode) tendrá una línea con GET_ITER justo antes de entrar al bloque y FOR_ITER <offset> dentro del bucle. Ese offset es la dirección a la que salta cuando __next__ lanza StopIteration — el compilador ha colocado el “¿me detengo?” exactamente ahí, no en un try/except de Python sino en lógica nativa de la VM.

Errores que debes conocer

Error: asumir que un iterador agotado puede reiniciarse pasándolo a iter() otra vez.

# ❌ Wrong
gen = (x for x in range(3))
for _ in gen:
    pass
for x in iter(gen):   # iter() devuelve el mismo objeto, ya agotado
    print(x)          # no imprime nada

# ✅ Right
def make_gen():
    return (x for x in range(3))

for x in make_gen():
    print(x)
for x in make_gen():   # iterador fresco cada vez
    print(x)

iter(gen) sobre un generador devuelve gen mismo (__iter__ retorna self), así que el estado agotado persiste; la solución es una función que produzca un generador nuevo.

Error: modificar una lista dentro del for que la recorre y esperar que se procesen todos los elementos.

# ❌ Wrong
items = [1, 2, 3, 4]
for item in items:
    if item % 2 == 0:
        items.remove(item)   # desplaza índices; saltará elementos

# ✅ Right
items = [item for item in items if item % 2 != 0]

La comprensión itera sobre items original y construye una lista nueva, dejando el iterador interno intacto durante todo el recorrido.

108

Dejar un comentario

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

Scroll al inicio