Pythonのクロージャでできること

私はPythonは型が柔軟なので、ゴリゴリにクラス実装するのは良くないと思っています。

書籍で、クロージャを多用すると複雑になるので、やめといたほうがよいという記述が見受けられますが、私はクラスをたくさん作って、継承していくほうが複雑でロジックを追いにくくなると考えています。

ここでは、クロージャについて、シンプルなものから徐々に複雑にしながら実装パターンを考察していきたいと思います。

シンプルなクロージャ

まずは、クロージャの簡単な説明をするために、シンプルな不変オブジェクトを扱うクロージャを作ってみます。

# クロージャの定義
def gen_counter():
    cnt = 0
    def _count():
        nonlocal cnt
        cnt += 1
        return cnt
    return _count

# クロージャの利用例
my_counter = gen_counter()
my_counter() # 1を返す
my_counter() # 2を返す

以下では、このクロージャgen_counterのポイントを説明していきます。

クロージャは関数を戻している

クロージャgen_counter内のスコープの中で、関数_countを定義し、その定義を返しています。

この関数countの定義名_countは一時的な名前で、クロージャgen_counterの中でしか使われません。なので、アンダースコア()を付けてますが、名前は自由です。一時的な変数というのがわかりやすいようにアンダースコアを先頭に付けています。

クロージャgen_counterは、関数を戻しているので、「my_counter = gen_counter()」のステートメントを実行すると、my_counterに_countの関数定義が代入されます。関数なので、「my_counter()」と関数呼び出しすれば、_counter()が呼び出されることになります。

単に関数を返すだけの関数であれば作る意味がありませんが、次のステート(状態)の説明でクロージャの意味がわかります。

ステート(今回はcnt)を保持している

クロージャgen_counterのスコープで定義しているcntが_countで利用されています。インクリメント「cnt += 1」され、戻り値「return cnt」として利用されています。

このcntの扱いがポイントです。_countの関数内で、「nonlocal cnt」と宣言しています。

nonlocalは、ひとつ外側のスコープに属する変数への代入が可能になります。つまり、_countのスコープで「nonlocal cnt」と宣言しているので、ひとつ外側のgen_counterのスコープにあるcntへ変数代入可能になります。

このnonlocalがないと、_countで変数cntへ代入すると、_countのローカル変数cntを新規に作成され、生存期間が_countの呼び出し時の一時的な変数となります。つまり、外側のgen_counterのcntとは同じ変数名だが、別の変数(入れ物)と扱われます。次のサンプルで説明しますが、変数に別のオブジェクトを代入しないのであれば、nonlocalで宣言する必要はありません。実はnonlocalは、外側スコープの変数に代入しなければいけない特殊なケースで必要なだけです。

説明が難しいですが、シンプルに、下記のような一つ外のスコープのステートをパックした関数が戻されると理解すればよいです。わかりやすくするために、定義を増やしています。

def gen_counter():
    #----ここから----
    cnt_a = 0
    cnt_b = 0
    def _count():
        nonlocal cnt_a
        cnt_a += 1
        cnt_b += 2
        return cnt_a + cnt_b
    #----ここまで----
    return _count #このreturnで「ここからここまで」というスコープを保持した_countを返す

厳密に言えば、変数の代入と参照はステートメントの評価時に探索して決定されるので、パックされるわけではないです。

補足:引数も外側スコープに入る

次のクロージャの例の前に、1つ目のクロージャを変形しスコープでもう一つ説明しておきたいです。

よく考えれば当たり前ですが、gen_counterが引数を取る場合、その引数もgen_counterのスコープで宣言した変数と同じ扱いです。

それを使ってgen_counterにカウントアップの初期値を与える拡張をします。

# クロージャの定義
def gen_counter(cnt=0):  #この引数のcntも「(gen_counterのスコープ)==(_countのひとつ外側のスコープ)
    def _count():
        nonlocal cnt
        cnt += 1
        return cnt
    return _count

# クロージャの利用例
my_counter = gen_counter(10)
my_counter() # 11を返す
my_counter() # 12を返す

nonlocalが必要ないステートのクロージャ

nonlocalは、ひとつ外側のスコープに属する変数への代入が可能になります。なので、代入しない場合はnonlocalは不要です。

リストなど可変オブジェクトは、それが代入された変数へオブジェクトの更新メソッドが用意されているので、代入での更新ではなく、メッソド呼び出ししてステート変更してきます。なので、可変オブジェクトをステートとするクロージャの場合は、nonlocalが不要になります。

下記にサンプルを示します。

