Pythonのデコレータをイチから展開・解説してみた 【あれ? ただのクロージャじゃん】編
前回のコードは思い返すだけでも寒気がするほどひどいものでした。そこで今回はPython固有の機能であるデコレータを使って、ちょっとPythonっぽいコードに変更します。
このデコレータ、LispやHaskellのように関数や処理を頻繁に渡す言語に慣れ親しんだ人なら比較的すぐに理解できると思うのですが、そうでない場合は難しく感じるかもしれません。しかし理解してしまえば「なんだそんなことか」といったものなので、もちろん乱用は禁物ですが、適宜使っていくことで明らかに読みやすく宣言的なコードを書くことができます。参考: PEP 318
再掲:リファクタリング対象
「デコレータとはなんぞや」というのは検索すればいくらでも出てきますので、今回ハマった点について書いていきます。また、デコレータが行うこと、行った結果関数がどう変更されるか、も書いてみようかと。
ではリファクタリング前のdictionary.py
のsave
メソッド達です。
def save(self): """メモリ上の辞書をファイルに保存する。""" self._save_random() self._save_pattern() self._save_template() self._markov.save(Dictionary.DICT['markov']) def _save_template(self, dicfile=None): """テンプレート辞書をdicfileに保存する。 dicfileのデフォルト値はDictionary.DICT['template']""" dicfile = dicfile if dicfile is not None else Dictionary.DICT['template'] with open(Dictionary.DICT['template'], mode='w', encoding='utf-8') as f: for count, templates in self._template.items(): for template in templates: f.write('{}\t{}\n'.format(count, template)) def _save_pattern(self, dicfile=None): """パターン辞書をdicfileに保存する。 dicfileのデフォルト値はDictionary.DICT['pattern']""" dicfile = dicfile if dicfile is not None else Dictionary.DICT['pattern'] lines = [Dictionary.pattern_to_line(p) for p in self._pattern] with open(dicfile, mode='w', encoding='utf-8') as f: f.write('\n'.join(lines)) def _save_random(self, dicfile=None): """ランダム辞書をdicfileに保存する。 dicfileのデフォルト値はDictionary.DICT['random']""" dicfile = dicfile if dicfile is not None else Dictionary.DICT['random'] with open(dicfile, mode='w', encoding='utf-8') as f: f.write('\n'.join(self.random))
いつ見てもひどいですが、デコレータを使ってこんな風に書けないでしょうか。
import functools # ...中略... def save_dictionary(dict_key): """ 辞書を保存するためのファイルを開くデコレータ。 dict_key - Dictionary.DICTのキー。 """ def _save_dictionary(func): @functools.wraps(func) def wrapper(self, *args, **kwargs): with open(Dictionary.DICT[dict_key], 'w', encoding='utf-8') as f: result = func(self, *args, **kwargs) f.write(result) return result return wrapper return _save_dictionary @save_dictionary('template') def _save_template(self): """テンプレート辞書を保存する。""" lines = [] for count, templates in self._template.items(): for template in templates: lines.append('{}\t{}'.format(count, template)) return '\n'.join(lines) @save_dictionary('pattern') def _save_pattern(self): """パターン辞書を保存する。""" lines = [Dictionary.pattern_to_line(p) for p in self._pattern] return '\n'.join(lines) @save_dictionary('random') def _save_random(self): """ランダム辞書を保存する。""" return '\n'.join(self.random)
すげー! Pythonっぽい!!
「こんな風に書けないでしょうか」と言ったものの、これで動作してしまいます。リファクタリング前のコードと比べて格段に読みやすくなった……と思うんですが、Python初心者の意見なのでもっと良い方法があるはずだと信じています。
とりあえず想定通りの動作はしています。というかリファクタリングの前にテスト環境を整えるのが絶対優先のはずが、流れでやっちゃってるのでそのうちテストコード書きたいです。
何が起きたか
リファクタリング前と後では、削除されたコードと追加されたコードがあります。まずは削除されて読みやすくなったもの。
with
ブロックf.write
メソッドの呼び出しDictionary.DICT
の参照- 引数のデフォルト値
何と言ってもwith
ブロックがなくなったのは大きな一歩です。radon cc -s dictionary.py
を走らせると、今回リファクタリングした_save_*
系メソッドのCCはすべて3以下で、_save_random
に至っては1にまで低下しました。
また、これまでf.write
で直接ファイルディスクリプタに書き込んでいたのを、文字列を返すように変更しました。明示的に書き込む処理がなくなって不思議な感じがします。
open
に渡していたファイル名はデコレータの引数へ移動しました。当初は
@save_dictionary(Dictionary.DICT['random'])
のように書けるかなと思っていましたが、この式が評価されるのは「読み込み時」であって「実行時」ではありません。読み込み時はDictionary
という名前解決ができない(Dictionary.DICT
が存在しない)ため、この書き方はできません。代わりに文字列のみを渡して、Dictionary.DICT
の値を取得するようにしたらうまくいきました。見た目もすっきりしたので、多分Pythonic Wayです。
削除された処理はすべてsave_dictionary
というメソッドに移動されました。これがデコレータの正体です。ひとつずつ見ていきます。
デコレータを展開する
def save_dictionary(dict_key): """ 辞書を保存するためのファイルを開くデコレータ。 dict_key - Dictionary.DICTのキー。 """ def _save_dictionary(func): @functools.wraps(func) def wrapper(self, *args, **kwargs): with open(Dictionary.DICT[dict_key], 'w', encoding='utf-8') as f: result = func(self, *args, **kwargs) f.write(result) return result return wrapper return _save_dictionary
関数内関数定義が二重にネストされていて、ぱっとみよくわかりません。戻り値もwrapper
関数を除けばすべて関数で、値ではありません。これだけ見てすぐに理解できた人は、よほど高階関数の扱いに慣れているか、Pythonistaであるか、天才です。
まず前提条件として、「デコレータ記法はシンタックスシュガーに過ぎない」点を忘れてはいけません。
@save_dictionary('random') def _save_random(self): return '\n'.join(self._random)
このコードは以下と等価です。
def _save_random(self): return '\n'.join(self._random) _save_random = save_dictionary('random')(_save_random)
save_dictionary('random')
で返ってきた関数に_save_random
という関数を渡し、その結果を_save_random
に代入するという、ちょっと意味のわからないことになってます。ここまでややこしくなったのはsave_dictionary
が引数を取るからなのですが、整理してみましょう。
一行ずつ展開してみる - save_dictionary('random')
まずはsave_dictionary('random')
で何が返ってくるのか見てみましょう。実際には関数が返ってくるのですが、処理を追いかけたいのでコードにしてみます。
func1 = save_dictionary('random')
func2 = func1(_save_random)
_save_random = func2
戻り値をひとつずつバラすとこうなります。ここからはPythonに似せた擬似コードで説明してみます。まずfunc1 = save_dictionary('random')
には何が入るのでしょうか。
save_dictionary('random') == function(func): def wrapper(self, *args, **kwargs): with open(Dictionary.DICT['random'], 'w', encoding='utf-8') as f: result = func(self, *args, **kwargs) f.write(result) return result return wrapper
save_dictionary('random')
を呼び出すと、一番外側の関数である_save_dictionary
の定義が実体化します。そのためdef
ではなく、実際の処理を表すためにfunction
という識別子で区別しています。なんかJavaScriptみたいですね。lambda
でも良かったんですが、擬似コードであることを強調するためにfunction
にしています。
実体化すると何が起こるかというと、変数が値に変わります。save_dictionary
に与えた引数'random'
が、dict_key
という識別子から実体に変わり、wrapper
関数内のopen
でDictionary.DICT['random']
となります。
ここまでが確定した処理です。この処理が先ほどのfunc1
の中身ということになりますね。
一行ずつ展開してみる - func1(_save_random)
save_dictionary('random')
では、関数(実体化した処理の塊)が返ってきていました。次はfunc1(_save_random)
で何が返ってくるのか見てみましょう。一行ずつ分けたコードを再掲します。
func1 = save_dictionary('random')
func2 = func1(_save_random)
_save_random = func2
func2
には何が入るのでしょうか。ここでもやはり擬似コードを使って展開してみます。
func1(_save_random) == function(self, *args, **kwargs): with open(Dictionary.DICT['random'], 'w', encoding='utf-8') as f: result = _save_random(self, *args, **kwargs) f.write(result) return result
今度はwrapper
関数が実体化したのでfunction
に置き換わっています。_save_dictionary
の引数であったfunc
は_save_random
として実体化するので、かなり普通のPythonコードに近づいてきました。
しかし、'random'
という値はsave_dictionary
が実体化させたものです。もうsave_dictionary
から離れたのだから、元々のコードdict_key
っていう変数は存在しないんじゃないの? という疑問はもっともです。
これがクロージャと呼ばれるもので、上位(ここで言うsave_dictionary
)が一度実体化させた名前空間は、下位(ここで言う_save_dictionary
およびwrapper
)から参照できるという特徴があります。
具体的には、上記で説明したようにdict_key
はすでに'random'
に置き換わっているのでNameError
は発生しません。あまり詳しく説明すると主旨から外れてしまうので、やっぱりクロージャで調べてみてください。詳しい解説がたくさんあります。
一行ずつ展開してみる - _save_random = func2
大体わかってきました。とはいえ、ここまでの説明だと「_save_random
を呼ぶ出す関数を_save_random
に代入する」ことになって、またわけがわからなくなります。
実はwrapper
が実体化するとき、もうひとつ仕事をしています。
func1 = save_dictionary('random')
func2 = func1(_save_random)
_save_random = func2
func2
には本当は何が入るのか、調べてみましょう。
func2 == function(self): with open(Dictionary.DICT['random'], 'w', encoding='utf-8') as f: result = function(self): return '\n'.join(self.random) f.write(result) return result
実はfunc
(_save_random
)の呼び出し時に、その中身も展開して処理に埋め込んでしまうのです。厳密に言うと違うんですが、無名関数とクロージャを使って似たようなことを実現させています。
つまりどういうことかと言うと、「オリジナルの_save_random
も展開して処理に組み込む」ので、新しい_save_random
に代入しても無限ループになったりしない、という感じです。
デコレータの定義で使われている
@functools.wraps(func)
もまたデコレータです。これはfunc
のオリジナル(_save_random
など)のdocstringやメソッド名が上書きされないようにする「おまじない」です。詳しくは検索するか、functools
のソースを読んでみることをお勧めします。
最終的な _save_random
デコレータは「関数を上書きする」ものだということがわかりました。というわけで、@save_dictionary('random')
のくっついた_save_random
は実際にはどんなコードに変化しているのか確認してみます。
def _save_random(self): with open(Dictionary.DICT['random'], 'w', encoding='utf-8') as f: result = '\n'.join(self.random) f.write(result) return result
あれだけ複雑な展開処理があったにも関わらず、最終的なコードはこじんまりしていますね。デコレータに限らず、「汎用的に使える機能は様々な用途に対応するためにコードが長くなりがち」という(私の勝手に作った)法則があります。
Pythonでは[1, '2', (3,)]
のような要素の型が違うリストを簡単に作れてしまいますが、C言語レベルで考えるとどれだけ複雑な処理がされているのか、想像するだけで震えがきます。そういったものの積み重ねで便利な機能を実現させているので、「デコレータって書くの大変だけど便利だね」くらいの認識でいいと思います。
まとめ
このデコレータのアプローチが正しいかどうかはともかく、デコレータの定義方法と内部的に何が起こっているのかは理解できました。デコレータについてそれなりに検索してもみたのですが、Python界隈は日本語の詳しい説明がなかなか見つからないので、最終的にはEnglishでGooglingをDoした始末。
前のブログでRubyの記事を書いているときは凄まじい速度で情報が劣化していくので疲れましたが、Pythonは比較的静かなコミュニティなのに日本語情報が少なくてもったいない気がします。
このブログにはくどい説明と間違っているかもしれない解釈が含まれていますが、ツッコミはPythonistaに任せて気ままに書いていきます。