cover_effective_python

【 Effective Python, 2nd Edition 】デスクリプタ ( descriptor ) を利用して @property で行っていた属性値への操作を再利用できるようにしよう! 投稿一覧へ戻る

Tags: Python , Effective , property , descriptor

Published 2020年7月16日21:55 by T.Tsuyoshi

@property は属性値のちょっとした操作、変更に非常に便利ですが、最大の問題点は再利用性です。


当然ながら @property のメソッドの対象はクラス内のただ1つの属性であって、複数の属性に同じ操作を適用することはできませんし、ましてや、異なるクラスで再利用することなどできません。


例えば、生徒が宿題の自主採点をして提出するためのクラスを作成し、入力された点数が 0 点から 100 点の間であるかを検証する機能を実装したとします。


class HomeWork:
def __init__(self):
self._grade = 0

@property
def grade(self):
return self._grade

@grade.setter
def grade(self, value):
if not (0 <= value <= 100):
raise ValueError(f"値は 0 から 100 の間でなければいけません: value={value}")
self._grade = value



@property の setter メソッドを利用することでクラスの属性への代入時に検証が行われるため非常に簡潔な実装になっていますし、使い勝手も良好、先生も手間無しです。


nana = HomeWork()
nana.grade = 90



これで味をしめた先生は、本試験でも生徒に自己採点をさせて提出させるために他のクラスを作成しました。
今回は本試験用ですから、1つのクラスで複数の教科に対応させました。


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

