PythonProgrammationDéveloppeur Python

Qu'est-ce qui provoque l'élévation de l'UnboundLocalError en Python lorsqu'une fonction fait référence à une variable avant de l'assigner, même s'il existe une variable globale avec le même nom ?

Réussissez les entretiens avec l'assistant IA Hintsage

Réponse à la question

Dans Python, la résolution de la portée des variables est effectuée statiquement durant la phase de compilation plutôt que dynamiquement durant l'exécution. Lorsque le compilateur CPython rencontre une définition de fonction, il parcourt l'arbre syntaxique abstrait pour construire une table des symboles qui classe chaque nom comme local, global ou variable de cellule. Si le compilateur détecte toute opération de liaison — telle qu'une assignation, une assignation augmentée ou une importation — pour un nom dans le corps de la fonction, il marque ce nom comme une variable locale pour la portée entière. Ce design permet à la machine virtuelle d'utiliser des opcodes optimisés LOAD_FAST qui opèrent sur un tableau de taille fixe plutôt que d'effectuer des recherches dans une table de hachage plus lentes. Cette optimisation est fondamentale pour la performance des appels de fonction de Python mais introduit des exigences de liaison strictes.

Lorsqu'un nom est classé comme local, le compilateur émet des instructions de bytecode LOAD_FAST pour toutes les opérations de lecture de ce nom. Pendant l'exécution, LOAD_FAST tente de récupérer la référence de l'objet à partir de l'index correspondant dans le tableau des variables locales de la pile. Si l'emplacement contient un pointeur nul indiquant qu'aucune valeur n'a encore été assignée, le runtime élève l'UnboundLocalError. Cela se produit même s'il existe une variable globale avec le même nom, car le compilateur a délibérément évité d'émettre LOAD_GLOBAL. L'erreur indique explicitement cette décision de portée statique, la distinguant de NameError.

Pour résoudre cela, vous devez informer explicitement le compilateur que le nom fait référence à l'espace de noms global en déclarant global <variable_name>. Cette déclaration amène le compilateur à passer à des opcodes LOAD_GLOBAL et STORE_GLOBAL, qui recherchent dynamiquement le nom dans le dictionnaire global du module. Alternativement, restructurez le code pour vous assurer que toutes les variables locales sont initialisées en haut de la fonction avant que toute logique conditionnelle ne les lise. Pour les portées imbriquées, le mot-clé nonlocal force le compilateur à utiliser LOAD_DEREF pour accéder aux cellules de fermeture. Ces déclarations modifient la décision de liaison du compilateur au moment de la compilation, empêchant le scénario de local non lié.

threshold = 100 def analyze(data): # Le compilateur voit 'threshold = ...' ci-dessous, le marque comme local if data > threshold: # Éleve UnboundLocalError return "high" threshold = 50 # L'assignation le rend local # Solution utilisant 'global' def analyze_fixed(data): global threshold if data > threshold: # LOAD_GLOBAL réussit return "high" threshold = 50 # Met à jour la variable globale

Situation de la vie réelle

Une équipe d'ingénierie des données construisait un pipeline ETL utilisant Apache Airflow. Ils ont défini un dictionnaire de configuration par défaut CONFIG = {"batch_size": 1000} au niveau du module pour permettre un ajustement facile des paramètres de traitement. La fonction de transformation principale process_batch() vérifiait d'abord if len(records) > CONFIG["batch_size"]: pour déterminer si un fractionnement était nécessaire. Plus tard dans la fonction, sous une condition spécifique, le code a tenté d'optimiser la mémoire en réduisant la taille du lot avec CONFIG = {"batch_size": 500}. Ce modèle a involontairement déclenché un conflit de portée.

Lorsque le pipeline s'exécutait, il s'est écrasé sur la première ligne de la fonction avec UnboundLocalError: variable locale 'CONFIG' référencée avant l'assignation. L'instruction d'assignation à la fin de la fonction a fait que le compilateur Python a traité CONFIG comme une variable locale pour l'ensemble du corps de la fonction. Par conséquent, l'opération de comparaison au début utilisait LOAD_FAST pour accéder à l'emplacement de la variable locale non initialisée. Cet échec a arrêté le pipeline de données durant un exécution critique en production parce que la fonction ne pouvait pas commencer son exécution.

