Python魔法方法漫游指南:描述符
3142 views, 2023/10/17 updated Go to Comments
描述符是 Python 语言中一个强大的特性,它隐藏在编程语言的底层,为许多神奇的魔法提供了动力。
如果你认为它只是个花里胡哨、且不太能用到的高级主题,那么本文将帮助你了解为什么描述符是一个非常有意思、并且让代码变简洁的优雅工具。
一个例子
在探讨枯燥的理论前,让我们从一个简单的例子来了解描述符。
某日,假设你需要一个类,来记录数学考试的分数。
这个需求非常简单,你10秒钟就写好了代码:
class Score:
def __init__(self, math):
self.math = math
但是稍后你就发现了问题:分数为负值是没有意义的。
但显然上面的代码对输入参数没有任何检查:
>>> score = Score(-90)
>>> score.math
-90
因此你修改代码,使得初始化时检查输入值:
class Score:
def __init__(self, math):
if math < 0:
raise ValueError('math score must >= 0')
self.math = math
但这样也没解决问题,因为分数虽然在初始化时不能为负,但后续修改时还是可以输入非法值:
>>> score = Score(90)
>>> score.math
90
>>> score.math = -100
>>> score.math
-100
幸运的是,有内置装饰器 @property
可以解决此问题。
如果你以前没用过 @property
,下面就是个例子:
class Score:
def __init__(self, math):
self.math = math
@property
def math(self):
# self.math 取值
return self._math
@math.setter
def math(self, value):
# self.math 赋值
if value < 0:
raise ValueError('math score must >= 0')
self._math = value
试验下:
>>> score = Score(90)
>>> score.math
90
>>> score.math = 10
>>> score.math
10
>>> score.math = -10
Traceback (most recent call last):
File "...", line 20, in math
raise ValueError('math score must >= 0')
ValueError: math score must >= 0
简单来说就是 @property
接管了对 math
属性的直接访问,而是将对应的取值赋值转交给 @property
封装的方法。
虽然 @property
已经表现得比较完美了,但是它最大的问题是不能重用。
如果要同时保存数学、英语、生物三门课程的成绩,这个类就会变成这样:
class Score:
def __init__(self, math, english, bio):
self.math = math
self.english = english
self.bio = bio
@property
def math(self):
return self._math
@math.setter
def math(self, value):
if value < 0:
raise ValueError('math score must >= 0')
self._math = value
@property
def english(self):
return self._english
@english.setter
def english(self, value):
if value < 0:
raise ValueError('english score must >= 0')
self._english = value
@property
def bio(self):
return self._bio
@bio.setter
def bio(self, value):
if value < 0:
raise ValueError('bio score must >= 0')
self._bio = value
虽然外部调用时依然简洁,但掩盖不了类内部的臃肿。
描述符就可以很好的解决上面的代码重用问题。
描述符这个词听起来很玄乎,其实就是实现了魔法方法 __get__
、 __set__
、 __delete__
的类(根据需求,可以只实现其中一部分方法,不一定三个都实现)。一但实现了描述符协议,那么这个类就具有非常强大的特性了。
比如上面这个检查非负的需求,写成描述符类就是这样:
class NonNegative:
"""检查输入值不能为负"""
def __init__(self, name):
self.name = name
def __get__(self, instance, owner=None):
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
if value < 0:
raise ValueError(f'{self.name} score must >= 0')
instance.__dict__[self.name] = value
里面的细节后面会讲到,现在你只需要注意以下几点:
- 它实现了
__get__
用于取值,也实现了__set__
用于赋值。因此它是一个描述符类。 - 在
__set__
中对输入值value
进行了检查,确保非负。
像这样来使用描述符:
class Score:
math = NonNegative('math')
english = NonNegative('english')
bio = NonNegative('bio')
def __init__(self, math, english, bio):
self.math = math
self.english = english
self.bio = bio
现在,math
、english
、 bio
三个属性均被描述符接管。也就是说,对它们进行点符的访问实际上会执行描述符类中对应的 __get__
、 __set__
方法。
试试其功能,与 @property
是类似的:
>>> score.math = 10
>>> score.math
10
>>> score.math = -10
Traceback (most recent call last):
File "...", line 1, in <module>
score.math = -10
ValueError: math score must >= 0
功能虽然相同,但是 Score
类的定义明显清爽了不少。
描述符类型
在开始讨论本节之前,让我们先回顾一点基础知识。
Python 的类具有一个特殊的字典叫 __dict__
,它被称作命名空间,说白了就是一个存放对象所有属性的字典。
对属性的引用被解释器转换为对该字典的查找,比如 a.x
相当于 a.__dict__['x']
。看下面的例子:
class Foo:
def __init__(self):
self.a = 10
self.b = 20
foo = Foo()
print(foo.__dict__)
# {'a': 10, 'b': 20}
foo.__dict__['c'] = 30
print(foo.__dict__)
# {'a': 10, 'b': 20, 'c': 30}
print(foo.c)
# 30
可以看到在程序运行期间,你可以动态的向 __dict__
中插入新的值,使得对象具有新的属性。
了解完这个,我们再回到描述符的话题。
描述符可以用一句话概括:描述符是可重用的属性,它把函数调用伪装成对属性的访问。
描述符可以只实现 __get__
方法:
class Ten:
"""非数据描述符"""
def __get__(self, instance, owner=None):
print(self)
print(instance)
print(owner)
return 10
class Foo:
"""应用了描述符的类"""
ten = Ten()
foo = Foo()
print(foo.ten)
# 输出:
# <__main__.Ten object at 0x0000023B4B074EB0>
# <__main__.Foo object at 0x0000023B4B074940>
# <class '__main__.Foo'>
# 10
__get__
方法中有三个参数:
self
:描述符实例instance
:描述符所附加的对象的实例owner
:描述符所附加的对象的类型
这种只实现 __get__
方法的叫做非数据描述符。
如果描述符定义了 __set__
或者 __delete__
,则被叫做数据描述符。比如:
class Five:
"""数据描述符"""
def __get__(self, instance, owner=None):
return 5
def __set__(self, instance, value):
raise AttributeError('Cannot change this value')
__set__
方法中也有三个参数:
self
:描述符实例instance
:描述符所附加的对象的实例value
:当前准备赋的值
数据描述符和非数据描述符不仅仅是名字上的区别,更重要的是在查找链上的位置不同。
当访问对象的某个属性时,其查找链简单来说就是:
- 首先在对应的数据描述符中查找此属性。
- 如果失败,则在对象的
__dict__
中查找此属性。 - 如果失败,则在非数据描述符中查找此属性。
- 如果失败,再去别的地方查找。(本文就不展开了)
问题来了:根据以上查找规则,上面定义的两个描述符 Ten
和 Five
,哪个能作为只读属性?
答案是 Five
。
由于 Ten
没有设置 __set__
方法,因此对属性的赋值和取值会被对象的 __dict__
的属性所覆盖:
class Ten:
def __get__(self, instance, owner=None):
print('calling __get__')
return 10
class Foo:
ten = Ten()
foo = Foo()
print(foo.ten)
# calling __get__
# 10
foo.ten = 20
print(foo.ten)
# 20
但是由于数据描述符的查找要早于对象的 __dict__
,因此拦截了对属性的访问:
class Five:
def __get__(self, instance, owner=None):
print('calling __get__')
return 5
def __set__(self, instance, value):
raise AttributeError('Cannot change this value')
class Bar:
five = Five()
bar = Bar()
print(bar.five)
# calling __get__
# 5
bar.five = 20
# Traceback (most recent call last):
# File "...", line 23, in __set__
# raise AttributeError('Cannot change this value')
# AttributeError: Cannot change this value
共享陷阱
描述符有一个非常迷惑人的特性:在同一个类中每个描述符仅实例化一次,也就是说所有实例共享该描述符实例。
看下面这个例子就明白了:
class NonNegative:
"""检查输入值不能为负"""
def __get__(self, instance, owner=None):
return self.value
def __set__(self, instance, value):
if value < 0:
raise ValueError(f'{self.name} score must >= 0')
# 数据被绑定在描述符实例上
# 由于描述符实例是共享的
# 因此数据也只有一份被共享
self.value = value
class Score:
math = NonNegative()
def __init__(self, math):
self.math = math
score_1 = Score(10)
score_2 = Score(20)
# 所有对象共享同一个描述符实例
print(score_1.math, score_2.math)
# 输出: 20 20
score_1.math = 30
print(score_1.math, score_2.math)
# 输出: 30 30
修改某个实例的值后,所有实例跟着一起改变了。这通常不是你想要的结果。
要破除这种共享状态,比较好的解决方式是将数据绑定到使用描述符的对象实例上,就像本文开头的例子所做的那样:
class NonNegative:
"""检查输入值不能为负"""
def __init__(self, name):
self.name = name
def __get__(self, instance, owner=None):
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
if value < 0:
raise ValueError(f'{self.name} score must >= 0')
# 数据被绑定在描述符附加的对象上
# 因此保持了对象之间的数据隔离
instance.__dict__[self.name] = value
class Score:
math = NonNegative('math')
def __init__(self, math):
self.math = math
唯一有些不爽的是,为了给数据属性规定一个名字,在定义描述符的时候 NonNegative('math')
还得传递 math
这个名字进去,有点多此一举。
幸好 Python 3.6 为描述符引入了 __set_name__
方法,现在你可以这样:
class NonNegative:
# 注意这里
# __init__ 也没有了
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner=None):
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
if value < 0:
raise ValueError(f'{self.name} score must >= 0')
instance.__dict__[self.name] = value
class Score:
# NonNegative() 不需要带参数以规定属性名了
math = NonNegative()
def __init__(self, math):
self.math = math
应用场景
上面关于赋值检查的 NonNegative
已经展示描述符的其中一种用途了:托管属性并复用代码,保持简洁。
接下来看看另外一些描述符的典型应用场景。
缓存
假设你有一个耗时很长的操作,需要缓存其计算结果以便后续直接使用(而不是每次都傻乎乎的重新计算)。
描述符就可以实现这个缓存功能:
class Cache:
"""缓存描述符"""
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, instance, owner=None):
instance.__dict__[self.name] = self.func(instance)
return instance.__dict__[self.name]
from time import sleep
class Foo:
@Cache
def bar(self):
sleep(5)
return 'Just sleep 5 sec...'
foo = Foo()
# 第一次执行耗时约5秒
print(foo.bar)
# 第二次执行瞬间返回
print(foo.bar)
让我们花点时间看看到底发生了什么。
这个缓存功能得以实现的原因,还是在于 Cache
是个非数据描述符,还记得吗?非数据描述符的查找顺序要晚于 __dict__
,因此使得附加描述符的对象有机会在 __dict__
中写入数据,从而覆盖掉描述符中的耗时运算。
如果给
Cache
增加__set__
方法,还能实现缓存能力吗?欢迎自行尝试,并在评论区告诉我。
其次,这里以装饰器的形式应用了描述符。读过我的旧文装饰器入门的读者都知道,装饰器就是语法糖。
上面这个装饰器:
@Cache
def bar(self):
...
等效于下面这句:
bar = Cache(bar)
因此完成了描述符的定义(同时将方法转化成了属性),并且将原函数 bar
传递给了描述符的参数 func
。
验证器
让我们看看官方文档给出的例子,如何用描述符实现一个规范的验证器。
首先定义一个仅具有基础功能的验证器抽象基类:
from abc import ABC, abstractmethod
class Validator(ABC):
"""验证器抽象基类"""
def __set_name__(self, owner, name):
self.private_name = '_' + name
def __get__(self, instance, owner=None):
return getattr(instance, self.private_name)
def __set__(self, instance, value):
self.validate(value)
setattr(instance, self.private_name, value)
@abstractmethod
def validate(self, value):
pass
Validator
描述符类定义了 validate
方法,用于子类覆写以执行具体的验证逻辑。__get__
和 __set__
表明这是类是数据描述符。
写好这个基类,接下来就可以写实际用到的验证器子类了。
比如写两个子类:
class OneOf(Validator):
"""字符串单选验证器"""
def __init__(self, *options):
self.options = set(options)
def validate(self, value):
if value not in self.options:
raise ValueError(f'Expected {value!r} to be one of {self.options!r}')
class Number(Validator):
"""数值类型验证器"""
def validate(self, value):
if not isinstance(value, (int, float)):
raise TypeError(f'Expected {value!r} to be an int or float')
OneOf
用于确保输入值为固定的某种类型。Number
用于确保输入值必须为数值型。它们均以 Validator
为父类,并实现了 validate
方法。
像这样使用它们:
class Component:
kind = OneOf('wood', 'metal', 'plastic')
quantity = Number()
def __init__(self, kind, quantity):
self.kind = kind
self.quantity = quantity
实际操作试试效果:
>>> Component('abc', 100)
# 失败,'abc' 不在选择范围中
ValueError: Expected 'abc' to be one of {'metal', 'plastic', 'wood'}
>>> Component('wood', 'notNum')
# 失败,'notNum' 不是数值型
TypeError: Expected 'notNum' to be an int or float
>>> Component('wood', 100)
# 成功,参数均合法
Out[25]: <__main__.Component at 0x13df8059640>
再试试赋值:
>>> c = Component('wood', 100)
>>> c.kind = 'abc'
ValueError: Expected 'abc' to be one of {'metal', 'plastic', 'wood'}
>>> c.kind
'wood'
>>> c.kind = 'metal'
>>> c.kind
'metal'
>>> c.quantity = 'haha'
TypeError: Expected 'haha' to be an int or float
>>> c.quantity = 20
>>> c.quantity
20
很顺利的实现了验证器的功能。
学过 Django 的同学看着眼熟不,是不是有点 Django 中的验证器和字段的意思了?除此之外,很多底层的功能都可以用描述符进行纯 Python 的实现,比如属性、方法、静态方法、类方法等等。
完整例子见文档描述符指南。
总结
通过本文,你应该已经感受到描述符的强大功能,并且大致明白应该在哪些场合运用它了:
- 描述符就是可复用的属性,它将函数调用伪装成对属性的访问。
- 数据描述符和非数据描述符,在查找链中位于不同的优先级。
- 描述符在属性托管、缓存和验证器等场景下应用较为常见。
没骗你吧,描述符绝对是个很有意思的特性,也不是炫技用的花拳绣腿。合理运用,可以让你的代码简洁而优雅。
参考
本系列文章开源发布于 Github,传送门:Python魔法方法漫游指南
看完文章想吐槽?欢迎留言告诉我!