编写高质量代码:用描述符来改写需要复用的 @property 方法

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__的方法具体运作细节。
© 版权声明

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
none
暂无评论...