PythonProgrammationDéveloppeur Python

Qu'est-ce qui fait que les attributions au dictionnaire retourné par **Python**'s `locals()` sont ignorées dans les corps de fonction mais persistent au niveau du module ?

Réussissez les entretiens avec l'assistant IA Hintsage

Réponse à la question.

Dans CPython, l'implémentation de référence de Python, le comportement de locals() diverge en fonction de la portée d'exécution en raison de stratégies d'optimisation. Au niveau du module, locals() retourne le dictionnaire de l'espace de noms global lui-même, qui est le stockage autorisé pour les variables, donc toute modification se reflète immédiatement dans l'environnement. À l'intérieur d'une fonction, cependant, CPython utilise une optimisation appelée "fast locals", stockant les variables dans un tableau C de taille fixe de pointeurs PyObject* indexés par le bytecode plutôt que dans une table de hachage. Lorsque locals() est appelé dans une fonction, CPython crée un nouveau dictionnaire et le remplit en copiant les valeurs de ce tableau local rapide, produisant un instantané temporaire. En conséquence, écrire dans ce dictionnaire met à jour uniquement le mappage éphémère, laissant le tableau local rapide sous-jacent inchangé, de sorte que la fonction continue d'utiliser les valeurs de variables d'origine.

Situation de la vie réelle

Une équipe de développement construisait un outil de débogage dynamique qui permettait aux développeurs d'injecter des variables utilitaires temporaires au milieu de la portée d'une fonction en cours d'exécution via une interface de débogueur à distance. L'implémentation initiale capturait locals() à un point d'arrêt, injectait des objets d'aide dans le dictionnaire retourné et s'attendait à ce que la fonction en cours d'exécution accède à ces aides dans les lignes suivantes.

La première approche tentait de modifier directement le dictionnaire retourné par locals(), fonctionnant sous l'hypothèse qu'il s'agissait d'une référence vivante à l'espace de noms de la fonction. Avantages: Cela ne nécessitait aucun changement dans les signatures de fonction et semblait syntaxiquement simple. Inconvénients: Cela échouait silencieusement car CPython traite ce dictionnaire comme un instantané en lecture seule du tableau local rapide ; les modifications étaient rejetées, laissant les vraies variables locales inchangées.

La deuxième stratégie consistait à injecter l'état temporaire dans globals() à la place, utilisant l'espace de noms global comme tableau d'affichage partagé. Avantages: Cette méthode persistait les données à travers l'application et était accessible partout sans passer d'arguments. Inconvénients: Cela introduisait de graves dangers de sécurité des threads, polluait l'espace de noms global avec des données de débogage transitoires et violaient les principes d'encapsulation en exposant l'état interne à l'ensemble du processus.

La solution finale a refactorisé les fonctions instrumentées pour accepter un argument de dictionnaire context explicite, à travers lequel le débogueur pouvait passer un état mutable. Avantages: Cette approche est explicite, sûre pour les threads, et fonctionne de manière identique à travers CPython, PyPy, et Jython, respectant le principe Python selon lequel l'explicite est mieux que l'implicite. Inconvénients: Cela nécessitait de modifier les signatures de fonctions cibles et les sites d'appels, ce qui impliquait plus de refactoring initial que les autres approches.

L'équipe a adopté la stratégie de passage explicite de context. Cela a éliminé la dépendance aux détails d'implémentation spécifiques à CPython, empêché la pollution de l'espace de noms, et résulté en un utilitaire de débogage stable et multiplateforme.

Ce que les candidats manquent souvent

Pourquoi locals() se comporte-t-il différemment à l'intérieur d'une compréhension de liste par rapport à une boucle for standard au niveau du module ?

Dans Python 3, les compréhensions de liste introduisent leur propre portée locale, similaire à une fonction imbriquée, pour prévenir la fuite de variables de la variable de boucle dans l'espace de noms environnant. Lorsque locals() est appelé à l'intérieur d'une compréhension, il retourne le dictionnaire pour cette portée temporaire, et non la fonction ou le module englobant. De plus, tout comme dans les fonctions normales, ce dictionnaire est un instantané des espaces locaux rapides si la compréhension est implémentée comme un objet de code distinct, donc les écritures vers celui-ci ne persistent pas. En revanche, au niveau du module, locals() est un alias pour globals(), qui est le dictionnaire de module vivant. Cette distinction est critique car les développeurs supposent souvent que les compréhensions partagent le même espace de noms local que leur bloc conteneur, ce qui entraîne de la confusion lors de la tentative de débogage ou d'injection de variables à l'intérieur.

Pouvez-vous forcer un retour d'écriture vers les espaces locaux rapides en manipulant l'objet de frame via sys._getframe(), et quels sont les risques ?

Les utilisateurs avancés peuvent accéder à la frame d'exécution actuelle en utilisant sys._getframe() et modifier frame.f_locals, que CPython expose comme un mappage écrivable. Dans certaines versions, attribuer à frame.f_locals peut déclencher un retour d'écriture vers le tableau local rapide en utilisant des API internes comme PyFrame_LocalsToFast, mais ce comportement est dépendant de l'implémentation, fragile à la version, et ne fait pas partie de la spécification du langage. Les risques incluent la corruption de la mémoire si les comptes de référence ne sont pas gérés correctement, un comportement incohérent où l'optimiseur ignore les valeurs mises à jour parce qu'il les a déjà mises en cache dans des registres ou l'array, et un échec total dans d'autres implémentations de Python comme PyPy qui n'utilisent pas du tout une architecture de tableau local rapide. Se fier à cette technique introduit un comportement indéfini et rend le code impossible à maintenir à travers les versions de Python.

Comment la présence de exec() ou eval() avec des locaux explicites affecte-t-elle l'optimisation des espaces locaux rapides dans une fonction ?

Si un corps de fonction contient un appel à exec() ou eval() qui fait référence à l'espace de noms local, CPython ne peut garantir que les variables ne seront accédées que via le tableau local rapide optimisé ; la chaîne exécutée pourrait introduire ou supprimer dynamiquement des variables. Pour accommoder cela, le compilateur désactive l'optimisation locale rapide pour cette fonction, revenant à stocker toutes les variables locales dans un dictionnaire standard qui est consulté pour chaque accès. Dans ce mode "non optimisé", locals() retourne ce dictionnaire réel, ce qui en fait une vue mutable en direct où les modifications persistent immédiatement. Cela explique pourquoi le code utilisant exec() fonctionne souvent plus lentement et pourquoi locals() peut sembler "correct" (permettant des écritures) dans de telles fonctions, alors que dans des fonctions optimisées, ce n'est pas le cas.