← Wszystkie wpisy

UnboundLocalError w Pythonie: winna jest inna linia

Traceback wskazuje print, a przyczyną jest przypisanie dwie linie niżej, w gałęzi if, która się nie wykonała. Skąd Python wie o nim przed uruchomieniem funkcji.

Artur Kot 10 min czytania

Ta funkcja liczy cenę z rabatem albo bez niego. Wywołana bez rabatu nie powinna niczego zmieniać, a mimo to kończy się błędem UnboundLocalError:

cena = 100

def podsumuj(rabat):
    print("Cena przed rabatem:", cena)
    if rabat:
        cena = cena * 0.9
    return cena

print(podsumuj(False))

Python 3.14.7 drukuje taki traceback (ścieżkę do pliku skróciłem, reszta jest bez zmian):

Traceback (most recent call last):
  File "...\pozniej.py", line 9, in <module>
    print(podsumuj(False))
          ~~~~~~~~^^^^^^^
  File "...\pozniej.py", line 4, in podsumuj
    print("Cena przed rabatem:", cena)
                                 ^^^^
UnboundLocalError: cannot access local variable 'cena' where it is not associated with a value

Podkreślenie znajduje się pod nazwą cena w wywołaniu print, czyli w linii 4. Ta linia tylko czyta zmienną, która istnieje: cena = 100 jest zdefiniowana na samej górze pliku. Gdyby linia 4 była jedyną w funkcji, program wydrukowałby 100 i nikt by się nie zdziwił.

Przyczyna znajduje się dwie linie niżej.

Przypisanie cena = cena * 0.9 w linii 6 się nie wykonało, bo rabat ma wartość False. Wystarczyło, że znajduje się w treści funkcji. Traceback wskazuje tylko miejsce odczytu, a linii 6 w ogóle nie wymienia.

Python ustala zasięg przed wywołaniem funkcji

Kiedy interpreter kompiluje def, przegląda całe ciało funkcji, a nie tylko linie, które wykonają się w danym wywołaniu. Każda nazwa, do której funkcja gdziekolwiek coś przypisuje, staje się zmienną lokalną. Dla całej funkcji, od pierwszej linii.

Nie ma wyjątku dla gałęzi, które się nie wykonają, ani dla przypisań leżących poniżej odczytu. O tym, czy cena w linii 4 oznacza zmienną modułu, nie decyduje kolejność wykonania. Decyduje sam fakt, że gdzieś w funkcji występuje cena =.

Najprościej sprawdzić to wywołaniem locals() jako pierwszą instrukcją tej samej funkcji. Wynik tuż przed wyjątkiem:

{'rabat': False}

Słownik zmiennych lokalnych zawiera tylko parametr. Nazwy cena w nim nie ma, bo nic jeszcze nie zapisało jej wartości, ale Python i tak nie zajrzy do zmiennych modułu.

Dokładniej widać to w kodzie bajtowym. Dwie funkcje różnią się tylko tym, czy przypisują coś do licznik:

import dis

licznik = 0

def czytaj():
    return licznik + 1

def dodaj():
    licznik += 1

dis.dis(czytaj)
dis.dis(dodaj)
print(czytaj.__code__.co_varnames, dodaj.__code__.co_varnames)

Moduł dis drukuje dla nich:

czytaj():
  5           RESUME                   0

  6           LOAD_GLOBAL              0 (licznik)
              LOAD_SMALL_INT           1
              BINARY_OP                0 (+)
              RETURN_VALUE
dodaj():
  8           RESUME                   0

  9           LOAD_FAST_CHECK          0 (licznik)
              LOAD_SMALL_INT           1
              BINARY_OP               13 (+=)
              STORE_FAST               0 (licznik)
              LOAD_CONST               1 (None)
              RETURN_VALUE
() ('licznik',)

W czytaj() odczyt to LOAD_GLOBAL. Python szuka nazwy w module i ją znajduje.

