Scope y la regla LEGB en Python

Cuando escribes un nombre en Python —x, len, result— el intérprete no lo busca al azar. Sigue un orden fijo y determinista llamado LEGB: Local → Enclosing → Global → Built-in. Entender este orden no es trivia académica; es la clave para predecir qué variable vas a leer o modificar en cualquier contexto.

El mecanismo es simple a propósito: Python resuelve nombres en tiempo de ejecución buscando en cuatro capas de espacios de nombres (namespaces), de dentro hacia afuera, parándose en la primera coincidencia.

  • Local: el namespace de la función que se está ejecutando ahora mismo.
  • Enclosing: los namespaces de las funciones que contienen a la actual, de la más cercana hacia afuera. Solo aparece cuando tienes funciones anidadas.
  • Global: el namespace del módulo donde vive el código.
  • Built-in: el namespace de builtinslen, print, range, TypeError, etc.

Hay un detalle que genera la mayoría de los bugs: Python decide si una variable es local en tiempo de compilación del bytecode, no en tiempo de ejecución. Específicamente, si en algún punto dentro de una función aparece una asignación a un nombre (x = ...), Python marca ese nombre como local para toda la función, incluso antes de llegar a la línea de asignación. Si intentas leer ese nombre antes de que la asignación ocurra, obtienes un UnboundLocalError —no un NameError, porque Python ya sabe que la variable existe localmente, solo que todavía no tiene valor.

Cuando esto no te conviene y necesitas mutar una variable de un scope exterior, tienes dos herramientas: global para alcanzar el nivel de módulo, y nonlocal para alcanzar el nivel enclosing más cercano.

# scope_legb.py
"""
Demostración completa de la regla LEGB:
UnboundLocalError, closures y el problema clásico con bucles.
"""

# ── Nivel Global ──────────────────────────────────────────────────────────────
threshold = 100


def check_value(value: int) -> str:
    # 'threshold' no está asignada aquí → Python sube a Global y la encuentra.
    if value > threshold:
        return "above"
    return "below or equal"


# ── UnboundLocalError ─────────────────────────────────────────────────────────
def broken_increment():
    # Python ve 'count = count + 1' y marca 'count' como local PARA TODA LA FUNCIÓN.
    # La lectura en la misma línea ocurre antes de la asignación → UnboundLocalError.
    count = count + 1  # noqa: F821  (intencional para ilustrar el error)


def fixed_increment_global():
    global threshold  # le decimos explícitamente: este nombre vive en Global.
    threshold += 10


# ── Enclosing + nonlocal ──────────────────────────────────────────────────────
def make_counter(start: int = 0):
    count = start  # variable en el scope Enclosing de las funciones internas

    def increment(step: int = 1) -> int:
        nonlocal count  # sin esto, la asignación crearía una 'count' Local nueva
        count += step
        return count

    def reset() -> None:
        nonlocal count
        count = start

    return increment, reset


# ── El problema clásico de closures en bucles ─────────────────────────────────
def broken_multipliers() -> list:
    """
    Todas las funciones capturan la *variable* i, no su valor en cada iteración.
    Al ejecutarlas, i ya vale 4 (el valor final del bucle).
    """
    funcs = []
    for i in range(5):
        funcs.append(lambda x: x * i)  # 'i' se resuelve en Enclosing cuando se llama
    return funcs


def fixed_multipliers_default() -> list:
    """
    Solución idiomática: capturar el valor actual como argumento por defecto.
    Los defaults se evalúan en el momento de definir la función, no al llamarla.
    """
    funcs = []
    for i in range(5):
        funcs.append(lambda x, factor=i: x * factor)  # 'factor' queda fijado aquí
    return funcs


def fixed_multipliers_factory() -> list:
    """
    Alternativa más explícita: una función de fábrica crea un scope Enclosing
    propio para cada iteración.
    """
    def make_multiplier(factor: int):
        return lambda x: x * factor  # 'factor' vive en el Enclosing de make_multiplier

    return [make_multiplier(i) for i in range(5)]


