60
50

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Pythonのクロージャについて: 関数のスコープと、関数が第一級オブジェクトであることからちゃんと考える

Last updated at Posted at 2018-10-08

※2018/10/31 コメント欄のご指摘を基に、誤字脱字含め一部修正しました

まだまだ Pythonを勉強中なのですが、「クロージャ」という概念がパッとは分からなかったので、調べました。

「Flaskで LINE Bot作ろう」
->「デコレータでルーティングしてる、デコレータ理解してないから勉強しよう」
->「デコレータはクロージャを使った仕組みらしいけどクロージャ分からないから勉強しよう」
という調べものスパイラルのさなかです。

手元の入門書を見たり、Webで検索したりして調べましたが、クロージャの解説は複雑・難解なものや、逆に省略し過ぎなものが多いように感じて困ったので、いろいろ手元で試しました。似たようなお悩みを持つ方のお役に立てば幸いです。

なお、本記事の執筆にあたって参考にさせていただいたサイト様を参考欄に記載していますので、そちらも合わせて読んでいただくとより理解が深まると思います。

この記事が役に立つであろう方

クロージャについて調べてたけどよくわからん、という方に、何かしらの気づきを共有できるのではないかと思います1
なお、クロージャで悩む方はご存知だと思うので、ごく初歩的なスコープ知識は前提としています。

クロージャとは

まずは、クロージャについての現時点での私の理解を述べます。

クロージャ
関数が第一級オブジェクトであるため、変数に関数を格納すると その関数定義自体 と共に その環境 が格納されることを利用する、関数を状態ごと保持 したオブジェクト

......これだけだとやはりどういうこっちゃだと思います。
以下順を追って必要な知識を解説していきますので、とりあえず気にせず読み進めてください。

クロージャ理解のためにその1: 「Pythonの関数」のスコープの基礎

関数のスコープの基本

クロージャの理解には、Pythonの関数のスコープの知識が必要です2

そこで唐突ですが質問です。
以下の例では、func1() func2() それぞれの実行結果はどうなるでしょうか。

scope_sample1.py
scope = 'global'


def func1():
    print(scope)


def func2():
    scope = 'local'
    func1()


func1()  # これと
func2()  # これの結果は?

「こんなの簡単でしょ、func1()globalfunc2()local」と思った方。
私もそう思ってました。
実際の結果は以下です。

result-scope_sample1.py
>>> func1()
global
>>> func2()
global

これで「????」となった方は、是非最後までお付き合いください。私も「????」となったので、頑張って調べました。

なぜこんな結果になるのかというと、**「Pythonの関数のスコープでは、関数の実行時ではなく、関数の定義時の環境が参照可能な変数を決定するため」となっています3
scope_sample.py だと、「func2 の内部で実行されている func1 の環境は、4、5行目で定義された時点で決定済み」であり、
「その時点では func1 が参照可能な変数 scopeglobal しかなかった」**ので、func2 の実行結果も global となった、ということです。

では、次の例ではどうでしょうか。

scope_sample2.py
scope = 'global'


def func1():
    scope = 'local1'  # ここでも変数 scopeを定義
    print(scope)


def func2():
    scope = 'local2'
    func1()


func1()  # これと
func2()  # これと
print(scope)  # これの結果は?

scope_sample1.py とほぼ同じですが、今度は func1 の中でも scope を定義しています。
以下結果。

result-scope_sample2.py
>>> func1()
local1
>>> func2()
local1
>>> print(scope)
global

func1 のローカルスコープ = 定義時 のスコープ内に scope 変数があります。
同名変数は近いスコープから優先して解決されるので、この func1 のローカルスコープ内の変数 scope が**func1 の環境内でグローバルの scope変数よりも優先して参照**された結果、 func1func2 の実行結果が local1 となっています。
func2() の実行結果が local1 になるのは、scope_sample1.py で説明した通りの理屈ですね。

また、当然グローバル変数の scope が上書かれたり消えたりしたわけではないので、グローバルスコープで print(scope) を実行すると global となっています。

