そのコードがわかりにくいのはクロージャのせいではない

せっかくPythonの話がホットエントリに入っているのに、あまりいいサンプルとは思えなかったので、書き直してみたい。

 クロージャと無名関数を使うと、こんな風に実装することができる。

import yaml

def _get_from_disk():
     data = open('config.yaml').read().decode('utf8')
     config = yaml.load(data) # クロージャ内に隠蔽・保持されるローカル変数
     global get
     get = lambda : config   # 二回目からはconfigを返す無名関数を呼ぶ様に変更
     return get()

get = _get_from_disk   # 初回のみローダーを実行

 わずかな違いだが、私にはこのスタイルの方がずっとすっきりくる。

Life is beautiful: Python Hack : 噛めば噛むほどおいしくなるクロージャの話

ワシにはこのコードはぜんぜんすっきりこない。

まず5行目のここ。「#」が全角であり、その直前も全角空白が入っている。

     config = yaml.load(data) # クロージャ内に隠蔽・保持されるローカル変数

これじゃあ全然すっきりしない。

・・・というのは置いといて、YAMLの読み込みは、ファイルがutf8で書かれているなら、yaml.load()にファイルオブジェクトを渡すほうがいい。

     ## こう書くよりも
     data = open('config.yaml').read().decode('utf8')
     config = yaml.load(data)
     ## こっちのほうがよい
     config = yaml.load(open('config.yaml'))


・・・というのは置いといて、本当に言いたいのはだな、もとのコードのポイントは、関数が自己再定義を行なっている点であり、クロージャとかローカル変数とかじゃない。そして、もとのコードではその「自己再定義を行なっている」ということがちっとも明確でない。もし上のコードを読んでわからない人がいれば、それはクロージャが分かってないからじゃなくて、自己再定義を行なっていることが明確に示されてない上のコードが悪いからである。

ワシならこう書く。自己再定義なんだから、_get_from_disk()とかまるでいらん。

def get():
     ## 初回実行時に設定ファイルを読み出す
     config = yaml.load(open('config.yaml'))
     ## 設定ファイルの内容を返す関数で自分自身を置き換える
     global get
     get = lambda: config
     ## 置き換えた関数を実行する
     return get()

あるいは:

def get():
     """return configuration"""
     ## 初回実行時に設定ファイルを読み出す
     config = yaml.load(open('config.yaml'))
     ## 設定ファイルの内容を返す関数で自分自身を置き換える
     def _get():
         return config
     global get
     _get.__doc__ = get.__doc__ # 関数のドキュメントをコピー
     get = _get
     ## 置き換えた関数を実行する
     return get()

これなら、自己再定義を行なっていることが明確である。また設定ファイルを読み出すのが最初の一回だけであることもコメントしておいたほうが分かりやすい。

キャッシュした値を覚えている config はローカル変数なのでモジュール外だけでなくモジュール内の他の部分からも完璧に隠蔽されているし、二回目からは "lambda: config" (JavaScriptだと "function() { return config; }" という無名関数に相当)を実行するだけなので効率が良い。

モジュール外からはともかく、モジュール内からの隠蔽はそれほど気にする必要はないよなあ。だいたい、いろんなオブジェクトの中身が丸見えな言語を使っておいて、「完璧な隠蔽」とかいらんだろ。

あと実行効率を気にするなら、外の変数を参照しないようにするほうが速い。

def get():
     config = yaml.load(open('config.yaml'))
     global get
     #get = lambda: config      # non localな変数を毎回参照するよりも
     def _get(_config=config):  # 最初の一回だけ参照するようにして
         return _config         # あとはlocal変数を参照したほうが速い
     get = _get
     return get()

でもこんなことで向上する速度なんてほんのわずかだし、使う人が引数を間違って渡しちゃうかもしれないし、コードもわかりにくくなるのでお勧めしない。

 もう一つは、「if not _config:」という条件分岐を毎回毎回実行しなければならないこと。一つ一つは小さな話でも、何百回も何千回も呼ぶ必要がある場合、このオーバーヘッドも馬鹿にならない

ほんと?『馬鹿にならない』といえるほど違いがある?
個人的には、if文1回のコストなんて、関数呼び出しのコストに比べればたかが知れていると思うんだけど。

 唯一の難点は、「クロージャがなんであるか」が直感的に理解しにくいために、初心者にとっては、決して「読みやすいプログラム」にはなっていないこと(C++Javaにしか触れていない人たちには上のサンプルは理解しがたい)。あまりクロージャを駆使すると、一部の人しか理解できない黒魔術になってしまうので、そこは気をつけなければいけない。

もとのコードがわかりにくいのは、自己再定義を行なっていることが明確にされてないことが原因であり、クロージャがどうのこうのはあんまり関係ない。あと、C++という難解な言語を使いこなせるプログラマ―なら、クロージャの理解なんかたやすい*1から、そこは心配しなくていい。C++プログラマーの頭の良さはそれこそ馬鹿にできない。


別のブログから:

まず、普通の実装

import yaml

_config = None
def get():
    global _config
    if not _config:
        data = open('config.yaml').read().decode('utf8')
        _config = yaml.load(data)
    return _config
http://satoshi.blogs.com/life/2009/11/python-hack.html

では、

最初にgetが呼ばれたタイミングで設定ファイルがロードされるが、クロージャを使ったほうではコードが読み込まれた時点でconfigに値が束縛されてしまう。これはメモリ効率上よくない。

キャッシュと効率 - 素人がプログラミングを勉強していたブログ

たしかに! これは気づかなかったけど、バグといっていいよね。

推測だが、_get_from_diskという関数は本当はgetという名前で、一度呼ぶと関数がキャッシュ済みのconfigを返すように差し替えられるという挙動をさせたかったのだと思う。

ですよねー。『推測』しなきゃいけない時点で、もとのコードがわかりにくいことを物語ってますよね。

速度向上に役に立つかについても疑問に思う。
...(snip)...
100万回呼び出してたったの0.035msしか速度が変わらないオーバヘッドが、"馬鹿にならない"ほどであることは少ないと思う。

ですよねー。

この話で必要とされているのはクロージャではなくstatic変数であり、様々な代替手段があるので可読性と速度を天秤にかけ適切な手段を選ぶべきだ。

ですよねー。

*1:C++プログラマ―でもクロージャをうまく使いこなすのは勉強しないといけないだろうけど、上のコードを読むくらいならすぐに理解できるだろ。