L'équipe a d'abord envisagé de renommer la réassignation locale en local_config, créant un nouveau dictionnaire pour le traitement de lots réduit. Cela éviterait complètement l'ombre et garderait la configuration globale immuable. Cependant, cette approche nécessitait de refactoriser le code en aval qui s'attendait à ce que le nom CONFIG reflète les limites actuelles. Cela introduisait des incohérences potentielles si le développeur oubliait d'utiliser le nouveau nom de variable dans la logique ultérieure. La surcharge cognitive de suivre deux noms de variables pour le même concept a rendu cette solution moins attrayante.

Une autre option était d'ajouter global CONFIG au début de la fonction, forçant le compilateur à traiter toutes les références comme des recherches globales. Bien que cela empêcherait l'erreur, l'équipe l'a rejetée car modifier l'état global durant un processus par lots est un anti-modèle dangereux. Cela empêche la réentrance de la fonction et complique considérablement les tests unitaires. De plus, cela créerait des conditions de concurrence si le code était jamais parallélisé à travers des threads. Les effets secondaires sur l'état au niveau du module ont été jugés inacceptables pour les pipelines de données en production.

La troisième solution impliquait de muter le dictionnaire existant en place en utilisant CONFIG["batch_size"] = 500 plutôt que de réassigner le nom de la variable lui-même. Comme cette opération ne crée pas de nouvelle liaison pour le nom CONFIG, le compilateur continue de le traiter comme une référence globale. Cela évite l'UnboundLocalError tout en permettant à la mise à jour de la configuration de persister pour les appels suivants. Cela a été jugé comme le meilleur correctif immédiat, même si l'équipe prévoyait de refactoriser la configuration dans une instance de classe plus tard. L'approche de mutation a préservé l'API existante tout en résolvant l'écrasement immédiat.

Ils ont mis en œuvre la troisième solution, changeant la réassignation en une mutation CONFIG["batch_size"] = 500. Le pipeline a repris son exécution sans erreurs, et le changement de configuration s'est appliqué correctement aux lots suivants. Plus tard, ils ont refactorisé le code pour utiliser un objet de paramètres Pydantic injecté dans la fonction. Cela a complètement supprimé la dépendance aux variables globales au niveau du module et a rendu la fonction pure et testable. L'incident a incité à une révision du code de tous les opérateurs Airflow pour éliminer des modèles de sombra similaires.

Ce que les candidats manquent souvent

Pourquoi le fait de del une variable à l'intérieur d'une fonction, suivi d'une tentative de la lire, élève l'UnboundLocalError au lieu de tomber dans la portée globale ?

Lorsque vous exécutez del x sur une variable locale, cela supprime la référence de f_locals de la pile mais ne change pas la classification statique de x comme locale. Le compilateur a toujours généré LOAD_FAST pour les lectures subséquentes. Lorsque l'interpréteur exécute LOAD_FAST, il trouve l'emplacement vide et lève UnboundLocalError plutôt que de revenir aux globales. Cela confirme que les décisions de portée sont immuables à l'exécution. Pour accéder à un global x après la suppression, vous devez déclarer global x au moment de la compilation.

Comment les expressions d'arguments par défaut évitent-elles le piège de l'UnboundLocalError, et que révèle cela sur leur moment d'évaluation ?

Les arguments par défaut sont évalués une fois lorsque la définition de la fonction s'exécute dans la portée englobante, pas à l'intérieur de la portée locale de la fonction. Si vous écrivez def f(val=CONFIG["key"]):, Python utilise LOAD_GLOBAL pour résoudre CONFIG au moment de la définition. Même si le corps de la fonction assigne plus tard à CONFIG, le rendant local, la valeur par défaut a déjà été capturée en toute sécurité. Cela révèle que les valeurs par défaut utilisent la portée globale au moment de la définition, séparément de l'exécution locale du corps de la fonction. Ainsi, les valeurs par défaut évitent l'UnboundLocalError qui se produirait si le même accès se produisait à l'intérieur du corps de la fonction avant l'assignation.

Pourquoi l'UnboundLocalError ne se produit-il jamais dans les corps de classe, et quelle différence de bytecode cela permet-il ?

Les corps de classe utilisent LOAD_NAME au lieu de LOAD_FAST pour l'accès aux variables. LOAD_NAME effectue une recherche dynamique dans le dictionnaire de classe, puis le dictionnaire global, puis les builtins. Il n'utilise pas un emplacement fixe pré-alloué, donc il ne rencontre jamais un état "local non lié". Si un nom est référencé avant l'assignation dans un corps de classe, LOAD_NAME procède simplement pour le trouver dans la portée globale. Cette approche basée sur les dictionnaires échange la rapidité des locaux de la fonction pour la flexibilité nécessaire durant la construction de classe.