cover_effective_python

【 Effective Python, 2nd Edition 】クロージャー関数 ( closure function ) の変数スコープについて - 参照と代入における違いを理解してますか? 投稿一覧へ戻る

Tags: Python , Effective , nonlocal , closure , scope

Published 2020年6月21日8:21 by T.Tsuyoshi

数値で構成されるリストの並べ替えをする際に、あるグループに属する数値を前方に配置したいとします。


このようなロジックは、ユーザーにメッセージ等を表示する際、重要度の高いものから表示したい場合などに有効です。


実装する一般的な方法は、リストの sort() の key パラメータに、並べ替えに利用する値を提供するためのヘルパー関数を渡すことです。


def sort_priority(numbers, group):
def helper(x):
if x in group:
return (0, x)
else:
return (1, x)

numbers.sort(key=helper)

numbers = [8, 3, 1, 2, 5, 4, 7, 6]
group = [2, 3, 5, 7]
sort_priority(numbers, group)
print(numbers) # [2, 3, 5, 7, 1, 4, 6, 8]



この sort_priority() が期待通り動作しているのには3つの理由があります。


1: Python がクロージャー関数をサポートしているから。

>>> これによって helper() は本来は自分のスコープ外である sort_priority() の引数、group にアクセスすることができます。


2: Python における関数は first-class オブジェクトだから。

>>> これによって、関数を直接参照することができ、他の関数へ引数として渡したり、変数へセットしたりすることができます。
>>> ここで sort() の key パラメータとして helper() を設定できるのはこのためです。
>>> (関数を参照するだけで実行するわけではないので、関数名の後に () を記述していないことに注意してください)


3: Python がシーケンスを比較する際に特別な規則を設けているから。

>>> まずインデックス 0 同士が比較され並べ替えられます。比較結果が等しい場合は続けてインデックス 1 同士が、再び同じであれば次のインデックス同士が...
>>> と比較され並べ替えられるため、helper() から返される (0, n) 要素がまず前方に、(1, n) 要素はその後方に並べられ、
>>> 続けてインデックス 1 同士の比較でグループ内で昇順に並べ替えられます。


さてここで、優先グループ - [group] - で指定されている数値がシーケンス - [numbers] - に含まれていた場合に、
呼び出し元に「あったよー」と知らせることにしましょう。


ちょろいですね、簡単すぎます。
クロージャー関数を利用しており、そこで数値を見つけているわけですから、見つかったらフラグを立て、最終的にそれを返してあげれば完成です。


def sort_priority_flag(numbers, group):
found = False
def helper(x):
if x in group:
found = True
return (0, x)
else:
return (1, x)

numbers.sort(key=helper)
return found


numbers = [8, 3, 1, 2, 5, 4, 7, 6]
group = [2, 3, 5, 7]
found = sort_priority_flag(numbers, group)
print(f"Found: {found}") # Found: False
print(numbers) # [2, 3, 5, 7, 1, 4, 6, 8]



並べ替えはうまくいっています。でも、??????、返ってきているフラグは False です、見つかっているんだから True じゃなきゃダメなのに...


そこでまず、変数参照を Python インタープリタがどのような順番でスコープを移動しながら解決するか、をおさらいしましょう。。


1: 現在の関数のスコープ
2: 現在の関数を含む他の関数等の閉じられたスコープ
3: 現在のコードを含むモジュールスコープ (グローバルスコープと呼ばれているのはココです)
4: 組み込み (built-in) スコープ (len や str といった組み込み関数を含むスコープです)


そして上の 1 ~ 4 の順番で探しても該当する名前の変数を見つけられないと、NameError を投げて仕事を終了します。


そしてもう1つ、変数へ値を割り当てる際の規則も理解しておかなければいけません。
(以降の文章では「割り当て」と「代入」を同じ意味合いで使用しています)


値を割り当てる変数が現在のスコープ内ですでに定義されている場合、その既存の変数へ新たな値を代入します。


もし変数がスコープ内でまだ定義されていない場合、Python はその行為を「変数の定義」として捉え、
その変数のスコープとして、割り当て書式が記述されている関数のスコープを設定します。