# ── Ejercicio de trazado LEGB ─────────────────────────────────────────────────
def demonstrate():
    print("=== check_value ===")
    print(check_value(150))   # 'above'  — usa threshold del Global
    print(check_value(50))    # 'below or equal'

    print("\n=== make_counter ===")
    inc, rst = make_counter(10)
    print(inc())    # 11
    print(inc(5))   # 16
    rst()
    print(inc())    # 11  — reset funcionó gracias a nonlocal

    print("\n=== broken_multipliers (todas dan x*4) ===")
    broken = broken_multipliers()
    print([f(3) for f in broken])   # [12, 12, 12, 12, 12]

    print("\n=== fixed_multipliers_default ===")
    fixed = fixed_multipliers_default()
    print([f(3) for f in fixed])    # [0, 3, 6, 9, 12]

    print("\n=== fixed_multipliers_factory ===")
    factory = fixed_multipliers_factory()
    print([f(3) for f in factory])  # [0, 3, 6, 9, 12]


if __name__ == "__main__":
    demonstrate()

Qué está pasando en cada parte

check_value y el nivel Global es el caso más cotidiano. La función no asigna threshold en ningún lugar, así que Python sube por LEGB hasta encontrarla en el módulo. Limpio y sin sorpresas.

broken_increment ilustra por qué UnboundLocalError no es un bug del runtime, sino una decisión del compilador de bytecode. Puedes comprobarlo tú mismo: import dis; dis.dis(broken_increment) muestra la instrucción LOAD_FAST para countFAST indica variable local— antes de STORE_FAST. El compilador ya decidió que count es local por la presencia de la asignación en esa misma función. fixed_increment_global resuelve eso con global, aunque en código real deberías preferir devolver el nuevo valor antes que mutar estado global.

make_counter es donde LEGB se vuelve interesante. increment y reset son closures: capturan el scope enclosing de make_counter, que incluye count y start. Sin nonlocal, Python aplicaría la misma regla que vimos arriba: la asignación count += step crearía una variable local, y la lectura implícita en el lado derecho de += fallaría con UnboundLocalError. Con nonlocal, Python sabe que debe buscar count un nivel más arriba y operar sobre esa misma variable. Cada llamada a make_counter crea un namespace enclosing independiente, por eso dos contadores no comparten estado.

El problema del bucle es posiblemente el bug más frecuente con closures. La lambda lambda x: x * i no captura el valor de i en el momento de creación: captura la variable i del scope enclosing. Cuando el bucle termina, i vale 4, y todas las lambdas lo ven igual. La solución con factor=i explota el hecho de que los valores por defecto se evalúan al definir la función, convirtiendo el valor actual en parte de la firma. La solución con make_multiplier es más explícita: cada llamada a la fábrica crea un scope enclosing nuevo donde factor tiene el valor del momento.

Errores que debes conocer

Error: mezclar lectura de una variable global con asignación en la misma función sin declarar global.

# ❌ Wrong
visits = 0

def record_visit():
    print(f"Visit number {visits}")  # UnboundLocalError aquí
    visits += 1                       # esta línea hace que Python marque 'visits' como local

# ✅ Right
visits = 0

def record_visit():
    global visits
    print(f"Visit number {visits}")
    visits += 1

El compilador procesa toda la función antes de ejecutarla; ver visits = ... en cualquier punto es suficiente para que visits sea local en toda ella.


Error: asumir que una closure en un bucle captura el valor actual de la variable de iteración.

# ❌ Wrong
handlers = [lambda: print(i) for i in range(3)]
handlers[0]()  # imprime 2, no 0

# ✅ Right
handlers = [lambda i=i: print(i) for i in range(3)]
handlers[0]()  # imprime 0

La versión correcta fija el valor como default del parámetro en el momento de crear cada lambda, rompiendo la referencia a la variable del bucle.


Error: usar nonlocal intentando alcanzar el nivel global —nonlocal solo llega hasta el enclosing más cercano, no al módulo.

# ❌ Wrong
total = 0

def outer():
    def inner():
        nonlocal total  # SyntaxError: no binding for nonlocal 'total' found
        total += 1

# ✅ Right
total = 0

def outer():
    def inner():
        global total   # 'total' está en el módulo, no en un enclosing
        total += 1

nonlocal requiere que el nombre esté asignado en algún scope enclosing dentro del mismo archivo; el nivel de módulo no cuenta como enclosing para este propósito.

59

Dejar un comentario

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

Scroll al inicio