Python(5)クロージャ(Closure)のお勉強

ラベル:

前の関連記事:Python(4)関数の引数の変数名やそれに代入されているコードを取得


クロージャについてのお勉強の記録です。クロージャを使うと関数に関数外からアクセスできない値をもたすことができます。

関数の結果を保持したいけどグローバルのスコープには保持したくない


LibreOffice(49)オブジェクトからサービスとインターフェイスの継承関係辞書を生成でみたように、Pythonの関数の引数は原則、参照渡しがなく(プログラミング FAQ — Python 3.3.3 ドキュメント)、値渡しのみになります。

関数の引数は呼び出し元から渡されると以後呼び出し元とは関連がなくなることとなります。
# -*- coding: utf-8 -*-
n = 0  # 変数に0を代入。
def inc(n):  # 変数nを引数に受け取る関数inc。
    n += 1  # nに1を加える。
    return n  # nを戻り値として返す。
print(inc(n))
print(inc(n))
print(inc(n))
print(inc(n))
ということで2行目で定義されたnと関数inc()中のnとは関連はなく、inc(n)を何回呼び出したところでnは0のままでinc(n)で返ってくる値はすべて1になります。
1
1
1
1
(行番号はソースのprint()の行数にあわせています。)

ところがリストのようなオブジェクトを引数にした場合は呼び出し元との関連は切れずに実質参照渡しと同等になります。
# -*- coding: utf-8 -*-
n = 0
m = [n]  # 要素番号0の値が0のリストmを生成。
def inc(m):  # リストmを引数に受け取る関数inc。
    m[0] += 1  # リストmの要素番号0の要素に1を加える。
    return m[0]  # リストmの要素番号0の値を戻り値として返す。
print(inc(m))
print(inc(m))
print(inc(m))
print(inc(m))
これは関数inc()を呼び出すたびにリストmは更新されるために出力結果は1ずつ増加することになります。
1
2
3
4
さらにリストmはグローバルなスコープ(有効範囲)なので引数にする必要もありません。
# -*- coding: utf-8 -*-
n = 0
m = [n]  # 要素番号0の値が0のリストmを生成。
def inc():
    m[0] += 1  # リストmの要素番号0の要素に1を加える。
    return m[0]  # リストmの要素番号0の値を戻り値として返す。
print(inc())
print(inc())
print(inc())
print(inc())
これも同じように1ずつ増加する結果が返ります。

ところがグローバルなスコープにあるものは容易に変更が可能になり予想外の結果が引き起こすことがあります。

例えば関数inc()の定義とそれを出力するprint()が離れたところにあってその間でリストを再定義してしまっているとします。
# -*- coding: utf-8 -*-
n = 0
m = [n]  # 要素番号0の値が0のリストmを生成。
def inc():
    m[0] += 1  # リストmの要素番号0の要素に1を加える。
    return m[0]  # リストmの要素番号0の値を戻り値として返す。

m = [10]  # リストmを再定義。
print(inc())
print(inc())
print(inc())
print(inc())
これを実行すると結果は11から始まることになります。
11
12
13
14
これは最初に期待した結果と異なってしまいます。まあPyCharmが注意してくれますけど、、、

このようにグローバルなスコープのものを使うと管理が大変になってくるのでなるべく使いたくありません。
# -*- coding: utf-8 -*-
class Inc:  # Incクラス。
    def __init__(self, n):
        self.m = [n]  # クラスIncをインスタンス化したときの引数nをうけてクラスのメンバ変数のリストmを生成。
    def inc(self):  # クラスIncのincメソッドを定義。
        self.m[0] += 1  # リストmの0番目の要素に1を加える。
        return self.m[0]  # リストmの0番目の要素を返す。
n = 0
ins = Inc(n)  # 変数nを引数としてクラスIncのインスタンスinsを生成。


print(ins.inc())
print(ins.inc())
print(ins.inc())
print(ins.inc())
これも1, 2, 3, 4が返ります。

