関数の外にある変数を読めていたのに、同じ関数で代入した途端にUnboundLocalErrorになることがある。関数内にその名前への代入があると、通常は関数全体でローカル変数として扱われるためである。まずは値を引数で受け、更新後の値を戻り値で返す形にすると、状態の流れが分かりやすい。
足し算でも代入として扱われる
total += valueは、単に外の値を読むだけの文ではない。新しい値をtotalへ代入する操作も含む。そのため、その関数内のtotalはローカルと判断される。まだ値が設定されていないローカル変数を読もうとして、UnboundLocalErrorが発生する。
代入がifの内側にあり、実際にはその分岐を通らなかったとしても、名前の扱いは同じである。実行された行だけを見て「外側の値を読めるはず」と考えると分かりにくい。関数全体の中でその名前へ代入していないかを確認しよう。
入力と戻り値で更新を表す
example.py
total = 10
def bad_add(value):
total += value
return total
try:
bad_add(2)
except UnboundLocalError:
print("UnboundLocalError")
else:
raise AssertionError("The local value has not been assigned")
assert total == 10
def add(total, value):
return total + value
total = add(total, 2)
print("returned:", total)
assert total == 12 and add(0, 3) == 3
def make_counter():
value = 0
def increment():
nonlocal value
value += 1
return value
return increment
first = make_counter()
second = make_counter()
values = [first(), first(), second()]
print("counters:", values)
assert values == [1, 2, 1]
実行結果
UnboundLocalError
returned: 12
counters: [1, 2, 1]
addのtotalは引数として値を受け取るので、未初期化の参照にならない。外側でtotal = add(total, 2)と代入し直すことで、どこで状態が変わったかを追える。関数が隠れたグローバル変数を変更しないため、異なる初期値でも試しやすい。
囲む関数の状態を持ちたいならnonlocal
後半のmake_counterは、作るたびに独立したvalueを持つ。内側のincrementからその値を再代入するため、nonlocal valueを宣言している。nonlocalは、直近の適切な外側の関数スコープにある既存の束縛を使う指定である。
nonlocalでモジュール全体のグローバル変数を指定することはできない。また、対応する外側の関数にその名前がなければ構文上の問題になる。どこに状態を置き、どの関数がそれを更新するかが明確な場合に使うとよい。
globalを最初の解決策にしない
global totalを宣言すれば、モジュールのtotalへ再代入することはできる。ただし、複数の関数が同じ状態を書き換える設計になる。呼び出し順によって結果が変わりやすく、独立した計算を並べて試すときにも影響が混ざることがある。
短い確認用コードではglobalが分かりやすい場合もあるが、解析関数では入力と戻り値で受け渡す方が再利用しやすい。継続した状態が必要な場面では、クロージャーや専用オブジェクトなど、その状態を持つ範囲を限定する方法も考えられる。
オブジェクトの変更と名前への代入は違う
外側のリストへitems.append(value)する処理は、itemsという名前を別のオブジェクトへ再代入する操作ではない。そのため、今回のtotal += valueとまったく同じ規則でエラーになるわけではない。ただしリスト自体の状態を変更する副作用は残る。
リストに対する+=は、型によってインプレースの操作になることがあっても、構文上は名前への代入を伴う。この違いから混乱しやすいので、外側の可変オブジェクトを使う場合も、変更したいのが名前の参照先なのか、中身なのかを区別したい。
確認するのは例外が消えたかだけではない
修正後は、初期値を変えた呼び出し、複数の独立したカウンター、連続した更新を試す。例ではfirstが二回進んでもsecondは1から始まることを確認している。意図した範囲でだけ状態が共有されているかを見ることが大切である。
名前のスコープが原因なら、変数名を何となく変えるだけでは別の場所で同じ問題が起こり得る。値の置き場所、代入する場所、結果を受け取る場所を分けて読むと、UnboundLocalErrorだけでなく、思わぬグローバル状態の変更も見つけやすくなる。
動作確認と参考資料
掲載例はLinux・CPython 3.12.14で動作確認した。OS固有のコマンドや環境ごとに変わるパスは、本文中の条件を確認して使ってほしい。
