En Python, la resolución del alcance de la variable se realiza de manera estática durante la fase de compilación en lugar de dinámicamente durante la ejecución. Cuando el compilador CPython encuentra una definición de función, recorre el árbol de sintaxis abstracta para construir una tabla de símbolos que categoriza cada nombre como variable local, global o de celda. Si el compilador detecta alguna operación de enlace —como asignación, asignación aumentada o importación— para un nombre en cualquier parte del cuerpo de la función, marca ese nombre como una variable local para todo el alcance. Este diseño permite que la máquina virtual utilice los opcodes optimizados LOAD_FAST que operan sobre un arreglo de tamaño fijo en lugar de realizar búsquedas más lentas en tablas hash. Esta optimización es fundamental para el rendimiento de las llamadas a funciones de Python, pero introduce requisitos estrictos de enlace.
Cuando un nombre se clasifica como local, el compilador emite instrucciones de bytecode LOAD_FAST para todas las operaciones de lectura de ese nombre. Durante la ejecución, LOAD_FAST intenta recuperar la referencia del objeto desde el índice correspondiente en el arreglo de variables locales del marco. Si la ranura contiene un puntero nulo que indica que no se ha asignado ningún valor aún, en tiempo de ejecución se genera un UnboundLocalError. Esto ocurre incluso si existe una variable global con el mismo nombre, porque el compilador evitó deliberadamente emitir LOAD_GLOBAL. El error indica explícitamente esta decisión de alcance estático, distinguiéndola de NameError.
Para resolver esto, debes informar explícitamente al compilador que el nombre se refiere al espacio de nombres global declarando global <nombre_variable>. Esta declaración causa que el compilador cambie a los opcodes LOAD_GLOBAL y STORE_GLOBAL, que buscan dinámicamente el nombre en el diccionario global del módulo. Alternativamente, reestructura el código para asegurarte de que todas las variables locales se inicializan en la parte superior de la función antes de que cualquier lógica condicional las lea. Para los alcances anidados, la palabra clave nonlocal obliga al compilador a usar LOAD_DEREF para acceder a las celdas de cierre. Estas declaraciones alteran la decisión de enlace del compilador en tiempo de compilación, evitando el escenario de variable local no vinculada.
threshold = 100 def analyze(data): # El compilador ve 'threshold = ...' más abajo, lo marca como local if data > threshold: # Genera UnboundLocalError return "alto" threshold = 50 # La asignación lo convierte en local # Solución usando 'global' def analyze_fixed(data): global threshold if data > threshold: # LOAD_GLOBAL tiene éxito return "alto" threshold = 50 # Actualiza la variable global
Un equipo de ingeniería de datos estaba construyendo un pipeline ETL usando Apache Airflow. Definieron un diccionario de configuración predeterminado CONFIG = {"batch_size": 1000} a nivel de módulo para permitir un ajuste fácil de los parámetros de procesamiento. La función de transformación principal process_batch() inicialmente verificaba if len(records) > CONFIG["batch_size"]: para determinar si era necesario dividir. Más tarde en la función, bajo una condición específica, el código intentó optimizar la memoria reduciendo el tamaño del lote con CONFIG = {"batch_size": 500}. Este patrón desencadenó inadvertidamente un conflicto de alcance.
Cuando se ejecutó el pipeline, se bloqueó en la primera línea de la función con UnboundLocalError: variable local 'CONFIG' referenciada antes de la asignación. La declaración de asignación al final de la función causó que el compilador de Python tratara a CONFIG como una variable local para todo el cuerpo de la función. En consecuencia, la operación de comparación al principio utilizó LOAD_FAST para acceder a la ranura de variable local no inicializada. Esta falla detuvo el pipeline de datos durante una ejecución crítica en producción porque la función no pudo comenzar su ejecución.
El equipo primero consideró cambiar el nombre de la reasignación local a local_config, creando un nuevo diccionario para el procesamiento reducido del lote. Esto evitaría el problema de ocultación por completo y mantendría la configuración global inmutable. Sin embargo, este enfoque requería refactorizar el código posterior que esperaba que el nombre CONFIG reflejara los límites actuales. Introducía potenciales inconsistencias si el desarrollador olvidaba usar el nuevo nombre de variable en la lógica subsiguiente. La sobrecarga cognitiva de rastrear dos nombres de variable para el mismo concepto hacía que esta solución fuera menos atractiva.
Otra opción era agregar global CONFIG al inicio de la función, obligando al compilador a tratar todas las referencias como búsquedas globales. Si bien esto evitaría el error, el equipo lo rechazó porque modificar el estado global durante un proceso de lote es un anti-patrón peligroso. Impide la reentrancia de funciones y complica significativamente las pruebas unitarias. Además, crearía condiciones de carrera si el código se paraleliza alguna vez entre hilos. Los efectos secundarios en el estado a nivel de módulo se consideraron inaceptables para los pipelines de datos en producción.
La tercera solución involucró mutar el diccionario existente en su lugar usando CONFIG["batch_size"] = 500 en lugar de reasignar el nombre de variable en sí. Dado que esta operación no crea un nuevo enlace para el nombre CONFIG, el compilador continúa tratándolo como una referencia global. Esto evita UnboundLocalError mientras permite que la actualización de la configuración persista para llamadas subsiguientes. Esto se consideró la mejor solución inmediata, aunque el equipo planeó refactorizar la configuración en una instancia de clase más adelante. El enfoque de mutación preservó la API existente mientras resolvía el bloqueo inmediato.
Implementaron la tercera solución, cambiando la reasignación a una mutación CONFIG["batch_size"] = 500. El pipeline reanudó la ejecución sin errores, y el cambio de configuración se aplicó correctamente a los lotes subsiguientes. Más tarde, refactorizaron el código para usar un objeto de configuración Pydantic inyectado en la función. Esto eliminó completamente la dependencia de variables globales a nivel de módulo y hizo que la función fuera pura y testeable. El incidente provocó una revisión de código de todos los operadores de Airflow para eliminar patrones de ocultación similares.
¿Por qué el del de una variable dentro de una función, seguido de un intento de leerla, genera UnboundLocalError en lugar de retroceder al alcance global?
Cuando ejecutas del x en una variable local, elimina la referencia del f_locals del marco, pero no cambia la clasificación estática de x como local. El compilador todavía generó LOAD_FAST para las lecturas subsiguientes. Cuando el intérprete ejecuta LOAD_FAST, encuentra la ranura vacía y genera UnboundLocalError en lugar de retroceder a globales. Esto confirma que las decisiones de alcance son inmutables en tiempo de ejecución. Para acceder a un x global después de la eliminación, debes declarar global x en tiempo de compilación.
¿Cómo evitan las expresiones de argumentos predeterminados la trampa UnboundLocalError, y qué revela esto sobre su momento de evaluación?
Los argumentos predeterminados se evalúan una vez cuando se ejecuta la definición de la función en el alcance que la contiene, no dentro del alcance local de la función. Si escribes def f(val=CONFIG["key"]):, Python usa LOAD_GLOBAL para resolver CONFIG en el momento de la definición. Incluso si el cuerpo de la función más tarde asigna a CONFIG, haciéndolo local, el valor predeterminado ya se capturó de manera segura. Esto revela que los valores predeterminados utilizan el alcance global en el momento de la definición, separado de la ejecución local del cuerpo de la función. Por lo tanto, los predeterminados evitan el UnboundLocalError que ocurriría si el mismo acceso ocurriera dentro del cuerpo de la función antes de la asignación.
¿Por qué nunca ocurre UnboundLocalError en los cuerpos de clase, y qué diferencia de bytecode permite esto?
Los cuerpos de clase utilizan LOAD_NAME en lugar de LOAD_FAST para el acceso a variables. LOAD_NAME realiza una búsqueda dinámica en el diccionario de la clase, luego en el diccionario global y luego en los builtins. No utiliza una ranura fija preasignada, por lo que nunca encuentra un estado de "variable local no vinculada". Si un nombre se referencia antes de la asignación en un cuerpo de clase, LOAD_NAME simplemente procede a buscarlo en el alcance global. Este enfoque basado en diccionario sacrifica la velocidad de las variables locales de función por la flexibilidad necesaria durante la construcción de la clase.