やっとPythonのクロージャの仕組みを少しは理解した件¶
先日、 Pythonの動的クラス生成と特殊メソッドとフレームの謎 というBlog投稿をしたら複数の関係筋から「 Pythonのクロージャはいわゆるレキシカルクロージャ 」「 これがPython的に正しい挙動 」というツッコミをもらいました。 @atsuoishimoto さん @okuji さんありがとうございました。
ということで、自分が何を分かってなかったのかのまとめです。
単純にlambdaを作った場合¶
まず、以下のようにlambdaを使って関数を作るとします。
>>> x = 1
>>> f = lambda: x
この関数を呼び出したら以下のような結果になります。
>>> f()
1
ここまでは想定通りだと思いますが、以下の場合は想定通りでしょうか?
>>> g = lambda: y
>>> y = 1
>>> g()
1
>>> y = 2
>>> g()
2
yを後から定義しても動作します。自分は、こういう動きをすることは知っていて、そのようなコードも書いていましたが、ちゃんとは理解できていませんでした><
lambdaで関数を作ってdictに入れた場合¶
>>> d = {}
>>> d[1] = lambda: 1
>>> d[2] = lambda: 2
>>> d[3] = lambda: 3
>>> d[1]()
1
>>> d[2]()
2
>>> d[3]()
3
こういうことをしたいシーンは時々あります(dictのキーとlambdaの返す値が一緒かどうかはさておき)。で、こういうコードはfor文で回すコードにしたいと思いますよね。
for文でlambdaを作った場合¶
>>> d = {}
>>> for i in (1, 2, 3):
... d[i] = lambda: i
...
上記のようなコードを用意して、それぞれの関数を呼び出すと以下の結果になります。
>>> d[1]()
3
>>> d[2]()
3
>>> d[3]()
3
前述のgとyの例から、このような結果になることは想定できたはずですが、自分はこの動きは想定外でした。for文を使ったことと、dに代入するキーにもiを使ったこなど、あとは実際に書いていたコードがもうすこし複雑だったことなどが原因で、 iの値が lambda式の実行時に束縛されると思い込んでしまったんだと思います。
ちなみに、前の例でx=2とした時のように、i=2にすれば前述のコードと同様の結果になります。
>>> i = 2
>>> d[1]()
2
>>> d[2]()
2
>>> d[3]()
2
>>> i = d
>>> d[1]()
{1: <function <lambda> at 0x027C53F0>,
2: <function <lambda> at 0x027EECF0>,
3: <function <lambda> at 0x027EED70>}
ここで注意が必要なのは、あくまで名前とフレームオブジェクトを束縛しているのであって、値、または参照しているデータを束縛しているのではないという点。
解決版のコード¶
ここまでのことから、以下のようにコードを書き換えれば、束縛されるフレームオブジェクトがlambda毎に異なるため、最初のサンプルコードと同じ結果を得ることができます。
>>> d = {}
>>> for i in (1, 2, 3):
... def wrap(x):
... return lambda: x
... d[i] = wrap(i)
...
>>> d[1]()
1
>>> d[2]()
2
>>> d[3]()
3
wrapという関数を呼び出すことで、lambdaが束縛する名前=x, フレームオブジェクト=wrap関数のフレーム, という組み合わせになります。lambda生成毎に関数を呼び出して個別のフレームを生成しているところがミソですね。
次の確認に向けてコードを修正¶
とりあえずlambdaをdef文に置き換えます。
>>> d = {}
>>> for i in (1, 2, 3):
... def wrap(x):
... def f():
... return x
... return f
... d[i] = wrap(i)
...
>>> d[1]()
1
さらにこれらの処理を再利用できるように、関数の中で行うようにします。
>>> def gen():
... d = {}
... for i in (1, 2, 3):
... def wrap(x):
... def f():
... return x
... return f
... d[i] = wrap(i)
... return d
...
>>> d = gen()
>>> d[1]()
1
これで下準備完了。
f()呼び出し時のローカル変数を確認¶
前述のコードに以下のようにprint文を埋め込んで、f()関数内で使用できるローカル変数の一覧を確認します。
>>> def gen():
... d = {}
... for i in (1, 2, 3):
... def wrap(x):
... def f():
... print '%%%', locals()
... return x
... return f
... d[i] = wrap(i)
... return d
...
>>> g = gen()
>>> g[1]()
%%% {'x': 1}
1
このように、f()の中で利用できるローカル変数はxだけす。iやdは束縛されていないためか、ローカル変数にはありません。globals() で確認すればモジュール内のグローバル変数も確認できますが、i,dは含まれていないでしょう。
ここでf()の関数定義内でiやdを参照すれば、束縛されてf()内のローカル変数として参照できます。
>>> def gen():
... d = {}
... for i in (1, 2, 3):
... def wrap(x):
... def f():
... i
... print '%%%', locals()
... return x
... return f
... d[i] = wrap(i)
... return d
...
>>> g = gen()
>>> g[1]()
%%% {'i': 3, 'x': 1}
1
あとは、フレームオブジェクトはどこまで保存されるのかとか、コールスタックの途中のフレームオブジェクトは解放されるのかとか、もうちょっと調べたいことはありますが、それはまたいつか自分か、あるいは誰かが書いてくれるんじゃないかと期待。