PythonProgramaciónDesarrollador Python

¿Qué causa que las asignaciones al diccionario devuelto por **Python**'s `locals()` sean ignoradas en los cuerpos de funciones pero persistan a nivel de módulo?

Supere entrevistas con el asistente de IA Hintsage

Respuesta a la pregunta.

En CPython, la implementación de referencia de Python, el comportamiento de locals() diverge según el ámbito de ejecución debido a estrategias de optimización. A nivel de módulo, locals() devuelve el diccionario del espacio de nombres global, que es el almacenamiento autoritativo para las variables, por lo que cualquier modificación se refleja de inmediato en el entorno. Sin embargo, dentro de una función, CPython emplea una optimización llamada "locales rápidas", almacenando variables en un arreglo C de tamaño fijo de punteros PyObject* indexados por bytecode en lugar de en una tabla hash. Cuando locals() se llama dentro de una función, CPython crea un nuevo diccionario y lo llena copiando valores de este arreglo local rápido, produciendo una instantánea temporal. En consecuencia, escribir en este diccionario actualiza solo el mapeo efímero, dejando el arreglo local rápido subyacente inalterado, por lo que la función continúa utilizando los valores de las variables originales.

Situación de la vida real

Un equipo de desarrollo estaba construyendo una herramienta de depuración dinámica que permitía a los desarrolladores inyectar variables utilitarias temporales en medio del ámbito de una función en ejecución a través de una interfaz de depurador remoto. La implementación inicial capturó locals() en un punto de interrupción, inyectó objetos de ayuda en el diccionario devuelto y esperaba que la función en ejecución accediera a estos ayudantes en las líneas posteriores.

El primer enfoque intentó mutar el diccionario devuelto por locals() directamente, operando bajo la suposición de que era una referencia activa al espacio de nombres de la función. Pros: No requería cambios en las firmas de las funciones y parecía sintácticamente simple. Contras: Falló silenciosamente porque CPython trata este diccionario como una instantánea de solo lectura del arreglo local rápido; los cambios se descartaron, dejando las variables locales reales inalteradas.

La segunda estrategia implicó inyectar el estado temporal en globals() en su lugar, utilizando el espacio de nombres global como un tablón de anuncios compartido. Pros: Este método persistió datos a través de la aplicación y fue accesible en todas partes sin necesidad de pasar argumentos. Contras: Introdujo graves peligros de seguridad de hilos, contaminó el espacio de nombres global con datos de depuración transitorios y violó principios de encapsulación al exponer el estado interno a todo el proceso.

La solución final refactorizó las funciones instrumentadas para aceptar un argumento de diccionario contexto explícito, a través del cual el depurador podría pasar estado mutable. Pros: Este enfoque es explícito, seguro para hilos y funciona igual en CPython, PyPy y Jython, adhiriéndose al principio de Python de que lo explícito es mejor que lo implícito. Contras: Requería modificar las firmas y los sitios de llamada de las funciones objetivo, lo que implicaba más refactorización inicial que los otros enfoques.

El equipo adoptó la estrategia de pasar contexto explícitamente. Esto eliminó la dependencia de detalles de implementación específicos de CPython, evitó la contaminación del espacio de nombres y resultó en una utilidad de depuración estable y multiplataforma.

Lo que los candidatos a menudo pasan por alto

¿Por qué locals() se comporta de manera diferente dentro de una comprensión de lista en comparación con un bucle for estándar a nivel de módulo?

En Python 3, las comprensiones de lista introducen su propio ámbito local, similar a una función anidada, para prevenir la fuga de variables de la variable del bucle al espacio de nombres circundante. Cuando locals() se llama dentro de una comprensión, devuelve el diccionario para este ámbito temporal, no la función o módulo que lo rodea. Además, al igual que en las funciones regulares, este diccionario es una instantánea de los locales rápidos si la comprensión se implementa como un objeto de código separado, por lo que las escrituras en él no persisten. En contraste, a nivel de módulo, locals() es un alias para globals(), que es el diccionario del módulo en vivo. Esta distinción es crítica porque los desarrolladores a menudo asumen que las comprensiones comparten el mismo espacio de nombres local que su bloque contenedor, lo que lleva a confusiones al intentar depurar o inyectar variables dentro de ellas.

¿Puedes forzar una escritura en los locales rápidos manipulando el objeto de marco a través de sys._getframe(), y cuáles son los riesgos?

Los usuarios avanzados pueden acceder al marco de ejecución actual utilizando sys._getframe() y modificar frame.f_locals, que CPython expone como un mapeo escribible. En algunas versiones, asignar a frame.f_locals puede activar una escritura de vuelta en el arreglo local rápido utilizando APIs internas como PyFrame_LocalsToFast, pero este comportamiento es dependiente de la implementación, frágil entre versiones y no forma parte de la especificación del lenguaje. Los riesgos incluyen corrupción de memoria si los conteos de referencia no se gestionan correctamente, comportamiento inconsistente donde el optimizador ignora los valores actualizados porque ya los ha almacenado en registros o en el arreglo, y un fallo completo en otras implementaciones de Python como PyPy que no utilizan una arquitectura de arreglo local rápido en absoluto. Confiar en esta técnica introduce un comportamiento indefinido y hace que el código sea imposible de mantener a través de las versiones de Python.

¿Cómo afecta la presencia de exec() o eval() con locales explícitos a la optimización de locales rápidos en una función?

Si el cuerpo de una función contiene una llamada a exec() o eval() que hace referencia al espacio de nombres local, CPython no puede garantizar que las variables solo se accederán a través del arreglo local rápido optimizado; la cadena ejecutada podría introducir o eliminar variables dinámicamente. Para acomodar esto, el compilador desactiva la optimización local rápida para esa función, retrocediendo a almacenar todas las variables locales en un diccionario estándar que se consulta para cada acceso. En este modo "no optimizado", locals() devuelve este diccionario real, convirtiéndolo en una vista mutable en vivo donde los cambios persisten de inmediato. Esto explica por qué el código que utiliza exec() a menudo se ejecuta más lentamente y por qué locals() podría parecer funcionar "correctamente" (permitiendo escrituras) en tales funciones, mientras que en funciones optimizadas no lo hace.