ループの番号ごとにlambdaを作ったのに、後から呼ぶとすべて最後の番号になることがある。関数は通常、外側の変数の作成時の値をコピーするのではなく、呼び出すときに参照するためである。生成時の値を保持したいなら、デフォルト引数へ入れるか、値を受け取って関数を作るファクトリーを使う。
lambdaだけの特別な不具合ではない
例えば番号0、1、2に対応するコールバックを作り、後からまとめて呼びたい場合を考える。lambda: iと書くと、iの値をその場で数値として埋め込むわけではない。ループが終わった後には、各関数が同じ最後のiを参照するため、同じ結果になる。
この動作はlambdaという短い書き方に限らない。defで内側の関数を定義し、同じ外側の変数を参照しても起こり得る。問題は関数の長さではなく、いつ、どの変数を参照するかという束縛の仕方にある。
デフォルト引数で生成時の値を保持する
example.py
bad = [lambda: i for i in range(3)]
good = [lambda i=i: i for i in range(3)]
print("late:", [f() for f in bad])
print("defaults:", [f() for f in good])
assert [f() for f in bad] == [2, 2, 2]
assert [f() for f in good] == [0, 1, 2]
def make_callback(index):
def callback():
return index
return callback
callbacks = [make_callback(i) for i in range(3)]
print("factory:", [f() for f in callbacks])
assert [f() for f in callbacks] == [0, 1, 2]
items = [1]
get_items = lambda saved=items: saved
items.append(2)
print("mutable:", get_items())
assert get_items() is items and get_items() == [1, 2]
実行結果
late: [2, 2, 2]
defaults: [0, 1, 2]
factory: [0, 1, 2]
mutable: [1, 2]
lambda i=iの左側は新しい関数の引数名、右側は作成時に評価する外側のiである。既定値は関数を定義するときに決まるため、それぞれの関数が異なる数値を保持できる。引数を省略して呼ぶと、その保存された値が使われる。
ファクトリーなら状態の範囲が見える
make_callbackへiを渡すと、呼び出すたびに別のindexを持つ関数が作られる。戻されたcallbackは、その呼び出しのindexを参照する。保持する値が増えたり、処理が複数行になったりする場合は、この形の方が読みやすくなることが多い。
ファクトリーの中でも、あとから同じindexへ代入する設計なら値は変わり得る。クロージャーだから自動的に不変になるわけではない。保持する値を作った後に変えないという設計が、この例の分かりやすさにつながっている。
イベントの引数と保存用引数を混ぜない
GUIなどのコールバックでは、呼び出す側がイベントオブジェクトを位置引数で渡すことがある。その場合、lambda i=iだけでは、そのイベントがiへ入り、保存しておいた値を上書きする。利用するAPIが何個の引数を渡すかを確認してからシグネチャを決める必要がある。
例のmake_callbackは引数を取らない用途である。イベントを受け取る用途なら、明示的にeventなどの引数を設け、保存したindexとは別の名前にする。短く書くことより、呼び出し側の約束と一致することを優先したい。
可変オブジェクトは値の固定にならない
デフォルト引数にリストを入れると、そのリストへの参照が保存される。生成後に元のリストへ要素を追加すれば、関数から見える内容も変わる。最後の例はこの違いを示している。数値を保持した例と同じ意味で、データ全体が複製されたと考えないようにする。
内容のスナップショットが必要なら、作成時に必要な深さのコピーを行う。単純な数値一覧ならtupleに変換して保持する方法もあるが、内側に可変オブジェクトが含まれる場合は別途考える必要がある。固定したいのが参照先か、その中身かを区別しよう。
登録した後にまとめて呼んで確認する
ループの内側で作ってすぐ呼ぶ確認だけでは、遅延束縛による違いが見えないことがある。登録処理を完了させてから、すべての関数を順に呼ぶ。この記事でも、リストに保存し終えた後で結果を比較している。
単に引数を固定したいだけならfunctools.partialも選択肢になる。ただし、partialも可変データを自動で深くコピーするわけではない。いずれの方法でも、呼び出し時までに変化するものと、生成時に保持するものを分けて考えるのが基本である。
動作確認と参考資料
掲載例はLinux・CPython 3.12.14で動作確認した。OS固有のコマンドや環境ごとに変わるパスは、本文中の条件を確認して使ってほしい。
関連するTips
- 関数を呼ぶたびにリストが増える?可変デフォルト引数を直す
- 値があるのにUnboundLocalError?変数のスコープを直す
- コピーしたデータまで変わる?浅いコピーと深いコピーを使い分ける
- 似た関数を量産しない:partialで処理の条件を固定する