W dodaj() ten sam odczyt to LOAD_FAST_CHECK, czyli odczyt z lokalnej komórki funkcji, połączony ze sprawdzeniem, czy komórka coś zawiera. Komórka jest pusta, więc właśnie ta instrukcja zgłasza wyjątek. Ostatnia linia wyniku mówi to samo krócej: co_varnames, lista zmiennych lokalnych ustalona w chwili kompilacji, dla czytaj jest pusta, a dla dodaj zawiera licznik.

Liczba 0 przy LOAD_FAST_CHECK i STORE_FAST to numer komórki. Zmienne lokalne funkcji są przechowywane w tablicy, a kompilator z góry przydziela każdej nazwie jej pozycję. W trakcie działania nie ma już szukania po nazwie, jest tylko odczyt spod ustalonego indeksu. Właśnie dlatego decyzja „lokalna czy globalna” musi zapaść przed pierwszym wywołaniem i nie może zależeć od tego, która gałąź się wykona.

Treść komunikatu opisuje to precyzyjnie. Python nie twierdzi, że nazwy nie ma. Twierdzi, że zmienna lokalna o tej nazwie nie ma jeszcze wartości.

Trzy warianty tego samego błędu

W kodzie początkujących ten mechanizm pojawia się najczęściej w trzech wariantach.

Pierwszy to licznik. licznik += 1 wygląda jak modyfikacja istniejącej zmiennej, a dla kompilatora jest przypisaniem, bo oznacza licznik = licznik + 1:

  File "...\licznik.py", line 4, in dodaj
    licznik += 1
    ^^^^^^^
UnboundLocalError: cannot access local variable 'licznik' where it is not associated with a value

Drugi to zmienna przypisana tylko w jednej gałęzi. Nie ma tu żadnej zmiennej globalnej, a błąd jest najbardziej podstępny z trzech, bo dla części danych wszystko działa:

def ocena(punkty):
    if punkty >= 50:
        wynik = "zaliczone"
    return wynik

print(ocena(80))
print(ocena(35))

Pierwsze wywołanie drukuje zaliczone. Drugie kończy się wyjątkiem na linii return wynik, z tym samym komunikatem o wynik. Testy napisane tylko na danych z zaliczeniem przejdą, a błąd wyjdzie przy pierwszym wyniku poniżej progu.

Trzeci to funkcja zagnieżdżona, która próbuje zmienić zmienną funkcji zewnętrznej. Ten układ pojawia się zawsze, gdy funkcja ma pamiętać coś między kolejnymi wywołaniami, na przykład numer następnej faktury albo liczbę kliknięć przycisku, a nie powinna przy tym sięgać do zmiennych całego modułu. Najkrótsza wersja wygląda tak:

def utworz_licznik():
    stan = 0
    def kolejny():
        stan += 1
        return stan
    return kolejny

Wywołanie kolejny() kończy się komunikatem o stan. Reguła jest ta sama: stan += czyni stan zmienną lokalną funkcji wewnętrznej i odcina ją od zmiennej o tej samej nazwie w funkcji zewnętrznej.

Ten sam tekst komunikatu dostałem z Pythona 3.12 zainstalowanego obok. Różnica jest kosmetyczna: starsza wersja nie rysuje podkreślenia ~~~~~^^ pod wywołaniem funkcji w linii wyżej.

Przypisaniem jest więcej, niż widać

Znak = to nie jedyny sposób, żeby nazwa stała się lokalna. Pętla for też przypisuje, i to potrafi zepsuć wbudowaną funkcję:

def raport(pozycje):
    print("Pozycji:", len(pozycje))
    for len in pozycje:
        print(len)

Wywołanie raport(["a", "b"]) kończy się na pierwszej linii funkcji komunikatem cannot access local variable 'len'. Ten komunikat dezorientuje bardziej niż pozostałe, bo len nie jest zmienną, tylko funkcją wbudowaną, której używasz od pierwszego dnia nauki. Pętla kilka linii niżej uczyniła jednak len zmienną lokalną, więc wbudowana funkcja o tej nazwie jest w całym ciele raport niewidoczna.