こんな風にクラスにしたところでPythonにはプライベートインスタンス変数はない(9.6. プライベート変数)のでリストmは書き換え可能です。
# -*- coding: utf-8 -*-
class Inc:  # Incクラス。
    def __init__(self, n):
        self.m = [n]  # クラスIncをインスタンス化したときの引数nをうけてクラスのメンバ変数のリストmを生成。
    def inc(self):  # クラスIncのincメソッドを定義。
        self.m[0] += 1  # リストmの0番目の要素に1を加える。
        return self.m[0]  # リストmの0番目の要素を返す。
n = 0
ins = Inc(n)  # 変数nを引数としてクラスIncのインスタンスinsを生成。

ins.m = [10]  # insのもっているリストmを書き換える。
print(ins.inc())
print(ins.inc())
print(ins.inc())
print(ins.inc())
これは11, 12, 13, 14が返ります。

関数のローカル変数を引数とする関数を作ると外部からアクセスできない値が保持できる


ということで外部からアクセスできないところとなると関数のローカル変数になります。

Pythonの関数は第一級関数 - Wikipediaなので関数でもオブジェクトとして扱えます。

そこで関数を関数の中に入れてしまって、中に入れた関数を戻り値にしてその関数を使うようにします。
# -*- coding: utf-8 -*-
def counter():  # 関数inc()を入れる関数。
    n = 0
    m = [n]  # 要素番号0の値が0のリストmを生成。リストmのスコープは関数counter()の中だけ。
    def inc():  # 関数counter()の中で関数inc()を定義。
        m[0] += 1  # リストmの要素番号0の要素に1を加える。
        return m[0]  # リストmの要素番号0の値を戻り値として返す。
    return inc  # 関数counter()の戻り値として関数inc()を返す。
count = counter()  # 関数counter()の戻り値の関数inc()をcountに代入。つまりcountを実行することはcounter()のローカルスコープで動くinc()実行することになる。
print(count())
print(count())
print(count())
print(count())
これでもう関数counterの中でなければいくらリストmを変更しても結果は変わりません。
# -*- coding: utf-8 -*-
def counter():  # 関数inc()を入れる関数。
    n = 0
    m = [n]  # 要素番号0の値が0のリストmを生成。リストmのスコープは関数counter()の中だけ。
    def inc():  # 関数counter()の中で関数inc()を定義。
        m[0] += 1  # リストmの要素番号0の要素に1を加える。
        return m[0]  # リストmの要素番号0の値を戻り値として返す。
    return inc  # 関数counter()の戻り値として関数inc()を返す。
m = [10]
count = counter()  # 関数counter()の戻り値の関数inc()をcountに代入。つまりcountを実行することはcounter()のローカルスコープで動くinc()実行することになる。
m = [10]
print(count())
print(count())
print(count())
print(count())
これでも結果は同じです。
1
2
3
4
参照渡しができるオブジェクトと第一級関数があるとここまでできてしまいます。

関数と静的スコープのペアをクロージャという


引数以外の変数を実行時の環境ではなく、自身が定義された環境(静的スコープ)において解決することを特徴とする。関数とそれを評価する環境のペアであるともいえる。
クロージャ - Wikipedia
このクロージャの定義は最初に読んだときは抽象的なものに思えてよくわからなかったのですが、ちゃんと「関数と静的スコープのペア」と読めますね。

ということで上のコードですと関数inc()とその静的スコープ、つまり関数counter()のローカルスコープのペアがクロージャということになります。

リストmは関数inc()に参照渡しするために使用していますが、Python3.xではnonlocal 文を使うことで一つ外側のスコープまで有効な変数を定義することができるようになったのでわざわざリストmを使う必要がなくなりました。
# -*- coding: utf-8 -*-
def counter():  # 関数inc()を入れる関数。
    n = 0
    def inc():  # 関数counter()の中で関数inc()を定義。
        nonlocal n  # nを一つ外側のスコープ(ここでは関数counterのローカル変数のスコープ)まで有効にさせます。
        n += 1  # nに1を加える。
        return n  # nを戻り値として返す。
    return inc  # 関数counter()の戻り値として関数inc()を返す。
count = counter()  # 関数counter()の戻り値の関数inc()をcountに代入。つまりcountを実行することはcounter()のローカルスコープで動くinc()実行することになる。
print(count())
print(count())
print(count())
print(count())
無用なリストが排除できてすっきりしましたね。

逆に関数inc()に引き渡す値が複数あってリストやタプルで渡す場合はnonlocal文は不要ということになります。

