0
0

More than 1 year has passed since last update.

python クロージャとスコープ(備忘録)

Last updated at Posted at 2022-12-07

コードを読んでみよう!

def sort_exam(values, group):
    def closure(x):
        if x in group:
            return (0, x)
        return (1, x)
    values.sort(key=closure)

numbers = [9, 3, 1, 5, 2, 10, 100, 0]
group = {3, 9, 2}
sort_exam(numbers, group)
print(numbers)

>>>
[2, 3, 9, 0, 1, 5, 10, 100]

このコードを見たとき、不思議に思った人もいるかもしれません(いないかもしれません)。
そもそも、なんで出力結果が、数字の大きさ順に並んでいないのでしょうか。インデクスが3以上の要素は、数字の大きさ順に並んでいます。group変数で定義されている集合の要素「3」「9」「2」がソート結果の左端(インデックスが3未満)に固まっています。このコードを読み解くために順番にポイントを見ていきます。

クロージャとは?

pythonはクロージャをサポートしています。
クロージャ(関数閉包)を一言で表すと、「第一級関数」に「レキシカルスコープ」をもたせることです。わけがわからなくなってきました…。

第一級関数 (First-class functions) ……その他の変数と同様に扱われる関数のこと。関数を他の関数への引数としてわたす、変数の値として代入するなどができます。

レキシカル(静的)スコープ……変数を宣言した場所でスコープが決まること。反対に、ダイナミック(動的)スコープがあり、これは、関数を呼び出した場所で変数のスコープが変わります。

語弊を恐れずにいうと、「クロージャ」は、外側のスコープの変数を記憶した関数のことです。

# エンクロージャ
def enclosure(x):
    x = 3
    # クロージャ
    def closure(y):
        return x + y
    return closure

pythonがこのクロージャを採用していることによって、関数の外側にある変数にもアクセスすることができます。enclosure関数を抜けるとxがなくなってしまうプログラミング言語も存在します。クロージャを採用しているメリットとして、グローバル変数を減らすことができる、ある状態を保持しつつ、別の処理を実行できる等あります。
メモリの消費量とか多くなりそうな気がしますが、どうなのでしょうか?わかる人がいらっしゃたら、教えてほしいです…。

また、ここで関数内関数とクロージャの違いについて触れたいと思います。

# クロージャ
def out_func(a):
    def in_func():
        print(a)
    return in_func

>>><function out_func.<locals>.in_func at 0x000001FC2F480EE0>

# 関数内関数
def out_func(a):
    def in_func():
        return a
    return in_func()

print(out_func(10))

>>>10

見たまんまですが、クロージャは最後のreturn文で関数を実行していない(括弧がついていない)、関数内関数は最後のreturn文で関数を実行しています。また、クロージャは、関数オブジェクトの位置を返しているのに対して、関数内関数は、関数が呼び出されたら即時実行をして10を返しています。
つまり、クロージャは呼び出されても、すぐには実行せず、この状態を記憶してあとで使うことができるということです。以下、クロージャの実行例です

# クロージャ
def out_func(message):
    def in_func():
        return message + '!sakuさん'
    return in_func

hello = out_func('こんにちわ')
print(hello())

>>>こんにちわ!sakuさん

ここでは、out_func関数を呼び出して、変数helloに入れています。そして、hello()をprint文で出力しています。これにより、任意の場所で関数結果を出力できます。

pythonのシーケンスにおける比較方式を思い出そう!

タプルなどのシーケンスを比較する際、最初のインデクス0を比較し、次にインデクス2、インデクス3というように比較していきます。

# 左から各要素で比べていく。「こんにちわ」の文字数が多いのでTrue
('おはよう', 6) < ('こんにちわ', 6)
>>>True

# 「いただきます」と「ごちそうさま」の文字数はともに6文字。次に「6」と「8」を比べて「8」が大きいのでTrue
('いただきます', 6) < ('ごちそうさま', 8)
>>>True

改めて最初のコードを見直してみよう!

def sort_exam(values, group):
    def closure(x): # クロージャ
        if x in group:
            return (0, x) # これにより戻り値が2つのグループに振り分けられる
        return (1, x)
    values.sort(key=closure)

numbers = [9, 3, 1, 5, 2, 10, 100, 0]
group = {3, 9, 2}
sort_exam(numbers, group)
print(numbers)

>>>
[2, 3, 9, 0, 1, 5, 10, 100]

では、次に上のソースコードに、「そもそも優先度が高い要素が存在したのか」という情報を加えてみます。優先度の高い要素があった場合はフラグをTrue,なかった場合はFalseにしてみます。

def sort_exam(values, group):
    found = False
    def closure(x): # クロージャ
        if x in group:
            found = True
            return (0, x) # これにより戻り値が2つのグループに振り分けられる
        return (1, x)
    values.sort(key=closure)
    return found

numbers = [9, 3, 1, 5, 2, 10, 100, 0]
group = {3, 9, 2}

found = sort_exam(numbers, group)
print('found:', found)
print(numbers)

>>>
found: False
[2, 3, 9, 0, 1, 5, 10, 100]

一見、エラーも出ずうまくいったかに思えますが、優先度の高い要素があったにも関わらず、Falseが出力されてしまいました。なぜ、こうなってしまったのでしょうか?

変数の挙動を確認しよう!

式中の変数を参照する場合

pythonインタプリンタは、次のような順序で参照を行います。
1 現在のスコープ内に変数が定義されているか確認
2 外側の関数に変数が定義されていないか確認
3 クローバルスコープに変数が定義されていないか確認
4 組み込みスコープ(組み込み関数など)に変数が定義されていないか確認
これらの中に、参照名の定義済み変数がないと、NameError例外を送ります。

変数を定義する場合

変数がすでに現在のスコープで定義されていれば、値を再定義して、現在のスコープに存在しない場合は新たに変数を定義します。

何がいけなかったのか

つまり、クロージャ内(関数内)でTrueを代入しても、そのローカルの中のみ有効であり、その外の関数やグローバルスコープ内では、Falseのままであるということです。
これをスコープ処理バグと言ったりもするようです。しかし、逆に言ってしまえば関数内のローカル変数は、その外の変数を汚染しないというメリットがあります。

関数内で定義した変数をその外のスコープで使うには?

ズバリ、「nonlocal 変数名」と変数はローカルな変数ではありませんよという文を入れれば解決です。

def sort_exam(values, group):
    found = False
    def closure(x): # クロージャ
        if x in group:
            nonlocal found
            found = True
            return (0, x) # これにより戻り値が2つのグループに振り分けられる
        return (1, x)
    values.sort(key=closure)
    return found

numbers = [9, 3, 1, 5, 2, 10, 100, 0]
group = {3, 9, 2}

found = sort_exam(numbers, group)
print('found:', found)
print(numbers)

>>>
found: True
[2, 3, 9, 0, 1, 5, 10, 100]

しかし、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 = [9, 3, 1, 5, 2, 10, 100, 0]
group = {3, 9, 2}

sorter = Sorter(group)
numbers.sort(key=sorter)
assert sorter.found is True
print(numbers)

このように、関数が大きく、複雑な場合はクラスを定義して対応しましょう。
ちなみにassert文は、条件をテストするデバッグ支援ツールです。
アサーションの条件がTrueならスルー、Falseの場合はAssertionError例外が送られます。

found = False
assert found is True

>>>assert found is True
AssertionError

参考図書

Effective Python pythonプログラムを改良する90項目

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0