@staticmethod
def _check_grade(value):
if not (0 <= value <= 100):
raise ValueError(f"値は 0 から 100 の間でなければいけません: value={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
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 science_grade(self):
return self._science_grade

@science_grade.setter
def science_grade(self, value):
self._check_grade(value)
self._science_grade = value



果たしてこの実装方法はどうでしょう?
見るからに冗長です。


教科 ( == 属性) が増えるたびにその属性に対する @property を記述し、検証と代入を行うために setter メソッドを実装します。
しかも、例えば期末試験用のクラスを別に作成しようとしたら、_check_grade() メソッドも含めて全てを実装し直さなければなりません。


では、検証を行う機能を Mixin クラスとして提供すればどうでしょう?


class GradeCheckMixin:
def check_grade(self, value):
if not (0 <= value <= 100):
raise ValueError(f"値は 0 から 100 の間でなければいけません: value={value}")
return value


class BetterExam(GradeCheckMixin):
def __init__(self):
self._math_grade = 0
self._writing_grade = 0
self._science_grade = 0

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

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

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

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

@property
def science_grade(self):
return self._science_grade

@science_grade.setter
def science_grade(self, value):
self._science_grade = self.check_grade(value)



確かに、今後もし新たなクラスを作成する場合には GradeCheckMixin クラスから派生させれば _check_grade() メソッドを記述しなおす必要はなくなります。
ただし、教科が増えるたびに @property ボイラープレートを繰り返し記述しなければならないことに何ら変わりはありません。


このようなシチュエーションにおける Python でのより良い解決方法はデスクリプタ ( descriptor ) の利用です。


デスクリプタプロトコル ( descriptor protocol ) は属性に対するアクセス方法を定義するもので、
デスクリプタとして機能するオブジェクトでは __get__()、__set__()、__del__() メソッドの全て、もしくは、いずれかが定義されています。


逆に言えば、これら 3つのメソッドのどれかが定義されているオブジェクトは、属性へのアクセス方法を上書きする機能を持つデスクリプタである、ということです。


そして、このデスクリプタを利用することで、複数の属性に対して同じ記述を繰り返すことなく検証コードを適用することができるようになるんです。
しかも一度デスクリプタを作成すれば他のクラスでも再利用が可能です。


例えば、Exam クラスの各教科に相当する属性へのアクセスを制御するデスクリプタとして Grade クラスを作成します。


class Grade:
def __get__(self, instance, owner):
pass

def __set__(self, instance, value):
pass


class Exam:
"""クラス属性として定義しています"""
math_grade = Grade()
writing_grade = Grade()
science_grade = Grade()



この実装において Exam のインスタンスを作成し、math_grade 属性に値を代入するとします。


final_exam = Exam()
final_exam.math_grade = 80



この代入式は Python コンパイラによって次のように解釈されます。


Exam.__dict__['math_grade'].__set__(final_exam, 80)



つまり、まず最初に、Exam のインスタンスの属性辞書に 'math_grade' というキー名をもつ属性があるかどうかを調べます。


print(final_exam.__dict__)
# {}



ここでの属性はクラス属性として定義されていますからインスタンス属性の辞書には 'math_grade' というキー名は含まれていません。
そこで続いて、Exam のクラス属性辞書に 'math_grade' というキー名をもつ属性があるかどうかを調べます。


print(Exam.__dict__)
# {..., 'math_grade': <__main__.Grade object at 0x0000000002679A00>, 'writing_grade': ...}



ありました。
そこで Python はこの属性のオブジェクト ( Grade ) に __set__() が定義されているか調べ、もし定義されていれば、そこに記述されているデスクリプタプロトコルに従って処理を行う、ということになります。


属性値を取得する場合の流れも同様です。


final_exam.math_grade



という式は


Exam.__dict__['math_grade'].__get__(final_exam, Exam)



と解釈される、ということですね。


ここまででデスクリプタの動作機序は理解できたと思いますので、機能を実装してみましょう。


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

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

def __set__(self, instance, value):
if not (0 <= value <= 100):
raise ValueError(f"値は 0 から 100 の間でなければいけません: value={value}")
self._value = value


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


first_exam = Exam()
first_exam.math_grade = 90
first_exam.science_grade = 88


print(f"数学試験(第1回): {first_exam.math_grade}")
# 数学試験(第1回): 90

print(f"科学試験(第1回): {first_exam.science_grade}")
# 科学試験(第1回): 88



ちゃんと動作しています。
もし 0 から 100 の範囲外の値を代入しようとした場合はちゃんとエラーになります。


しかも、どうですか?
あれだけ目に付いていた @property ボイラープレートコードが一切無くなって嘘のようなスッキリさです。


気分がよくなった先生は第2回目の試験を行う、という暴挙に出ます。


second_exam = Exam()
second_exam.math_grade = 78


print(f"数学試験(第2回): {second_exam.math_grade}")
# 数学試験(第2回): 78



んっ、1回目よりも点数が悪くなったんじゃない? 確かめましょう。


print(f"数学試験(第1回): {first_exam.math_grade}")
# 数学試験(第1回): 78



あれっ、あれっ????
別々の Exam クラスインスタンスなのに、なぜか値が上書きされちゃってます。


まぁ、これはある意味当然といえば当然の結果です。


Exam クラスでは各属性をクラス属性として定義しましたから、属性の実体である Grade オブジェクトは Exam クラスが最初にインスタンス化されたときに一度だけ作成され、その後他の Exam インスタンスが作成されても共有されているからです。


解決策は、Grade クラスにおいて、どのインスタンスに属する値なのか、という情報を辞書で保存するようにすることです。


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

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

def __set__(self, instance, value):
if not (0 <= value <= 100):
raise ValueError(f"値は 0 から 100 の間でなければいけません: value={value}")
self._values[instance] = value



ただこの実装方法には欠点があります。


それは、Grade クラスの _values 辞書オブジェクトで、__set__ メソッドを経由した Exam クラスインスタンスへの参照をプログラムの実行中ずーっと保持し続けてしまっている、ということです。


そのために、それら Exam インスタンスの参照カウントはプログラム終了まで 0 にならず、いつまでたってもガーベッジコレクション ( garbage colleciton ) によるクリーンアップ対象にならない、つまり、メモリに居残ってしまうんです。つまり、 メモリリーク ( memory leaks ) を発生させてしまうんです。


しかしこの問題の解決方法もちゃんとあるんですね。


それは弱参照 ( weak reference ) を利用することです。
弱参照についての説明はここでは省きますので、興味のある方は Python ドキュメントの weakref モジュールの項を覗いてください。


weakref モジュールには、キーを弱参照とする辞書を作成するための WeakKeyDictionary() メソッドが用意されています。
それを使えば、_values 辞書オブジェクトに含まれる Exam クラスインスタンスへの参照がプログラム内における最後の1つになった場合、そのクラスインスタンスはガーベッジコレクションの対象となってメモリが解放されるようになります。


from weakref import WeakKeyDictionary


class Grade:
def __init__(self):
self._values = WeakKeyDictionary()

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

def __set__(self, instance, value):
if not (0 <= value <= 100):
raise ValueError(f"値は 0 から 100 の間でなければいけません: value={value}")
self._values[instance] = value



この実装によって、メデタシ、一件落着です。


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


first_exam = Exam()
first_exam.math_grade = 95

second_exam = Exam()
second_exam.math_grade = 83

print(f"数学試験(第1回): {first_exam.math_grade}")
# 数学試験(第1回): 95

print(f"数学試験(第2回): {second_exam.math_grade}")
# 数学試験(第2回): 83



まとめ:

1: デスクリプタ ( descriptor ) で属性へのアクセス方法を制御して、@property で実現していた属性値の変更や検証を再利用可能にしましょう。

2: 必要であれば弱参照を利用してメモリリークを防止しましょう。

3: 今回は取り上げていませんが、デスクリプタプロトコルによる属性アクセス方法の制御の根本は __getattribute__ メソッドが担っています。興味を持った方は是非 Python ドキュメントに目を通してください。

この投稿をメールでシェアする

0 comments

コメントはまだありません。

コメントを追加する(不適切と思われるコメントは削除する場合があります)