return文では静的スコープが保持されない

# -*- coding: utf-8 -*-
def counter():
    n = 0
    def inc():
        nonlocal n
        return n+1  # return文の式の結果は保持されない。
    return inc
count = counter()
print(count())
print(count())
print(count())
print(count())
単に6行目で式で返すようにしただけですがこれだと結果が1, 1, 1, 1になってしまいます。

ちなみにこのときはnonlocal文がなくてもエラーがでません。
# -*- coding: utf-8 -*-
def counter():
    n = 0
    def inc():
        return n+1  # 1しか返らない。
    return inc
count = counter()
print(count())
print(count())
print(count())
print(count())
うーん、これだと静的スコープが保持されていないのでクロージャではなくて単なる高階関数のような気がしますけど、クロージャとして紹介されているページがいっぱい出てきますね。

そういうのもクロージャというのですかね。よくわかりませんねぇ。

(H27.4.15追記。return文では静的スコープが保持されないというだけのことでした。nonlocal文がなくてもエラーがでないのはreturn文は外側の関数のスコープになるから?)

まあ使い方がわかればそれでいいのですけどね。

とりあえずデコレータを使ってみる


以前Python(1)WSGI:PythonからWSGIアプリでウェブブラウザに結果を出力するでWSGIアプリの使い方について調べているときに「werkzeug」でWSGIアプリをつくろう! — PythonMatrixJpでPythonのデコレータを知りました。

inc()をcounter()でデコレートして書けるかなと思ってやってみましたが、nがスコープ外になってしまって解決できませんでした。
# -*- coding: utf-8 -*-
def counter(inc):
    n = inc()
    def func():
        nonlocal n
        n += 1
        return n
    return func
@counter
def inc():
    return 0
print(inc())
print(inc())
print(inc())
print(inc())
無理やりデコレータを使用してみました。これも1, 2, 3, 4が返ってきます。

0を返す関数inc()を呼んでいるのですが、デコレータで1を加算する関数の戻り値が返っています。

クロージャについてもうちょっと考えてみる


以下単なるメモです。
# -*- coding: utf-8 -*-
def counter(n):
    def ins():
        nonlocal n
        n += 1
        return n
    return ins
count = counter(0)
print(count())
print(count())
count = counter(10)
print(count())
print(count())
これで1, 2, 11, 12が返ってきます。

counter(0)ではなくて、それを変数に入れたものを関数として呼び出すというのがなんか不思議ですね。

counter(0)で返ってくるのは関数counter()の戻り値の関数ins()が返ってきてそこに0から始まる数値が保持されているということですね。
# -*- coding: utf-8 -*-
def counter(n):
    def ins():
        nonlocal n
        n += 1
        return n
    return ins
count = counter(0)
count2 = counter(10)
print(count())
print(count2())
print(count())
print(count2())
こうすると1, 11, 2, 12が返ってきますね。

なんか面白いですね。

参考にしたサイト


プログラミング FAQ — Python 3.3.3 ドキュメント
Pythonの関数の呼び出し元と呼び出し先にある引数名の間にエイリアス(参照)はありません。

9.6. プライベート変数
Pythonのクラスにプライベートインスタンス変数はありません。

第一級関数 - Wikipedia
Pythonの関数は第一級関数なので、関数でもオブジェクトとして扱えます。

クロージャ - Wikipedia
関数と静的スコープのペア。

Javascript と Python でクロージャー使ったカウンタのメモ - 牌語備忘録 - pygo
この例を参考にさせていただきました。

Python とクロージャ - プログラマのネタ帳
Python3.xではリストを使わなくてもクロージャが使えることを教えていただきました。

Pythonでクロージャはできないの? | blog.PanicBlanket.com
上のサイトを教えていただきました。

7.13. nonlocal 文
Python3.xから一つ外のスコープの変数にアクセスできるようになりました。

「werkzeug」でWSGIアプリをつくろう! — PythonMatrixJp
デコレータの使用例。

Clojure プログラミング言語
カタカナで書くと同じ「クロージャ」ですがこちらはJVMで動くLispのことです。

次の関連記事:Python(6)WSGIサーバの例をいじっていろいろPythonのお勉強

PR

0 件のコメント:

コメントを投稿