连载的上两篇文章,介绍了 @property
的使用,利用 @property
装饰器和 setter
方法可以在设置实例属性时,进行参数验证:
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('Grade must be between 0 and 100')
self._grade = value
运行结果:
>> mia = Homework()
>> mia.grade = 95
>> mia.grade
95
>> mia.grade = 101
ValueError: Grade must be between 0 and 100
现在假设我们希望把这套验证逻辑同样也放到考试上面,考试包含了多个科目,每个科目单独计分,于是我们很容易定义出下面的类来:
class Exam:
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():
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
def __repr__(self):
return f'Exam(writing_grade={self._writing_grade}, math_grade={self._math_grade})'
上述定义的 Exam
类真的好枯燥,篇幅也很长,而且后面每增加一个科目,就要重写一次 @property
方法,而且相关的验证逻辑也要重做一遍。并且,上述的做法也不够通用,如果要在家庭作业和考试之外的场合,使用这套百分制的验证逻辑,那就需要反复编写例行的 @property
和 _check_grade
方法。
为此,Python 提供了描述符,用于改写需要复用的 @property
方法。
第一次尝试:使用描述符重构
描述符类提供了 __set__
和 __get__
方法,使得开发者无需再编写例行代码,即可复用百分制分数验证逻辑。
exam_grade_v1.py
代码:
class Grade:
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:
math_grade = Grade()
writing_grade = Grade()
science_grade = Grade()
def main():
mia = Exam()
mia.writing_grade = 98
mia.math_grade = 99
print(f'mia exam grades: math={mia.math_grade}, writing={mia.writing_grade}')
bob = Exam()
print(f'bob exam grades: math={bob.math_grade}, writing={bob.writing_grade}')
if __name__ == '__main__':
main()
重构之后的 Exam
类只需要编写几个 Grade
实例充当的类属性,而 Grade
类则实现了描述符协议 。在讨论运行结果之前,我们首先需要了解的是:在程序访问到 Exam
实例的描述符属性时,Python 会对这种访问操作进行转译:
为属性赋值时:
>> exam = Exam()
>> exam.writing_grade = 98
Python 会将代码转译为:
>> Exam.__dict__['writing_grade'].__set__(exam, 100)
>> exam.writing_grade
100
获取属性时:
>> exam.writing_grade
100
Python 也会将其转译为:
>> Exam.__dict__['writing_grade'].__get__(exam, Exam)
100
之所以会有这样的转译,是因为 Exam
实例在找不到名为 writing_grade
的实例属性时,那么 Python 就会转向 Exam 类,并在该类中查找同名的类属性。这个类属性如果实现了 __get__
和 __set__
方法的对象,那么 Python 就认为此对象遵从描述符协议。
尝试一的运行结果:
然而,运行结果并不符合我们的预期:米娅的数学和写作成绩分别是 99 和 98 分是我们设置的,但鲍勃的成绩没有做任何设置,应该默认为 0 ,但却和米娅共用了成绩。
这是因为所有的 Exam
实例都共享同一份 Grade
实例。表示 writing_grade
属性的 Grade
实例作为类属性,只会在程序的生命周期中构建一次。即:当程序定义 Exam
类时,它会把 Grade
实例构建好,以后创建 Exam
实例时,就不再构建 Grade
了。
第二次尝试:在描述符类中用字典保存每个实例的状态
为了解决上述问题,我们需要把每个 Exam
实例所对应的值记录到 Grade
中。
exam_grade_v2.py
代码:
class Grade:
def __init__(self):
self._values = {}
def __get__(self, instance, instance_type):
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
class Exam:
math_grade = Grade()
writing_grade = Grade()
science_grade = Grade()
def main():
mia = Exam()
mia.writing_grade = 98
mia.math_grade = 99
print(f'mia exam grades: math={mia.math_grade}, writing={mia.writing_grade}')
bob = Exam()
print(f'bob exam grades: math={bob.math_grade}, writing={bob.writing_grade}')
bob.math_grade = 100
print(Exam.__dict__['math_grade']._values)
del bob
print(Exam.__dict__['math_grade']._values)
if __name__ == '__main__':
main()
运行结果:
尝试二虽然能够正常运行,但会造成内存泄露。从运行结果也不难看出,再删除 del bob
引用后,创建的 Exam
实例并没有得到释放,因为 Grade
实例的 _values
字典中依旧引用了这个 Exam
实例。
总得来说,就是在程序的声明周期中,对于传给 __set__
方法的每个 Exam
实例来说,_values
字典都会保存指向该实例的一份引用。这将导致该实例的引用计数无法降为 0 ,从而使垃圾回收机制无法将其回收。
第三次尝试:使用 WeakKeyDictionary
保证描述符类不会内存泄露
Python 内置的 weakref
模块提供的 WeakKeyDictionary
特殊字典具有:如果运行期系统发现这种字典所持有的引用,是整个程序里面指向 Exam
实例的最后一份引用,那么,系统将会自动将该实例从字典的键中移除。
exam_grade_v3.py
代码:
from weakref import WeakKeyDictionary
class Grade:
def __init__(self):
self._values = WeakKeyDictionary()
def __get__(self, instance, instance_type):
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
class Exam:
math_grade = Grade()
writing_grade = Grade()
science_grade = Grade()
def main():
mia = Exam()
mia.writing_grade = 98
mia.math_grade = 99
print(f'mia exam grades: math={mia.math_grade}, writing={mia.writing_grade}')
bob = Exam()
print(f'bob exam grades: math={bob.math_grade}, writing={bob.writing_grade}')
bob.math_grade = 100
print(dict(Exam.__dict__['math_grade']._values))
del bob
print(dict(Exam.__dict__['math_grade']._values))
if __name__ == '__main__':
main()
运行结果:
如果想复用 @property
方法及其验证机制,那么可以自定义描述符类。需要注意的是,请使用 WeakKeyDictionary
记录使用描述符类的实例,否则描述符类将会导致内存泄露的风险。