読者です 読者をやめる 読者になる 読者になる

AttributeErrorを使ってプロパティをキャッシュすべきか?

Python

Pythonでオブジェクトのpropetyを取得する関数を書く時に、AttributeError例外を使ってコストの高い関数・メソッドの結果をキャッシュするという書き方がよく使われます。(コードA)

class AObject(object):
    def get_value(self):
        try:
            return self._value
        except AttributeError:
            self._value = a_expensive_func()
            return self._value


Djangoのコードは一過してこの書き方になっていたように思います。AttributeErrorを起こさないように書くならば次のようになるでしょう。(コードB)

class BObject(object):
    def __init__(self):
        self._value = None

    def get_value(self):
        if self._value is None:
            self._value = a_expensive_func()
        return self._value


CPythonでは「実際に例外が生じる可能性が高い場合は、try-exceptは高価」という特性があるので、上の例で言うと、あるインスタンスに対するget_value()というメソッドの呼び出し回数が多くなるほど前者の方が高速になると予想できます。
これを検証するために、次のようなコードでベンチマークをとってみます。

class AObject(object):
    def get_value(self):
        try:
            return self._value
        except AttributeError:
            self._value = 1
            return self._value

class BObject(object):
    def __init__(self):
        self._value = None

    def get_value(self):
        if self._value is None:
            self._value = 1
        return self._value

def func(cls, times):
    obj = cls()
    for x in xrange(times):
        value = obj.get_value()

if __name__ == '__main__':
    from timeit import Timer
    number = 10000
    for x in (2, 10, 100):
        print '*** %s times' % x
        print 'Using exception:', Timer('func(AObject, %d)' % x,
                    'from __main__ import AObject, func').timeit(number=number)
        print 'Using if-statement', Timer('func(BObject, %d)' % x,
                    'from __main__ import BObject, func').timeit(number=number)


結果は次のようになりました。(Python2.5.1で計測。)

*** 2 times
Using exception: 0.206912994385
Using if-statement 0.119593143463
*** 10 times
Using exception: 0.338870048523
Using if-statement 0.367202043533
*** 100 times
Using exception: 1.88616800308
Using if-statement 2.14664387703


やはり、例外が発生する割合が高い場合(2回に1回)は例外を使わない方が早く、例外が発生することがまれな場合(100回に1回)は例外を使った方が速いという結果になっています。
同じ議論は、

try:
    some_dict[KEY]
except KeyError:
    return None

と、

some_dict.get(KEY)

のどちらが速いかという点に関しても当てはまります。KeyErrorが発生することが稀であると考えるならば、try-execpt構文を使うべきです。この点ではDjangoでもそのような実装になっています。


コードBの欠点はキャッシュした結果がNoneである場合には、再度コストのかかる関数・メソッドが呼び出されてしまうことですが、次のように番兵オブジェクトを作ればその問題も解決できるはずです。

class _NotInitalized(object):
    pass
not_initalized = _NotInitalized()

class CObject(object):
    def __init__(self):
        self._value = not_initalized

    def get_value(self):
        if self._value is not_initalized:
            self._value = None
        return self._value 

このコードが平均した場合一番速いと予想したのですが、何度かベンチマークをとった結果、悪くは悪くはありませんが、思ったほどの結果が出ませんでした。