W Pythonie rozwiązywanie zakresu zmiennych odbywa się statycznie podczas fazy kompilacji, a nie dynamicznie podczas wykonywania. Gdy kompilator CPython napotyka definicję funkcji, przeszukuje drzewo składniowe, aby zbudować tabelę symboli, która kategoryzuje każdą nazwę jako lokalną, globalną lub zmienną komórkową. Jeśli kompilator wykryje jakąkolwiek operację wiążącą — taką jak przypisanie, powiększone przypisanie lub import — dla nazwy w dowolnym miejscu w ciele funkcji, oznacza tę nazwę jako lokalną dla całego zakresu. Ten projekt umożliwia maszynie wirtualnej użycie zoptymalizowanych opkodów LOAD_FAST, które operują na tablicy stałej wielkości, a nie wykonują wolniejszych wyszukiwań w tabelach mieszających. Ta optymalizacja jest fundamentalna dla wydajności wywołań funkcji Pythona, ale wprowadza surowe wymagania dotyczące wiązań.
Gdy nazwa jest klasyfikowana jako lokalna, kompilator emituje instrukcje bajtowe LOAD_FAST dla wszystkich operacji odczytu tej nazwy. W czasie wykonywania LOAD_FAST próbuje odzyskać referencję do obiektu z odpowiedniego indeksu w tablicy lokalnych zmiennych ramki. Jeśli slot zawiera wskaźnik null, co wskazuje na to, że nie przypisano jeszcze wartości, czas wykonania zgłasza UnboundLocalError. Dzieje się tak nawet, jeśli istnieje zmienna globalna o identycznej nazwie, ponieważ kompilator celowo unikał emitowania LOAD_GLOBAL. Błąd wyraźnie wskazuje na tę decyzję o statycznym zakresie, odróżniając ją od NameError.
Aby to rozwiązać, musisz wyraźnie poinformować kompilator, że nazwa odnosi się do globalnej przestrzeni nazw, deklarując global <nazwa_zmiennej>. Ta deklaracja powoduje, że kompilator przechodzi do opkodów LOAD_GLOBAL i STORE_GLOBAL, które dynamicznie wyszukują nazwę w globalnym słowniku modułu. Alternatywnie, przekształć kod, aby upewnić się, że wszystkie lokalne zmienne są inicjowane na początku funkcji przed jakąkolwiek logiką warunkową, która je odczytuje. Dla zagnieżdżonych zakresów, słowo kluczowe nonlocal zmusza kompilator do użycia LOAD_DEREF, aby uzyskać dostęp do komórek zamknięcia. Te deklaracje zmieniają decyzję kompilatora o wiązaniu w czasie kompilacji, zapobiegając nieprzypisanej lokalnej scenariuszy.
threshold = 100 def analyze(data): # Kompilator widzi 'threshold = ...' poniżej, oznacza jako lokalne if data > threshold: # Zgłasza UnboundLocalError return "high" threshold = 50 # Przypisanie czyni lokalnym # Rozwiązanie z użyciem 'global' def analyze_fixed(data): global threshold if data > threshold: # LOAD_GLOBAL działa return "high" threshold = 50 # Aktualizuje zmienną globalną
Zespół inżynierów danych budował pipeline ETL przy użyciu Apache Airflow. Zdefiniowali domyślny słownik konfiguracyjny CONFIG = {"batch_size": 1000} na poziomie modułu, aby umożliwić łatwe dostosowanie parametrów przetwarzania. Główna funkcja transformacyjna process_batch() początkowo sprawdzała if len(records) > CONFIG["batch_size"]:, aby określić, czy podział jest konieczny. Później w funkcji, pod określonym warunkiem, kod próbował zoptymalizować pamięć, zmniejszając rozmiar wsadu za pomocą CONFIG = {"batch_size": 500}. Ten wzór przypadkowo wywołał konflikt zakresu.
Gdy pipeline został uruchomiony, zawiesił się w pierwszej linii funkcji z UnboundLocalError: local variable 'CONFIG' referenced before assignment. Instrukcja przypisania na końcu funkcji spowodowała, że kompilator Pythona traktował CONFIG jako lokalną zmienną dla całego ciała funkcji. W rezultacie operacja porównania na początku użyła LOAD_FAST, aby uzyskać dostęp do niezainicjowanego slotu lokalnej zmiennej. Ta awaria zatrzymała pipeline danych podczas kluczowego uruchomienia produkcyjnego, ponieważ funkcja nie mogła rozpocząć wykonywania.
Zespół najpierw rozważał zmienienie lokalnego przypisania na local_config, tworząc nowy słownik dla zmniejszonego przetwarzania wsadów. To unikałoby całkowicie problemu zaciemnienia i utrzymywałoby globalną konfigurację jako niezmienną. Jednakże, to podejście wymagało refaktoryzacji kodu downstream, który oczekiwał, że nazwa CONFIG odzwierciedli bieżące limity. Wprowadzało potencjalne niespójności, jeśli programista zapomniałby użyć nowej nazwy zmiennej w kolejnej logice. Obciążenie poznawcze związane z śledzeniem dwóch nazw zmiennych dla tego samego pojęcia sprawiło, że to rozwiązanie wydawało się mniej atrakcyjne.
Inną opcją było dodanie global CONFIG na początku funkcji, zmuszając kompilator do traktowania wszystkich odniesień jako globalnych wyszukiwań. Chociaż to zapobiegłoby błędowi, zespół odrzucił to, ponieważ modyfikowanie stanu globalnego podczas przetwarzania wsadów jest niebezpiecznym antywzorem. Uniemożliwia to reentrancy funkcji i znacząco komplikuje testy jednostkowe. Dodatkowo, mogłoby to wprowadzić warunki wyścigu, jeśli kod zostałby kiedykolwiek zrównoleglony w wątkach. Efekty uboczne dotyczące stanu na poziomie modułu uznano za niedopuszczalne w produkcyjnych pipeline'ach danych.
Trzecie rozwiązanie polegało na mutowaniu istniejącego słownika w miejscu za pomocą CONFIG["batch_size"] = 500, zamiast przypisywania samej nazwy zmiennej. Ponieważ ta operacja nie tworzy nowego wiązania dla nazwy CONFIG, kompilator nadal traktuje to jako odniesienie globalne. Unika to UnboundLocalError, jednocześnie pozwalając na aktualizację konfiguracji, która przetrwa na kolejnych wywołaniach. Uznano to za najlepsze natychmiastowe rozwiązanie, chociaż zespół planował późniejszą refaktoryzację konfiguracji do instancji klasy. Podejście mutacji zachowało istniejące API, jednocześnie rozwiązując natychmiastowy awarię.
Zrealizowali trzecie rozwiązanie, zmieniając przypisanie na mutację CONFIG["batch_size"] = 500. Pipeline wznowił wykonywanie bez błędów, a zmiana konfiguracji została poprawnie zastosowana do kolejnych wsadów. Później, refaktoryzowali kod, aby użyć obiektu ustawień Pydantic wstrzykniętego do funkcji. To całkowicie usunęło zależność od globalnych zmiennych na poziomie modułu i uczyniło funkcję czystą i testowalną. Incydent skłonił do przeglądu kodu wszystkich operatorów Airflow, aby wyeliminować podobne wzorce zaciemnienia.
Dlaczego użycie del zmiennej wewnątrz funkcji, a następnie próba jej odczytu, zgłasza UnboundLocalError zamiast wracać do zakresie globalnego?
Gdy wykonasz del x na zmiennej lokalnej, usuwa odniesienie z f_locals ramki, ale nie zmienia statycznej klasyfikacji x jako lokalnej. Kompilator nadal generuje LOAD_FAST dla kolejnych odczytów. Gdy interpreter wykonuje LOAD_FAST, znajduje slot pusty i zgłasza UnboundLocalError, a nie wraca do globalnych. To potwierdza, że decyzje dotyczące zakresu są niezmienne w czasie wykonywania. Aby uzyskać dostęp do globalnego x po usunięciu, musisz zadeklarować global x w czasie kompilacji.
Jak domyślne wyrażenia argumentów unikają pułapki UnboundLocalError, a co to ujawnia o ich czasie ewaluacji?
Domyślne argumenty są ewaluowane raz, gdy definicja funkcji jest wykonywana w otaczającym zakresie, a nie wewnątrz lokalnego zakresu funkcji. Jeśli napiszesz def f(val=CONFIG["key"]):, Python używa LOAD_GLOBAL, aby rozwiązać CONFIG w czasie definicji. Nawet jeśli ciało funkcji później przypisze do CONFIG, czyniąc go lokalnym, domyślna wartość została już bezpiecznie przechwycona. To ujawnia, że wartości domyślne używają zakresu globalnego w czasie definicji, oddzielnie od lokalnego wykonania ciała funkcji. W ten sposób domyślne wartości unikają UnboundLocalError, która wystąpiłaby, gdyby to samo odwołanie miało miejsce wewnątrz ciała funkcji przed przypisaniem.
Dlaczego UnboundLocalError nigdy nie występuje w ciałach klas, a jaka różnica w bajtkodzie to umożliwia?
Ciała klas używają LOAD_NAME zamiast LOAD_FAST do dostępu do zmiennych. LOAD_NAME przeprowadza dynamiczne wyszukiwanie w dict klasy, a następnie globalnym dict, a następnie w builtins. Nie używa prealokowanego stałego slotu, więc nigdy nie napotyka stanu "nieprzypisanej lokalnej". Jeśli nazwa jest odniesiona przed przypisaniem w ciele klasy, LOAD_NAME po prostu kontynuuje wyszukiwanie w zakresie globalnym. To podejście oparte na słownikach wymienia szybkość lokalnych funkcji na elastyczność potrzebną podczas tworzenia klasy.