Pythonで変数スコープがブロックベースだと大変そう
ツイッターでPythonの文法の話題が盛ん(三角関数と同じで周期的に話題になる)で、個人的にはあまり同意できない意見も多いのだがなかなか面白いトピックもある。
例えば以下のようなコード:
fs = [] for i in range(5): fs.append(lambda: i) for f in fs: print(f())
これが多くの人の直観に反して4を5回出力するという挙動になる。
このような挙動になる理由は複合的で:
- 変数
i
のスコープがfor内のブロックで切れていない lambda: i
で定義された関数がクロージャとしてlexical scopeで変更され続ける変数i
を捕捉している
の二点が関係している。
クロージャとlexical scopeの話はまた今度したいが、本記事ではPythonの変数スコープがブロックで切れない、という話についてつらつら考えてみたい。
Pythonの変数スコープの境界は大まかに言って関数とクラスである。より細やかな制御構文単位(例えばifやfor、try/exceptなど)ではスコープは切れない。
つまり関数内で変数に代入したとして:
x = 1 def f(): x = 2 f() # ここでもまだx=1
関数外の変数には変更はないが、例えば:
x = 1 if True: x = 2 # ここだとx = 2
ifブロックの中で変数に代入した場合、ブロック外の変数の値が変わっている。
この変数スコープのルールが把握しにくいということはないと思うし、個人的には殊更不自然な仕様だとも感じない。ただC言語族とは流儀がかなり違う、という点でそちらに慣れている人にとっては非直感的になる恐れはある。ちなみに関数が最小の変数スコープというのはPascalもそうだったらしく、Pythonが初出ということはなさそうだ。プログラミング言語史における変数スコープの変遷というのもなかなか面白そうなトピックである。
この仕様の初出がPythonではないとは言え、Pythonがこの変数スコープのルールを導入しているのには(多分)理由がある。Pythonの他の構文との兼ね合いから、ブロックレベルでのスコープは非常に相性が悪いのである。
例えば例外処理で
try: res = some_func_that_can_throw(my_args) except SomeError: some_cleanup() res = default_value
のようなコードをよく書くと思うのだが、これはres
のスコープがブロックのみだとまったく意味をなさない。まあこれはC++などでも同じで、じゃあres
がそもそもtry
の外で定義されていればいいのでは?と
res = default_value try: res = some_func_that_can_throw(my_args) except SomeError: some_cleanup()
のように変更したとしても、今度はtry
の中のres
はどのスコープのものになるのか?という問題が生じる。つまりPythonには変数代入と独立した変数宣言が存在しないので、ブロックの中の変数がこのブロックの中で新たに作られるべき変数なのか、それとも外部スコープにすでに存在する変数を意図しているのか、判定するのが難しい。
同じ変数がスコープ外に存在しているならそれを使い、存在しないなら現在のスコープに限定された変数を作る、というような挙動もありえるだろうが、こちらの方が変数のスコープが意図しない形になってしまう可能性は高そうだ。あるいは関数の構文を真似てnonlocal
キーワードで外部スコープの変数を使うのを明示するという手もあるが、非常に冗長になってしまう。(関数の場合、外部スコープの変数を書き換えるというのはかなり例外的なのに対して、制御構文内ではほとんどのケースでこのような書き換えが必要になる)
なのでJSのようにlet
などで代入とは別に変数宣言できるようにしていない場合、制御構文のブロックベースで変数スコープを切ってしまうのは不都合。
「だから変数宣言が別で必要だ」と考えることもできるのだけど、実際のところ「関数が変数スコープの最小単位」というメンタルモデルを持ってしまえば正直これが不便だと感じることもほとんどない、というのが個人的な感想。基本的に「同一関数の中で同じ変数を別のことに利用しない」というのはさほど不自然なルールではないし、Pythonの文化的にもなるべく関数は小さく、ネストは浅く、という記法が好まれているので(まあこれは汎用的なスタイルだとは思うが、CやC++より守ろうと頑張る傾向があるように思う)同一変数を別のことに使いたくなってきたら別関数に切り出す事になる。
なので正直「変数スコープがブロックではなく関数単位」というのは「変数宣言を言語に導入する」レベルで言語仕様を変える必要のあるような問題(というかそもそも解決するべき「問題」)だとは思えない。
ただし、for
ブロックから変数が「漏れる」ということと、ブロック内で作ったクロージャが値ではなくlexical scopeの変数そのものを捕捉してしまうことの合わせ技である、本記事冒頭の非直感的挙動は確かにあまり嬉しくない。
解決策としては
fs = [] for i in range(5): fs.append(lambda x=i: x)
でデフォルト引数として保存させるのが一般的。あるいは
fs = [] for i in range(5): fs.append((lambda x: lambda: x)(i))
と新しくlexical scopeを作ってやる手もある。どちらもちょっとイケテナイ感が漂うのは事実だが・・・
次回はクロージャとLexical scopeの話。