Python内置的 @property 装饰器,有个明显的缺点,就是不便于复用。受它修饰的这些方法,无法为同一个类中的其他属性所复用,而且,与之无关的类,也无法复用这些方法。
例如,要编写一个类,来验证学生的家庭作业成绩都处在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(
'Grade must be between 0 and 100')
self._grade = value
由于有了@property,所以上面这个类用起来超级简单。
galileo = Homework()
galileo.grade = 95
目前,假设要把这套验证逻辑放在考试成绩上面,而考试成绩又是由多个科目的小成绩组成的,每一科都要单独计分。
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')
Exam类的代码写起来超级枯燥,由于每添加一项科目,就要重复编写一次@property方法,而且还要把相关的验证逻辑也重做一遍。
@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 代码和 _check_grade 方法。
还有一种方式能够更好地实现上述功能,那就是采用 Python 的描述符(descriptor)来做。Python 会对访问操作进行必定的转译,而这种转译方式,则是由描述符协议来确定的。描述符类可以提供__get__和__set__方法,使得开发者无需再编写例行代码,即可复用分数验证功能。由于描述符能够把同一套逻辑运用在类中的不同属性上面,所以从这个角度来看,描述符也要比mix-in好一些。
下面定义了名为Exam的新类,该类将几个Grade实例用作自己的类属性。Grade类实现了描述符协议。
class Grade:
def __get__(self, instance, instance_type):
...
def __set__(self, instance, value):
...
class Exam:
# Class attributes
math_grade = Grade()
writing_grade = Grade()
science_grade = Grade()
在解释Grade类是如何工作的之前,了解Python在测试实例上访问这些描述符属性时会做什么是很重大的。
为属性赋值时:
exam = Exam()
exam.writing_grade = 40
Python会将代码转译为:
Exam.__dict__['writing_grade'].__set__(exam, 40)
而获取属性时:
exam.writing_grade
Python 也会将其转译为:
Exam.__dict__['writing_grade'].__get__(exam, Exam)
之所以会有这样的转译,关键就在于object类的__getattribute__方法。简单来说,如果Exam实例没有名为writing_grade的属性,那么Python就会转向Exam类,并在该类中查找同名的类属性。这个类属性,如果是实现了__get__和__set__方法的对象,那么Python就认为此对象遵从描述符协议。
清楚了这种转译方式之后,我们可以先按照下面这种写法,试着把Homework类里面的@property分数验证逻辑,改用Grade描述符来实现。
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
不幸的是,上面这种实现方式是错误的,它会导致不符合预期的行为。在同一个Exam实例上面多次操作其属性时,尚且看不出错误。
class Exam:
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)
>>>
Writing 82
Science 99
但是,如果在多个Exam实例上面分别操作某一属性,那就会导致错误的结果。
Click here to view code image
second_exam = Exam()
second_exam.writing_grade = 75
print(f'Second {second_exam.writing_grade} is right')
print(f'First {first_exam.writing_grade} is wrong; '
f'should be 82')
>>>
Second 75 is right
First 75 is wrong; should be 82
产生这种问题的缘由是:对于writing_grade这个类属性来说,所有的Exam实例都要共享同一份Grade实例。而表明该属性的那个Grade实例,只会在程序的生命期中构建一次,也就是说:当程序定义Exam类的时候,它会把Grade实例构建好,后来创建Exam实例时,就不再构建Grade了。
为了解决此问题,我们需要把每个Exam实例所对应的值记录到Grade中。下面这段代码,用字典来保存每个实例的状态。
class Grade:
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
上面这种实现方式很简单,而且能够正确运作,但它依旧有个问题,那就是会泄漏内存。在程序的生命期内,对于传给set方法的每个Exam实例来说,_values字典都会保存指向该实例的一份引用。这就导致该实例的引用计数无法降为0,从而使垃圾收集器无法将其回收。
使用Python内置的weakref模块,即可解决此问题。该模块提供了名为WeakKey-Dictionary的特殊字典,它可以取代_values原来所用的普通字典。WeakKeyDictionary的特殊之处在于:如果运行期系统发现这种字典所持有的引用,是整个程序里面指向Exam实例的最后一份引用,那么,系统就会自动将该实例从字典的键中移除。Python会做好相关的维护工作,以保证当程序不再使用任何Exam实例时,_value字典会是空的。
from weakref import WeakKeyDictionary
class Grade:
def __init__(self):
self._values = WeakKeyDictionary()
def __get__(self, instance, instance_type):
...
def __set__(self, instance, value):
...
改用WeakKeyDictionary来实现Grade描述符,即可令程序的行为符合我们的需求。
class Exam:
math_grade = Grade()
writing_grade = Grade()
science_grade = Grade()
first_exam = Exam()
first_exam.writing_grade = 82
second_exam = Exam()
second_exam.writing_grade = 75
print(f'First {first_exam.writing_grade} is right')
print(f'Second {second_exam.writing_grade} is right')
>>>
First 82 is right
Second 75 is right
要点
- 如果想复用@property方法及其验证机制,那么可以自己定义描述符类。
- WeakKeyDictionary可以保证描述符类不会泄漏内存。
- 通过描述符协议来实现属性的获取和设置操作时,不要纠结于__getattribute__的方法具体运作细节。