def gen_bank():
    account = []
    def _append(money):
        account.append(money)
        return account
    return _append

mybank = gen_bank()
mybank(100) # [100] を返す
mybank(50)  # [100, 50] を返す

※mybankを何回呼び出しても、同じインスタンスであるgen_bankのスコープにあるaccountに格納されているリストオブジェクトを返しているので意味ないです。

gen_bankのスコープで定義した変数accountには可変性のリストオブジェクトが格納されており

nonlocalを使わない同じ例で、listのappendと同じ機能を持つクロージャを定義してみます。

def gen_appender(buf):
    def _append(value):
        buf.append(value)
    return _append

bag = []
myappender = gen_appender(bag)
myappender(100)
bag  # [100]
myappender("hoge")
bag  # [100, 'hoge']

クロージャgen_appenderでステートとして扱うbufを受け取っています。クロージャが返す関数_appendは、戻り値を返さず、クロージャgen_appenderで渡されて保持しているbufに変更を加えるだけです。

私のかるーい言い方をすれば、「状態bufをクロージャでパックして処理する関数を作っている」です。

ここまでの整理

ここまでのクロージャで表現できる処理を整理すると下記です。

  • ステートを保持した関数を作りあげる
  • そのステートは、クロージャの引数で外から与えることも可能

なんとなく、クラスでできることが少しできている気がしませんか?

  • ステートを保持した関数を作りあげる ▶インスタンス変数
  • そのステートは、クロージャの引数で外から与えることも可能 ▶コンストラクタ

ここまで、理解できていれば、クロージャを使って大抵のことはできます。しかし、もう少しクラスでできることまで手を伸ばしてみたいと思います。

複数の処理を持つクロージャ

ここまでは、一つの処理を持つクロージャを作りましたが、クラス定義では複数の処理を持つものを定義できます。クロージャでも作れるのでその例です。

長くて、後述するように戻りがディクショナリで使いにくいクロージャですが、複数の処理を持つクロージャを作ってみます。

def gen_multi_counter(cnt=0):
    def _single_count_up():
        nonlocal cnt
        cnt = cnt + 1
        return cnt
    def _double_count_up():
        nonlocal cnt
        cnt = cnt + 2
        return cnt
    def _multi_count_up(val):
        nonlocal cnt
        cnt = cnt + val
        return cnt
    return dict(
        single = _single_count_up,
        double = _double_count_up,
        multi = _multi_count_up
    )

mycounter = gen_multi_counter(10)

mycounter['single']()  # 11を返す
mycounter['double']()  # 13を返す
mycounter['multi'](10) # 23を返す

このサンプルでは、引数で不変オブジェクトを受け取って、それをステートに使って

  • single ▶1つカウントアップ
  • double ▶2つカウントアップ
  • multi ▶受け取った数値分カウントアップ

というように3つの処理を持つクロージャを作っています。もう、言葉の定義的にはクロージャではないのかもしれません。

3つの処理をまとめるために、関数ではなく、関数を値に持つディクショナリを返しています。ディクショナリなので、[’single’]のように文字keyアクセスが必要で[”]の分無駄な表現でのアクセスになりますが、それはそれでディクショナリを簡易にアクセスする問題なので、別議論です。

まぁ、それぞれ分けてクロージャを作っても良いです。こんなこともできるよということです。ですが、3つ関連する機能を1つの定義に含めることは可読性で優位です。このサンプルはカウントアップの機能をまとめています。忘れては行けないですが、3つとも使う必要はないです。singleだけ使ってもよいのです。

処理を変えるクロージャ

クロージャでは、数値やリストオブジェクトだけでなく関数オブジェクトも保持することが可能です。

なので、下記のようにステートの変更内容の処理を決める関数を渡してもよいです。

def gen_counter(cnt, method):
    def _count():
        nonlocal cnt
        cnt = method(cnt)
        return cnt
    return _count

def _count_up(cnt): return cnt + 1

mycountupper = gen_counter(0, _count_up)
mycountupper() #1を返す
mycountupper() #2を返す

def _count_down(cnt): return cnt - 1

mycountupper = gen_counter(10, _count_down)
mycountupper() #9を返す
mycountupper() #8を返す

もっと言えば、クロージャに別のクロージャを渡しても良いです。

この例では、_countの処理がクロージャに渡されたmethodだけに依存した処理なので、結局_countってどんな処理するんだっけというのがわかりにくくなるので、やりすぎると無駄に柔軟で、結局は大したパターンがなく用意した柔軟性がわかりにくくするだけになってしまいます。

シェアする

  • このエントリーをはてなブックマークに追加

フォローする