クロージャ理解のためにその2: 「Pythonの関数」は第一級オブジェクト

とても大事なことですが、次のセクションからの例を見ると分かりやすいと思うので、簡潔に要点だけ述べます。
Pythonでは、関数は 第一級オブジェクト と呼ばれるオブジェクトに分類されており、このため以下のことができます。

  • 変数に関数を格納
  • 戻り値として変数を返す

この他にも引数として関数を渡せる、など色々な特徴がありますが、とりあえずのクロージャの理解には上の 2つを押さえておけば大丈夫です。
次のセクションで、さっそく実際の例を見ていきましょう。

クロージャ理解のためにその3: 関数内関数のスコープ

少し特殊な関数について、スコープを見ていきます。
「関数内関数」と呼ばれる、関数がネストされているパターンです。

scope_sample3.py
scope = 'global'


def func1():
    scope = 'local1'
    print("inside of func1: " + scope)

    def func1_1():
        scope = 'local1_1'
        print("inside of func1_1: " + scope)

        def func1_2():
            print("inside of func1_2: " + scope)
        return func1_2
    return func1_1


func1_1 = func1()  # func1() の実行の結果、戻り値として func1_1を受け取っている
func1_2 = func1_1()  # 上の行で func1_1を定義しているので定義可能

print(scope)  # I これと
func1()  # II これと
func1_1()  # III これと
func1_2()  # IV これの結果は?

ここで注目すべきポイントは、return func1_1 のように 「関数が戻り値として返されている」 ことと、func1_1 = func1() のように 「関数が変数に格納されている」 ことです。
これは、その2 で述べた通り、Pythonの関数が第一級オブジェクトであるからできていることです。

ちょっと複雑そうですが、とは言え難しいことはありません。
先ほどと同様、「関数のスコープは関数定義時の環境で決まる」「同名変数は近いスコープから優先して解決される」 というルールに忠実に従うだけです。
結果を見てみましょう。

results-scope_sample3.py
>>> print(scope)
global  # I の結果
>>> func1()
inside of func1: local1  # II の結果
<function func1.<locals>.func1_1 at 0x00000163058C9620>
# ↑ returnされた func1_1のオブジェクト
>>> func1_1()
inside of func1_1: local1_1  # III の結果
<function func1.<locals>.func1_1.<locals>.func1_2 at 0x00000163058C96A8>
# ↑ returnされた func1_2のオブジェクト
>>> func1_2()
inside of func1_2: local1_1  # IV の結果

注目すべきポイントは、func1_2 の実行時に、ローカル func1_1scope 変数を参照している点です。
しかし、結局 scope_sample1.pyfunc1 がグローバルの scope 変数を参照できていたのと原理的には同じですね。
関数内関数については、

  1. 自分のローカルスコープ
  2. その一つ外側の関数のスコープ
  3. (あれば) その一つ外側の関数の...... (×n)
  4. グローバルのスコープ

という順で、「近いスコープから優先して参照」 のルールに従っているだけです。

ここまで読んで理解いただけている場合、特に難しいことはないと思います。
本当にルールに忠実に従ってるだけですね。

クロージャについて

いよいよ本丸です。
冒頭で述べた定義をもう一度振り返ってみましょう。

関数が第一級オブジェクトであるため、変数に関数を格納すると その関数定義自体 と共に その環境 が格納されることを利用する、関数を状態ごと保持 したオブジェクト

ここまでの説明で、前半部分は何となくはわかるのではないかなと思います。
あとは、クロージャの特徴である 関数を状態ごと保持 の部分を中心に解説していきます。
今ピンと来なくても具体例を見るとわかるかもしれないので、少し進めてみましょう。

クロージャの体験: カウンターを作ってみる

クロージャの実例として、よく「カウンターの実装」が使われます。
以下の例のようなものです (リスト型を使っている理由は後で説明します)。

closure_sample1.py
def createCounter():
    cnt = [0]
    print("running createCounter")

    def inner():
        cnt[0] += 1  # cnt[0] = cnt[0] + 1
        print(cnt)
        print("running inner")
    return inner


