pythonでデフォルト引数がいつバインドされるか。クロージャの遅延評価でいつ変数がバインドされるか。


注意

Pythonの用語、言葉遣いがかなり怪しいです。

TLTR

  • 関数型言語の影響からPythonは遅延評価できる。
  • 関数定義の際に関数のデフォルト引数は評価される。実行時ではない。
  • クロージャは遅延評価される。
  • クロージャに明確に定義時の値を保持させたいとき、引数で渡すと良い。

動機

pythonの遅延評価についての記事を読んで、それをまとめました。

例1 関数のデフォルト引数はいつ評価されるか

Pythonはデフォルト引数を設定できます。
しかし、関数定義の際に関数のデフォルト引数は評価される。実行時ではない。という仕様を知らないと思わぬ落とし穴となります。

デフォルト引数=Noneがいいときもある

渡された引数をリストに追加するだけのappend_to関数を定義します。
デフォルトで空リストtoを持ちます。

引数をリストに追加するappend_to関数
>>> def append_to(element, to=[]):
...     to.append(element)
...     return to
...

12,42を足してみましょう。
引数のto=[]があるのだから、
[12]
[42]
となりそうです。

>>> my_list = append_to(12)
>>> print(my_list)
[12]
>>> my_other_list = append_to(42)
>>> print(my_other_list)
[12, 42]

予想とはことなります。これはどういうことでしょうか。

原因

デフォルト引数toをto = []として定義しています。
ここに続々と値を更新していきます。

to=[]->to=[12]->to=[12,42]となる過程においてto=[]が評価されれば、

to=[]->to=[12]->to=[]->to=[42]となり、望んだ結果が得られそうですが、デフォルト引数は関数定義の際に評価されます。

つまりtoは関数定義時にto=[]されますが、呼び出されるたびにto=[]はしてくれないのでappent_to(42)のときto=[12]のままであるということです。

解決策

代わりにどうするの
to=Noneするといいらしいです。

若干冗長に感じますが。どうすればいいかはわからず。

def append_to(element, to=None):
    if to is None:
        to = []
    to.append(element)
    return to


my_list = append_to(12)
print(my_list)

my_other_list = append_to(42)
print(my_other_list)

[12]
[42]

無事望んだ結果が得られました。

例2 クロージャの遅延評価

Pythonは遅延評価をできます。
遅延評価とは乱暴に言えば必要になったら評価するということです。

これは普通にPythonを学んでいる私のような初学者なら「知ってる!これ**ゼミで見たやつだ」となります。

これから中大規模なコードを書いていこうとなれば関数のデフォルト引数を設計するのは必至です。

しかし、このようなPythonの仕様があります。

クロージャにおける変数束縛(バインディング)は遅延評価される

関数の中に値が保持される、いわゆるクロージャを使ったコード例を見ていきましょう。

目標はリストの中身と引数の値を掛けた値を持つリストを返すコードです。

リストの中身と引数の値を掛けた値を持つリストを返すスクリプト
functions = []
for n in [1, 2, 3]:
    def func(x):
        return n * x
    functions.append(func)

# You would expect this to print [2, 4, 6]
print('{}'.format(str([function(2) for function in functions])))

[2, 4, 6]([1 * 2、2 * 2、3 * 2])が出力されそうですが、、、

[6, 6, 6]

と出力されました。

原因

pythonのクロージャは遅延評価されます。
クロージャで使用される変数の値もまた、呼び出された時点で参照されます。

よって、for文内でクロージャが呼び出される時点ではnは定まっておらず、n*xの状態でfunctionsに格納されます。言うなれば[n*x,n*x,n*x]
よっていざprint文で実行される際にはnの最後の状態であるn==3が参照されるので[6,6,6]([3*2,3*2,3*2],x=2)となります。

デバッグしてみる

デバッガを使って、for文とprint文の値の更新の順番を見てみましょう。

変数nの値がイテレーションによって更新されてすでに3になっているので、、printでfunctionが評価されるのはずっとn==3です。
printからn==3が3回参照されていることがわかります。

分かりづらいのでもう一つ載せます。

解決策

クロージャの引数をxからx,n=nにする。
問題はnの値を渡すこと、つまりnの値をクロージャを格納したfunctionsにfor文でイテレーションされる度に渡せば解決されます。

そのためにdef func(x, n=n):というようにnの値を引数に入れ、再代入のような形を取らせます。
これにより、クロージャはその関数内で値を保持するという仕様からnの値を更新てくれるようになります。

リストの中身と引数の値を掛けた値を持つリストを返すスクリプト

functions = []
for n in [1, 2, 3]:
    def func(x, n=n):# n=nを追記した。
    # def func(x):
        return n * x
    functions.append(func)


print('{}'.format(str([function(2) for function in functions])))


出力結果
[2,4,6]

デバッガで見てみましょう。

ちょっとfuncの挙動がうまくデバッガで表現できたかは疑問ですが、無事に思った通りの挙動をさせることができました。

その他の解

ライブラリを使えば良いらしいです。今回は割愛。

from functools import partial
from operator import mul

まとめ

Pythonは遅延評価をします。
これは普段の手続き型のプログラミングをしているとちょっと混乱します。
関数型言語から持ち込まれた機能ですから、ちょっと癖があるんですね。

変数のバインド(束縛)という表現(関数型言語における代入)がPythonにおいてどう使い分けるべきかよくわかっていません。

追記2020/05/19

コメントにて指摘がありました。

例1でPythonのデフォルト引数は遅延評価されると書きました。しかしこれは正しくない理解でした。
Pythonのデフォルト引数は遅延評価されてバインディングされるのではなく、関数宣言時にバインディングされる仕様です。それにより例1のような直感的ではない振る舞いをするというのがより正しいようです。

参考文献

https://ja.coder.work/so/python/1087272
https://python-guideja.readthedocs.io/ja/latest/writing/gotchas.html
https://stackoverflow.com/questions/36463498/late-binding-python-closures