To samo robi import wewnątrz funkcji. Taki import zwykle ma swój powód: ktoś przenosi wolno ładujący się moduł do funkcji, żeby program startował szybciej, i zapomina, że ta sama nazwa jest już używana wyżej. Gdy moduł importuje json na górze pliku, a funkcja w jakiejś gałęzi ma własne import json, to każdy wcześniejszy odczyt json.dumps w tej funkcji zgłasza UnboundLocalError dla json. Sprawdziłem to na tym samym interpreterze.

Pełna lista konstrukcji, które wiążą nazwę, obejmuje jeszcze except ... as, with ... as, del i definicję zagnieżdżonej funkcji albo klasy.

Co złapie linter, a co dopiero test

Większość tych przypadków da się wykryć bez uruchamiania programu. Ruff 0.17.2 z domyślnymi regułami zgłasza je jako F823:

F823 Local variable `cena` referenced before assignment
 --> pozniej.py:4:34
  |
3 | def podsumuj(rabat):
4 |     print("Cena przed rabatem:", cena)
  |                                  ^^^^
5 |     if rabat:
6 |         cena = cena * 0.9
  |

Ruff od razu pokazuje linię 6, czyli przypisanie, którego traceback w ogóle nie wymienia. To samo ostrzeżenie dostałem dla licznika, dla funkcji zagnieżdżonej, dla len i dla json.

Dla funkcji ocena ruff nie zgłosił niczego.

Analiza statyczna nie wie, jakie wartości przyjdą w punkty. Przypisanie w gałęzi if może się wykonać, więc dla lintera kod jest poprawny, a błąd zależy wyłącznie od danych. Taki przypadek łapie dopiero test, który poda wartość poniżej progu. Dwa testy w pytest 9.1.1, jeden z wynikiem 80 punktów i drugi z wynikiem 35:

    def ocena(punkty):
        if punkty >= 50:
            wynik = "zaliczone"
>       return wynik
               ^^^^^
E       UnboundLocalError: cannot access local variable 'wynik' where it is not associated with a value

ocena.py:4: UnboundLocalError
=========================== short test summary info ===========================
FAILED test_ocena.py::test_ponizej_progu - UnboundLocalError: cannot access l...
1 failed, 1 passed in 0.06s

Pierwszy test przechodzi. Gdyby był jedynym, funkcja trafiłaby dalej z błędem w środku. Pytest wypisuje przy tym punkty = 35, czyli wartość, przy której gałąź się nie wykonała, i to jest najszybsza droga do przyczyny.

Poprawki, od najlepszej

Najczęstsza rada w sieci brzmi „dopisz global”. Działa, ale warto po nią sięgać na końcu.

Licznik najlepiej przekazać parametrem i zwrócić wynik. Funkcja przestaje wtedy zależeć od stanu modułu:

def dodaj(licznik):
    return licznik + 1

licznik = 0
licznik = dodaj(licznik)
licznik = dodaj(licznik)
print(licznik)

Program drukuje 2. Taką funkcję testuje się jednym wywołaniem i nie trzeba pamiętać, ile razy wywołano ją wcześniej.

Zmiennej z gałęzi trzeba nadać wartość na każdej ścieżce: przed if albo w else. Wyjątek sygnalizuje tu prawdziwą lukę w logice. Funkcja nie wie, co zwrócić dla wyniku poniżej progu, i to trzeba rozstrzygnąć, a nie przykryć. Tutaj odpowiedź jest prosta dla każdego, kto zna regulamin zaliczeń, więc wystarczy ją zapisać:

def ocena(punkty):
    wynik = "niezaliczone"
    if punkty >= 50:
        wynik = "zaliczone"
    return wynik