counter = createCounter()  # ちなみにここで "running createCounter" が printされる

createCounter 関数で cnt というリスト型の変数を定義し、それを関数内関数 inner の中でインクリメントしています。

最終行では、変数 countercreateCounter の実行結果、すなわち戻り値として返される関数オブジェクト inner を格納しています。

ここで質問です。
counterinner を格納しているわけですが、では counter を実行するとどうなるでしょうか。


・・・・・・・・・・・・

結果は以下です。

result-counter
>>> counter()
[1]
running inner

そりゃそうだよね、という感じです。
では、もう一度 counter() を実行するとどうなるでしょうか。


・・・・・・・・・・・・

ここが大事なポイントで、なんと counter() をもう一度実行すると今度は [2] となります。
変数 cnt はグローバル変数でもないのに、前回の実行結果がちゃんと保持されていて、更にインクリメントされていることになります。
もっと試してみましょう。

result-repeat_counter
>>> counter
<function createCounter.<locals>.inner at 0x000001F1D3CA9510>
>>> counter()
[2]
running inner
>>> counter()
[3]
running inner
>>> counter()
[4]
running inner
>>> counter
<function createCounter.<locals>.inner at 0x000001F1D3CA9510>
>>> counter
<function createCounter.<locals>.inner at 0x000001F1D3CA9510>

実行した分だけ、どんどん増えています。
ちなみに、counter() のように関数オブジェクトの末尾に () を付けると「関数実行の命令」を意味し、counter のように () 無しだとオブジェクトそのものを指します。

上の実行例では counter オブジェクト自身も何度か確認していますが、at 以後の値を見るとわかる通り、counter (= inner) オブジェクトは counter の実行前後や回数に関わらず、同一メモリアドレスにある同じものを指していることが分かります。オブジェクトが破棄などされず生存し続けている、ということですね。

これがクロージャの利用例です。
くどいですが、冒頭の定義を再掲します。

関数が第一級オブジェクトであるため、変数に関数を格納すると その関数定義自体 と共に その環境 が格納されることを利用する、関数を状態ごと保持 したオブジェクト

closure_sample1.py では、関数内関数である inner の定義時の環境は、

  1. inner 自身のローカルスコープ
  2. 外側の関数である createCounter のローカルスコープ
  3. グローバルスコープ

となります。
inner のローカルスコープ内で変数 cnt を定義していないので、cnt[0] += 1 で参照される cnt は、createCounter で定義した cnt = [0] となります。

で、この inner を変数 counter に格納しており、この counter はこの例だとグローバル変数なので破棄はされません。
そしてこの生み出されてから生存し続けている counter は、関数 inner をその 環境・状態ごと 格納しているので、その状態に対して行った更新も保持され続ける、ということになります。

では、counter = createCounter() のように変数に格納せず、直接 inner の実行を繰り返すとどうなるでしょうか。

>>> createCounter()()  # createCOunter() で innerが返るので、
[1]                    # もう一つ () を付けて inner() を表している
>>> createCounter()()
[1]
>>> createCounter()()
[1]
>>> createCounter()()
[1]
>>> createCounter()
<function createCounter.<locals>.inner at 0x000001F1D3CA9620>
>>> createCounter()
<function createCounter.<locals>.inner at 0x000001F1D3CA9510>
>>> createCounter()
<function createCounter.<locals>.inner at 0x000001F1D3CA9620>
>>> createCounter()
<function createCounter.<locals>.inner at 0x000001F1D3CA9510>

今度はインクリメントしてませんね。
これは先ほどとは異なり、inner を変数に格納していない = メモリ領域を割り当てていないため、実行の都度 inner が生成 -> 破棄 されているためです。
この例では inner オブジェクト (createCounter()) は、オブジェクト参照の都度異なるメモリ領域が割り当てられていることが分かります (実際には 2箇所で生成・破棄を繰り返しているようです)。

