Date: 2010-09-22
Tags: python

やっと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

あとは、フレームオブジェクトはどこまで保存されるのかとか、コールスタックの途中のフレームオブジェクトは解放されるのかとか、もうちょっと調べたいことはありますが、それはまたいつか自分か、あるいは誰かが書いてくれるんじゃないかと期待。