何かを書き留める何か

数学や読んだ本について書く何かです。最近は社会人として生き残りの術を学ぶ日々です。

『Effective Python』Item 31: 再利用可能な@propertyメソッドにはディスクリプタを使おう

『Effective Python』の続き。@propertyの限界を超えろ。 www.effectivepython.com

@propertyデコレータの欠点として再利用ができないというのがある。 テストの成績を管理するクラスを例に説明している。

class Exam(object):
    def __init__(self):
        self._writing_grade = 0
        self._math_grade = 0

    @staticmethod
    def _check_grade(value):
        if not (0 <= value <= 100):
            raise ValueError('Grade must be between 0 and 100')


@property
def writing_grade(self):
    return self._writing_grade

@writing_grade.setter
def writing_grade(self, value):
    self._check_grade(value)
    self._writing_grade = value

@property
def math_grade(self):
    return self._math_grade

@math_grade.setter
def math_grade(self, value):
    self._check_grade(value)
    self._math_grade = value

このように科目ごとに@propertyを用意する必要があり、面倒である。

そこで、ディスクリプタを使って解消しようという話である。

class Grade(object):
    def __init__(self):
        self._value = 0

    def __get__(self, instance, instance_type):
        return self._value

    def __set__(self, instance, value):
        if not (0 <= value <= 100):
            raise ValueError('Grade must be between 0 and 100')
        self._value = value

class Exam(object):
    math_grade = Grade()
    writing_grade = Grade()
    science_grade = Grade()

とすると 一見 上手く動作するように見えるが、

first_exam = Exam()
first_exam.writing_grade = 82
first_exam.science_grade = 99
print('Writing', first_exam.writing_grade)
print('Science', first_exam.science_grade)

second_exam = Exam()
second_exam.writing_grade = 75
print('Second', second_exam.writing_grade, 'is right')
print('First ', first_exam.writing_grade, 'is wrong')
Writing 82
Science 99
Second 75 is right
First  75 is wrong

のようにクラス属性を使いまわしてしまい、期待していない動作をしてしまう。

じゃあどうするかというと、Examインスタンスを科目ごとに管理すればよい。

class Grade(object):
    def __init__(self):
        self._values = {}

    def __get__(self, instance, instance_type):
        if instance is None: return self
        return self._values.get(instance, 0)

    def __set__(self, instance, value):
        if not (0 <= value <= 100):
            raise ValueError('Grade must be between 0 and 100')
        self._values[instance] = value

これで無事動作するが、self._values = {}インスタンスへの参照が残ってしまいメモリリークが生じてしまうと指摘している。 そこで、weakrefモジュールを使って解決しましょうと提案している。

weakrefモジュールの簡単な解説はここにある。 11. 標準ライブラリミニツアー – その 2 — Python 3.4.3 ドキュメント

Python は自動的にメモリを管理します (ほとんどのオブジェクトは参照カウント方式で管理し、ガベージコレクション(garbage collection)で循環参照を除去します)。オブジェクトに対する最後の参照がなくなってしばらくするとメモリは解放されます。

このようなアプローチはほとんどのアプリケーションでうまく動作しますが、中にはオブジェクトをどこか別の場所で利用している間だけ追跡しておきたい場合もあります。残念ながら、オブジェクトを追跡するだけでオブジェクトに対する恒久的な参照を作ることになってしまいます。 weakref モジュールでは、オブジェクトへの参照を作らずに追跡するためのツールを提供しています。弱参照オブジェクトが不要になると、弱参照 (weakref) テーブルから自動的に除去され、コールバック関数がトリガされます。弱参照を使う典型的な応用例には、作成コストの大きいオブジェクトのキャッシュがあります。

なお、『Python文法詳解』の「7.6 ディスクリプタ」には

ディスクリプタは、__get__()というメソッドを持つオブジェクトで、属性値としてディスクリプタが参照されると、このメソッドが呼び出され、戻り値の属性の値として返します。

とある。

また、ディスクリプタには「データディスクリプタ」と「非データディスクリプタ」があり、違いは__set__()及び__detele__()を実装しているかで、実装してあれば(使わないにしても)データディスクリプタとなる。

また、『Python文法詳解』の「7.6 ディスクリプタ」には

インスタンスから属性値をobj.ATTRとして取得するとき、属性値が普通のオブジェクトか非データディスクリプタならば、値を次の順序で検索します。

インスタンス名前空間 -> クラスの名前空間

属性値がデータディスクリプタの場合は、次の順序で検索されます。

クラスの名前空間 -> インスタンス名前空間

インスタンスに、クラスのデータディスクリプタと同盟の属性が登録されていても、常にデータディスクリプタが優先的に使用されます。 この機能を実現するために、インスタンスから属性値を取得するときは、最初に必ずクラスの名前空間を検索し、データディスクリプタが見つからなかった時のみ、 インスタンス名前空間を検索するようになっています。

とある。 先程の 一見 上手く動作する実装は、ディスクリプタのこの振る舞いによって引き起こされたと思われる。

こう、『Python文法詳解』や『プログラミング言語C』や『Cリファレンスマニュアル』のような本はこのようなときに役に立つ。 何故か上手くいかないからこうしましょう、と言われても納得できないときはこのようなリファレンスが便利である。