いかがでしょうか。
もっとわかりやすく、実用性を実感できるように、もう一つ実行例を書いておきます。

closure_sample2.py
def createCounter():
    cnt = [0]
    print("running createCounter")

    def inner():
        cnt[0] += 1  # cnt[0] = cnt[0] + 1
        print(cnt)
        print("running inner")
    return inner


counter1 = createCounter()  # 変数を二つ宣言
counter2 = createCounter()
result-repeat_counter
>>> counter1()  # ここでは counter1を実行
[1]
running inner
>>> counter1()
[2]
running inner
>>> counter2()  # ここから counter2を実行
[1]
running inner
>>> counter2()
[2]
running inner

counter1counter2 それぞれに関数 inner を格納していますが、counter1counter2 は別の変数 = メモリ領域も別なので、それぞれに実行結果 ≒ cnt の値 ≒ オブジェクトの状態が保持されている ことが分かります。
こんな風に、「同じ処理を繰り返し、処理の結果更新される状態を保持したい」「変数ごとの異なる状態を保持したい」という場合に使えますね。
クロージャを使えば、global空間を汚染せずに (= 変数の名前空間を分離させて) 、処理に応じて変数の値を変更 できます。

また、力尽きたのでここでは例までは書いてませんが、関数に渡す「引数」も受け取った関数のローカル変数のように振舞うので、引数とクロージャを組み合わせたりしても便利です。

補足

いかがでしょうか。
ここまででクロージャの説明は一通り終わりですが、以下からは皆さんが気になっているであろう点について補足をしておきます。

補足1: Pythonのスコープの不思議

closure_sample1.py などで、なぜ cnt に数値型でなく、わざわざリスト型を使ったのかをここで説明します。
私もはじめは参考サイトの JavaScriptの例を見て、素直に数値型でサンプルを作って実行してみました。

closure_sample3.py
def createCounter():
    cnt = 0

    def inner():
        cnt += 1  # cnt = cnt + 1
        print(cnt)
    return inner


counter = createCounter()

自然ですよね。
何も疑問を持たず実行すると、以下の結果となりました。

result-closure_sample3.py
>>> counter()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 5, in inner
UnboundLocalError: local variable 'cnt' referenced before assignment
>>>

まさかエラーが出るとは。

どうやら、行5でローカル変数 cnt が宣言前に参照されている、というエラーのようです。
じゃあ宣言すればいいんだろう、と以下のスクリプトにして実行しました。

closure_sample4.py
def createCounter():
    # cnt = 0

    def inner():
        cnt = 0  # inner() が cntを参照できるようにこっちで宣言
        cnt += 1  # cnt = cnt + 1
        print(cnt)
    return inner


counter = createCounter()

結果。

result-closure_sample4.py
>>> counter()
1
>>> counter()
1
>>> counter()
1

......まあそうですよね。
counter() の実行 = inner の実行時に、inner のローカルスコープで cnt に 0を代入しているせいで、実行の度に cnt が 0に初期化されてしまいます4

困っていろいろと調べたところ、「Pythonでは JavaScriptと異なり, 外の関数のスコープの変数には、参照はできても代入はできない」という仕様であることがわかりました。
なんとややこしい、JavaScriptではできてるのに。

この仕様の回避方法の一つが、closure_sample1.py などで使っていた、「更新したい外側の変数をミュータブルな型で定義し、オブジェクトへの代入を行う (例ではリスト)」という方法です。
ミュータブル型の変数なら、外のスコープでも 値を書き換える ことができるんですね。
ローカルスコープ外変数への代入はエラー となるけど、ローカルスコープ外のミュータブルオブジェクトの書き換えは可能 なので、このようにクロージャが機能します。

もう一つ、こちらは Python3でのみ利用可能 な方法ですが、nonlocal というキーワードを使うことで、「この変数はローカルに属する変数ではなく、一つ外側のスコープに属する変数 ですよ」と宣言することができます。

closure_sample5.py
def createCounter():
    cnt = 0

    def inner():
        nonlocal cnt  # ここで変数 cntは nonlocalであると宣言
        cnt += 1
        print(cnt)
    return inner


