Python 属性 ~ 描述符类

连载的上两篇文章,介绍了 @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 记录使用描述符类的实例,否则描述符类将会导致内存泄露的风险。

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容