Z tą wersją oba testy przechodzą (2 passed in 0.01s). Wartość domyślna znajduje się teraz na górze funkcji, gdzie widać ją przy pierwszym czytaniu, zamiast wynikać z braku przypisania gdzieś niżej.

W funkcji zagnieżdżonej pomaga słowo kluczowe nonlocal, które mówi kompilatorowi, że nazwa należy do funkcji otaczającej:

def utworz_licznik():
    stan = 0
    def kolejny():
        nonlocal stan
        stan += 1
        return stan
    return kolejny

k = utworz_licznik()
print(k(), k(), k())

Wynik to 1 2 3. Każdy licznik utworzony przez utworz_licznik() ma własny stan.

global robi to samo dla zmiennej modułu i w pierwszym przykładzie usuwa wyjątek. Sprawdziłem, co dzieje się dalej. Ta sama funkcja z dopisanym global cena, wywołana dwa razy z rabatem i raz bez niego:

Cena przed rabatem: 100
90.0
Cena przed rabatem: 90.0
81.0
Cena przed rabatem: 81.0
81.0

Drugi rabat został naliczony od ceny już obniżonej, a trzecie wywołanie, bez rabatu, zwraca 81 zamiast 100. Żadnego wyjątku. Program wyświetla złe liczby i nic nie wskazuje miejsca, w którym się zepsuł.

Ta niewygoda jest zamierzona. Gdyby przypisanie wewnątrz funkcji po cichu zmieniało zmienną modułu, każda pomocnicza zmienna o popularnej nazwie mogłaby nadpisać coś w zupełnie innej części programu. Python wymaga więc, żeby zmianę cudzej zmiennej zapowiedzieć wprost, przez global albo nonlocal, i w ten sposób zostawia w kodzie ślad, który widać przy czytaniu.

Czytanie tego tracebacku następnym razem

Weź nazwę z komunikatu i przeszukaj całą funkcję, w której wystąpił błąd, a nie tylko linię z podkreśleniem. Szukasz każdego miejsca, które wiąże tę nazwę: nazwa =, nazwa +=, for nazwa in, import nazwa, as nazwa. Któreś z nich jest przyczyną, często poniżej odczytu albo w gałęzi, o której akurat nie myślisz.

Gdy funkcja jest długa i szukanie nie daje wyniku, wstaw print(locals()) tuż przed linią z błędem. Nazwa, której brakuje w wydrukowanym słowniku, a która jest w komunikacie, to zmienna uznana za lokalną bez wartości.

Potem zdecyduj, czego chcesz. Jeśli zmienna ma być lokalna, nadaj jej wartość przed pierwszym odczytem. Jeśli ma pochodzić z zewnątrz, przekaż ją parametrem. A jeśli edytor ma podpięty linter, ostrzeżenie F823 pojawi się, zanim w ogóle uruchomisz program.

Zasięg zmiennych i czytanie tracebacków to materiał rozdziałów Funkcje i Debugowanie w programie kursu Pythona. Ćwiczenia uruchamiasz tam na prawdziwym interpreterze, z debuggerem i testami wbudowanymi w aplikację.

Tematy: Błędy i komunikatyDebugowanie

Pobieranie ruszyło

DevJourney.for.Python_1.1.1_x64-setup.exe · 1.1.1

Jeśli system ostrzeże i jak sprawdzić plik

Jeśli Windows ostrzeże

Komunikat „System Windows ochronił Twój komputer” nie oznacza wykrycia wirusa. SmartScreen ufa certyfikatowi, którym podpisany jest instalator, a ten jeszcze zbiera reputację poprzez licznik pobrań.

W oknie ostrzeżenia: Więcej informacji → Uruchom mimo to.

Sprawdź to sam

Zobacz wynik skanowania w VirusTotal ↗

Albo w PowerShellu, w katalogu z pobranym plikiem:

Get-FileHash .\DevJourney.for.Python_1.1.1_x64-setup.exe

Powinno wypisać:

e78d7a73363185afc1516777a53ae47c30937c4c70e97a340fdb10896ece86dc