counter = createCounter()

実行結果です。

result-closure_sample5.py
>>> counter()
1
>>> counter()
2
>>> counter()
3

今度は cnt はイミュータブルな数値型ですが、ちゃんとインクリメントされてますね。
Python2にはなかった nonlocal というキーワードが Python3で追加されたということは、やはり要望が多かったんでしょうか。

ということで、クロージャを作るときには更新したい変数のデータ型にもご注意ください。

補足2: 類似機能であるインスタンス変数との使い分け

ここまでの説明や例を見て、「クラス」や「インスタンス」を知っている方からすると、「インスタンス変数と同じじゃないか?」という疑問が湧くと思います。

はい、機能的にはほぼその通りです。
これまでのカウンターの例は、以下のようにインスタンス変数でも同様に実現できます。

counter_class_sample.py
class Counter:
    def __init__(self, cnt):
        self.cnt = cnt  # インスタンス変数なのでインスタンスごとに値を持つ

    def incrementCount(self):
        self.cnt += 1
        print(self.cnt)

実行してみましょう。

result-counter_class_sample
>>> counter1 = Counter(0)  # インスタンス1作成
>>> counter2 = Counter(0)  # インスタンス2作成
>>>
>>> counter1.incrementCount()
1
>>> counter1.incrementCount()
2
>>> counter2.incrementCount()
1
>>> counter2.incrementCount()
2

これまでクロージャで説明してきたものと同じ結果が得られてますね。
このように、クラス、インスタンス変数を作ることでも同じ機能は実現はできます。
ただ、クラスを作るほどでもない簡易なものであれば、無用に新たなクラスという登場人物を増やすよりも、クロージャで十分なこともあるのではないでしょうか。

まとめ

当初の構想よりずいぶん長くなりましたが、いかがでしょうか。
参考サイトも記載していますので、そちらもぜひご確認いただければと思います。

しつこいですが、もう一度私の理解するところのクロージャの定義を記載します。

クロージャ
関数が第一級オブジェクトであるため、変数に関数を格納すると その関数定義自体 と共に その環境 が格納されることを利用する、関数を状態ごと保持 したオブジェクト

ここまで読んでいただけた方は、こちらの定義が伝わるのではないかと思います。
言葉にしてみると複雑そうですが、大事なことは以下の二つです。

  1. Pythonの関数は第一級オブジェクト
  2. Pythonの関数オブジェクトは、変数に格納した際に環境ごと格納される

結局「クロージャ」という名前がついていても、それは上の二つ (をはじめとする) 基本原則に忠実に従った結果の振る舞いを指しているだけです。
関数の振る舞いを理解すれば、クロージャというものがわけのわからない複雑なものではない、ということに気づけるのではないかなと思います5

長文となりましたが、お付き合いいただきありがとうございました。
次はもう一段本来の目的に戻って、デコレータも調べてまとめたいです。

参考

  • http://analogic.jp/closure/
    取り上げている言語は JavaScriptですが、とても分かりやすく実例を踏まえて解説してくださっています。サンプルコードなども参考にさせていただきました。
  1. 強い方には、全編通して誤った理解などのご指摘を頂けるととても嬉しいです。

  2. 「Pythonの」とわざわざ書いているのは、言語によってスコープ定義が異なるためです。これもまた混乱のもと。 言語によってスコープ定義が異なるのは事実ですが、今回のクロージャの解説には関連する部分ではありませんでした。

  3. Pythonに限らず大抵の言語でこうなるようです。コメント欄で shiracamusさんが C、JavaScripptでの実例を挙げてくれています。

  4. 正確には数値型はイミュータブルなので、同じ変数が初期化されているのではなく、都度新しい cnt を作ってそれを参照している、という感じのようです。

  5. でも関数の振る舞いなんて調べるまでまともに知らなかったので、ほんとわけわからなかったです。初心者殺しだと思います。

60
50
7

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
60
50

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?