すなぶろ

フリーランスのWebエンジニアがPythonを中心にプログラミング情報を紹介していきます。Rubyも書きますが、今はPython勉強中。一緒にPythonista目指しましょう!

Pythonのデコレータをイチから展開・解説してみた 【あれ? ただのクロージャじゃん】編

f:id:sandmark:20171016071130j:plain

前回のコードは思い返すだけでも寒気がするほどひどいものでした。そこで今回はPython固有の機能であるデコレータを使って、ちょっとPythonっぽいコードに変更します。

sandmark.hateblo.jp

このデコレータ、LispHaskellのように関数や処理を頻繁に渡す言語に慣れ親しんだ人なら比較的すぐに理解できると思うのですが、そうでない場合は難しく感じるかもしれません。しかし理解してしまえば「なんだそんなことか」といったものなので、もちろん乱用は禁物ですが、適宜使っていくことで明らかに読みやすく宣言的なコードを書くことができます。参考: PEP 318


再掲:リファクタリング対象

「デコレータとはなんぞや」というのは検索すればいくらでも出てきますので、今回ハマった点について書いていきます。また、デコレータが行うこと、行った結果関数がどう変更されるか、も書いてみようかと。

ではリファクタリング前のdictionary.pysaveメソッド達です。

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関数内のopenDictionary.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に任せて気ままに書いていきます。