これこそが、found フラグが False を返してきた真の理由です。


クロージャー関数 helper() 内における found 変数への True 値の代入は sort_priority_flag() スコープ内の found に対する代入とはみなされず、
クロージャー関数内における新しい「変数の定義」とみなされた結果なんです。


クロージャー関数は、自分をラップしている外側の関数のスコープに属する変数を参照することは可能です。
ですが、デフォルトでは、それらの変数へ値を代入することはできないんです。


この問題は scoping bug とも呼ばれ、Python 初心者をビックリさせます。
が、これは故意に意図されているものなんです。


この考え方によって、ローカル変数への操作がモジュール全体を「ごちゃごちゃ」にしてしまうことを防止するとともに、
原因が突き止めにくいバグの発生を予防しているんです。


Python では、クロージャー関数から外側のスコープ(自身をラップしている親関数のスコープ)にある変数を操作するための特別な構文が用意されています。


nonlocal ステートメントがそれで、クロージャー関数内において代入に関わるある変数の実体は、先に述べた4段階の変数参照の手順を踏んで解決してね、ということを Python に要求するものです。


ただし、nonlocal は4段階の参照検索のうち最初の2段階目までしか実行しません。あくまでもグローバルスコープの安全は死守する、ということなんですね。


つまり、クロージャー関数からは自分をラップしている一番外側の関数(3重、4重に関数がラップされているかもしれません)のスコープ内の変数に限られるんです。


def sort_priority_flag(numbers, group):
found = False
def helper(x):
nonlocal found
if x in group:
found = True
return (0, x)
else:
return (1, x)

numbers.sort(key=helper)
return found


numbers = [8, 3, 1, 2, 5, 4, 7, 6]
group = [2, 3, 5, 7]
found = sort_priority_flag(numbers, group)
print(f"Found: {found}") # Found: True <- おおっ、正解ですね!!!
print(numbers) # [2, 3, 5, 7, 1, 4, 6, 8]



この nonlocal ステートメントは、明記された変数への代入は他のスコープの同じ名前の変数に対して実行していることです、ということを明確にするものです。
そして、グローバル変数への参照を行う global ステートメントを補完するものでもあります。


しかし、グローバル変数使用に伴うソフト開発上のアンチパターン (本来やるべきではないけれど、一般的日常的に広く使われてしまっている開発上の禁忌) と同様、
nonlocal ステートメントの乱用は慎むべきです。


特に、nonlocal ステートメントとその変数に対する代入書式が遠く離れて記述されてしまうような大きな関数での使用は、解決しがたいエラーを発生させる可能性を高めてしまいます。


もし nonlocal ステートメントの使用でコードを追うことが少しでも困難なようであれば、同様の機能を提供するヘルパークラスの作成をおススメします。


記述するコードの分量は若干多くなりますが、読解性は高まり、将来的な機能の拡張余地も担保できます。


class Sorter:
def __init__(self, group):
self.group = group
self.found = False

# このクラスインスタンスを「関数」として渡した場合に実行される特殊関数
def __call__(self, x):
if x in self.group:
self.found = True
return (0, x)
return (1, x)

numbers = [8, 3, 1, 2, 5, 4, 7, 6]
group = [2, 3, 5, 7]
sorter = Sorter(group)
numbers.sort(key=sorter)
print(f"Found: {sorter.found}") # Found: True
print(numbers) # [2, 3, 5, 7, 1, 4, 6, 8]



まとめ:

1: クロージャー関数は、自身をラップしている親関数スコープ内の全ての変数を参照することが可能です。

2: 変数への代入に関する限り、クロージャー関数は自分をラップしている関数のスコープ内の変数に対して実行することはデフォルトではできません。

3: nonlocal ステートメントを利用することで、クロージャー関数内で自分をラップしている関数のスコープ内にある指定した変数への代入をすることが可能になります。

4: nonlocal ステートメントの使用は、非常に単純で小さな関数での使用にとどめるべきです。

この投稿をメールでシェアする

0 comments

コメントはまだありません。

コメントを追加する(不適切と思われるコメントは削除する場合があります)