3个用例+6个实验 讲透Python Descriptor(描述符)
Introduction
众所周知,Python入门容易,但是进阶有几大难点: 元类、异步IO、描述符;今天就来说说"描述符"。
这个概念本身有点晦涩,而网上能找到的资料大多还是蜻蜓点水的讲法,导致大家对它“似懂非懂”,甚至“不明觉厉”。 实际上,用好这个语法,能够大幅改善代码的灵活度和可读性。倘若束之高阁、稍感可惜。
我综合了网上找到的几篇中英文资料、结合官方文档和样例代码,争取把这个概念一次性彻底讲透,将其打回原形——一只普普通通的轮子
注意:本文针对1年以上经验的 Python 中阶开发者;假设读者已经熟悉Python OO写法
文中提到的语法主要供 框架层、架构层的开发人员使用
文中的用例效果,大部分可以用初阶语法来近似实现,但是可维护性会差很多
初识3个用例(感性认知)这里大致感受用法即可;如有暂时不理解的地方,可以先跳过;后文有重温&详解部分
print("""\n用例1: 惰性求值""")
class LazyProperty: # 这是一个 描述符类的定义
... # 描述符 通常视为 架构层的代码
class DeepThought: # 业务逻辑层的某个具体的类,观察一下用法
@LazyProperty
def meaning_of_life(self):
time.sleep(3) # 模拟耗时计算
return "eating"
my_deep_thought_instance = DeepThought()
print(arrow.now(), my_deep_thought_instance.meaning_of_life) # 第一次要算
print(arrow.now(), my_deep_thought_instance.meaning_of_life) # 这次直接读缓存
print(arrow.now(), my_deep_thought_instance.meaning_of_life) # 这次直接读缓存
print("""\n用例2: 参数检查""")
class PowerDesc: # 类似上面,也是个描述符类
... # 检查参数是否符合取值范围,且为2的整数次幂
class HParams:
image_width = PowerDesc()
image_height = PowerDesc()
batch_size = PowerDesc(region=(8, 256))
hparams = HParams()
hparams.image_width = 1024
hparams.image_height = 768 # 非2的幂次方, 报warning: SlowCalculation
hparams.batch_size = 8 # OK
hparams.batch_size = 4 # 取值范围外, 抛异常: ValueError
print("""\n用例3: 属性监听""")
class CallbackProperty: # 一个描述符类
... # 支持属性读写的监听
class BankAccount:
username: str
balance = CallbackProperty(0)
def __init__(self, username):
self.username = username
jack_account = BankAccount('jack')
def low_balance_warning(value, instance: BankAccount):
if instance.balance >= 100 > value: # 致贫
print(f"{instance.username} is getting poor")
if instance.balance < 100 <= value: # 脱贫
print(f"{instance.username} is getting rich")
BankAccount.balance.add_callback(low_balance_warning)
jack_account.balance = 101 # 初始0 -> 101; getting rich
jack_account.balance = 99 # 101 -> 99; getting poor
6个实验(理解语法)建议先跟着下面的介绍,快速了解一下
然后结合GitHub Gist上的完整代码,在IDE中单步调试彻底理解
实验1: 普通的类属性
这是个对照组,没有用到描述符,就是纯粹普通的python类属性
class ByClassAttrib:
r1 = 0 # 普通的类属性
r2 = 0
obj1, obj2 = ByClassAttrib(), ByClassAttrib() # 新建两个ByClassAttrib对象
obj1.r1, obj1.r2 = 3, 4 # 改动obj1中的两个类属性
# 观察obj1的属性词典、它的类的属性词典、两个属性值
"""# 发现是直接写入 `obj1.__dict__`,用实例属性覆盖类属性,不会污染其他实例obj1.__dict__ = {'r1': 3, 'r2': 4}type(obj1).__dict__ = {'r1': 0, 'r2': 0, ...}obj1.r1 = 3obj1.r2 = 4"""
# 观察obj2的...
"""obj2.__dict__ = {} # 未受污染type(obj2).__dict__ = {'r1': 0, 'r2': 0, ...}obj2.r1 = 0obj2.r2 = 0"""
实验2: 描述符 as 类属性 [重点]
描述符是个什么鬼? 参考这里,通过几个简单问题来介绍问: 什么是描述符?答: 描述符是一个Python对象。
问: 这个Python对象和普通的Python对象有什么区别?答: 只要具有 __get__(), __set__(), __delete__()方法中任意一个方法的对象就叫做描述符。
问: 描述符在功能上有什么特殊之处吗?答: 通过小数点语法访问某对象的属性时,如果该属性是个描述符,则默认属性访问规则会被 __set__, __get__, __delete__方法所覆盖。
问: 描述符有什么用?答: Python内部自带的staticmethod, classmethod, property,super等都是描述符,在很多Python库中也都有描述符的身影(例如SQLAlchemy),使用描述符能让你有更高的概率写出优美的代码、更简洁的API,并会加深对Python理解。
问: 描述符有哪些类别?答: 描述符又可分为数据描述符(data descriptor)和非数据描述符(non-data descriptor),只有__get__方法的对象被称为非数据描述符(non-data desriptor),其他的都称为数据描述符(data descriptor)
注: 个人习惯,称其为写描述符和读描述符
下面来看个例子;描述符作为类属性的用法:
class DescRead: # 一个`读描述符`类, 不含 __set__()方法
def __init__(self):
self.value = 0
def __get__(self, instance, owner):
return self.value
class DescWrite(DescRead): # 一个`写描述符`类
def __set__(self, instance, value):
assert 0 <= value <= 9, "The value is invalid"
self.value = value
class ByClassDesc:
# 这里 r1和r2都是 类属性;因访问优先级不同,行为略有区别
r1 = DescRead() # 描述符 作为 类属性
r2 = DescWrite()
obj1, obj2 = ByClassDesc(), ByClassDesc()
obj1.r1 = 3 # r1的类型是`DescRead`, 不支持`__set__()`; 所以`obj1.r1 = 3`会直接写入实例属性
obj1.r2 = 4 # r2的类型是`DescWrite`, 支持`__set__()`; 所以`ob1.r2 = 4`等价于`obj1.r2.__set__(obj2, 4)`,修改了作为类属性的r2 (而非增加实例属性)
# 观察obj1的...
"""obj1.__dict__ = {'r1': 3}type(obj1).__dict__ = {'r1': <__main__.descread object at>, 'r2': <__main__.descwrite object at>, ...} # 类的r1被obj1中的同名实例属性覆盖; r2内部的value在__set__()过程中被改动obj1.r1 = 3 # 按照访问链顺序, `写描述符`(找不到名为`r1`的写描述符) > 实例属性(命中) > `读描述符`; 所以这里轮询到`实例属性 obj1.__dict__['r1'] == 3`obj1.r2 = 4 # 顺序同理,轮询到 `写描述符`, 即 `obj1.r2.__get__(...) == 4`"""
# 观察obj2的...
"""obj2.__dict__ = {} # obj2的实例属性未受污染type(obj2).__dict__ = {'r1': <__main__.descread object at>, 'r2': <__main__.descwrite object at>, ...} # 类的r1没变; r2内部value被改动obj2.r1 = 0 # 按照访问链顺序, `写描述符`(找不到...) > 实例属性(obj2中找不到...) > `读描述符`(命中); 所以这里轮询到`读描述符 obj2.r1.__get__(...) == 0`obj2.r2 = 4 # ... 轮询到`写描述符 r2`,而这个东西是类属性(受污染),得到4"""
# 观察 属性词典取值语法 v.s. 小数点取值语法
"""# 要通过小数点访问(即通过`obj.prop`语法, 而非属性词典`obj.__dict__['prop']`)type(obj2).__dict__["r1"] = <__main__.descread object at>type(obj2).r1 = 0"""
完整的属性访问链 顺序如下:写描述符; 来自type(self)或者MRO上的任意基类定义皆可
- 注意,不能来自self(即通过__init__()定义), 详见StackOverflow
实例自己的属性词典 obj.__dict__[...]
读描述符; 来自type(self)或者MRO上的任意基类定义皆可
- 同上注意
实例所属类的属性词典 type(obj).__dict__[...]
实例所属类的基类的属性词典 type(obj).__base__.__dict__[...]
沿着MRO继承顺序上溯,重复上一步 ...
以上皆失败, 则raise AttributeError
实验3: 描述符 as 实例属性 [错误用法, 略]
class ByInstDesc: # 无效
def __init__(self):
self.r1 = DescRead()
self.r2 = DescWrite()
实验4: 通过描述符内部的字典将value与obj映射起来
实验2中,读描述符 r1不具有__set()__能力,而写描述符 r2又会导致实例间互相污染; 下面的写法,通过在描述符内部维护一个 obj -> value 的映射字典来解决这一矛盾
class DescWriteBindingDict:
def __init__(self):
self.value = weakref.WeakKeyDictionary() # 可以避免实例之间互相污染
# 如果直接用dict的话,对各实例都有强引用,阻碍GC
# 用 WeakKeyDictionary可以缓解GC问题,但是仍然有KeyError风险
def __set__(self, instance, value):
assert 0 <= value <= 9, "The value is invalid"
self.value[instance] = value # 将实例 映射到 对应的value
def __get__(self, instance, owner):
return self.value.get(instance, 0) # 根据实例查找相应的value
class ByClassDescBindingDict:
r1 = DescRead()
r2 = DescWriteBindingDict()
obj1, obj2 = ...
obj1.r1, obj1.r2 = 3, 4
# 观察obj1的...
"""obj1.__dict__ = {'r1': 3} # 同上 r1被实例属性覆盖# `写描述符 r2` 仍然被改动;但是这次内部不再是标量value,而是字典value; 每个实例对应赋值type(obj1).__dict__ = {'r1': <__main__.descread object at>, 'r2': <__main__.basic_syntax.>.DescWriteBindingDict object at 0x10d1914a8>, ...}obj1.r1 = 3 # 被实例属性覆盖obj1.r2 = 4 # type(obj1).r2.value[obj1] == 4"""
# 观察obj2的...
"""obj2.__dict__ = {} # obj2的实例属性未受污染type(obj2).__dict__ = {'r1': <__main__.descread object at>, 'r2': <__main__.basic_syntax.>.DescWriteBindingDict object at 0x10d1914a8>, ...}obj2.r1 = 0 # 轮询到类中定义的`读描述子 r1`obj2.r2 = 0 # obj2 not in type(obj1).r2.value"""
实验5: 将value直接写入obj中
上述方法已经解决了属性干扰问题,但是仍然有缺陷:用dict对GC不友好
用WeakKeyDictionary存在Key过期风险
解决方法也很简单:直接把value写入obj的实例属性字典中,跟随实例一起GC
class DescWriteBindingObj:
def __init__(self, name):
self.name = name # 注意不需要self.value, 因为值绑定到obj上
def __set__(self, instance, value):
assert 0 <= value <= 9, "The value is invalid"
instance.__dict__[self.name] = value # 值绑定到obj上
def __get__(self, instance, owner):
return instance.__dict__.get(self.name, 0) # 注意是从obj上取值
class ByClassDescBindingObj:
r1 = DescRead()
r2 = DescWriteBindingObj('r2') # 这里命名冗余
obj1, obj2 = ...
obj1.r1, obj1.r2 = 3, 4
# 观察obj1的...
"""# `读描述符 r1`同上,被同名实例属性覆盖# `写描述符 r2`的__set__()逻辑这次不同了,直接在 obj1.__dict__中写入了对应属性obj1.__dict__ = {'r1': 3, 'r2': 4} # 所以 实例属性字典中,两者都被写入了# r1被同名的实例属性覆盖; r2内部不存值,下次查的时候仍然去问实例属性type(obj1).__dict__ = {'r1': <__main__.descread object at>, 'r2': <__main__.basic_syntax.>.DescWriteBindingObj object at 0x10d191240>, ...}obj1.r1 = 3 # 被同名实例属性覆盖obj1.r2 = 4 # `写描述符 r2`的__get__()逻辑这次也不同了;自己不存值,而是去问实例属性;即 obj1.__dict__['r2'] == 4"""
# 观察obj2的...
"""obj2.__dict__ = {} # 实例属性字典未受污染type(obj2).__dict__ = ... # 同obj1obj2.r1 = 0 # `读描述符 r1` 返回0obj2.r2 = 0 # `写描述符 r2` 自己不存值,而是去问实例属性;即 obj2.__dict__.get('r2', 0) == 0"""
实验6: [需要 python>=3.6] 用 __set_name__() 代替 init() 避免命名冗余
上述实验5仍然不完美;注意到这一行 r2 = DescWriteBindingObj('r2') # 这里命名冗余 存在冗余命名的问题,可以通过 元类机制 或者 python3.6以上语法 __set_name__() 解决
class DescWritePy36:
assert sys.version_info >= (3, 6), "Need python >= 3.6 for DescWritePy36.__set_name__()"
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner):
...
def __set__(self, instance, value):
...
class ByClassDescPy36:
r1 = DescRead()
r2 = DescWritePy36() # 这里避免了命名冗余
# 实验效果同上
重温3个用例(原理详解)
用例1: 惰性求值
class LazyProperty:
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, instance, owner):
value = self.func(instance) # 调用原方法, 求出结果
instance.__dict__[self.name] = value # 写入原实例的属性词典中
return instance.__dict__[self.name]
# def __set__(self, instance, value): # 注意实例属性高于`读描述符`,而低于`写描述符`
# pass # 所以,这里不能加 __set__; 空逻辑也不行
# 如果想要保持 __set__() 能力,需要同时改写 __get__()逻辑;参考后面的例子
class DeepThought: # 业务逻辑层的某个具体的类,观察一下用法
@LazyProperty
def meaning_of_life(self):
time.sleep(3) # 模拟耗时计算
return "eating"
my_deep_thought_instance = DeepThought()
print(arrow.now(), my_deep_thought_instance.meaning_of_life) # 第一次要算
print(arrow.now(), my_deep_thought_instance.meaning_of_life) # 这次直接读缓存
print(arrow.now(), my_deep_thought_instance.meaning_of_life) # 这次直接读缓存
用例2: 参数检查
class PowerDesc:
def __init__(self, region=(-INF, INF)):
self.region = region # 取值范围
def __set_name__(self, owner, name): # 避免反射还能拿到变量名的骚操作
""":param owner: 绑定的类型,即HParams:param name: 即将绑定到的变量名"""
self.name = name
def __set__(self, instance, value):
""" 写值之前,做一些检查 """
if not self.region[0] <= value <= self.region[1]: # 越界, 直接抛异常
raise ValueError(f"{self.name}={value}, not within region {self.region}")
if not (value != 0 and value & (value - 1) == 0): # 非幂次方,打印告警
# warnings.warn(f"{self.name}={value}, not power of 2, slow calculating")
# warnings走的是 stderr,可能会扰乱打印顺序,这里用print便于教学演示
print(f"{self.name}={value}, not power of 2, slow calculating")
instance.__dict__[self.name] = value # 写到实例的属性词典里
def __get__(self, instance, owner):
return instance.__dict__[self.name] # 未设置的话,允许直接抛异常
class HParams:
image_width = PowerDesc()
image_height = PowerDesc()
batch_size = PowerDesc(region=(8, 256))
hparams = HParams()
hparams.image_width = 1024
hparams.image_height = 768 # 非2的幂次方, 报warning: SlowCalculation
hparams.batch_size = 8 # OK
hparams.batch_size = 4 # 取值范围外, 抛异常: ValueError
用例3: 属性监听
class CallbackProperty:
"""A property that will alert observers upon updates"""
def __init__(self, default=0):
self.default = default
self.callbacks = [] # 针对绑定到的类中所有实例的回调函数
def __set_name__(self, owner, name):
self.name = name
def __set__(self, instance, value):
for callback in self.callbacks:
callback(value, instance)
instance.__dict__[self.name] = value
def __get__(self, instance, owner):
if instance is None:
return self # 通过类访问时,直接返回描述符而非取值;以便加监听
return instance.__dict__.get(self.name, self.default)
def add_callback(self, callback):
if callback in self.callbacks:
warnings.warn(f"duplicate callback, [skip]") # 重复的实例回调
else:
self.callbacks.append(callback)
class BankAccount:
username: str
balance = CallbackProperty(0)
def __init__(self, username):
self.username = username
jack_account = BankAccount('jack')
def low_balance_warning(value, instance: BankAccount):
if instance.balance >= 100 > value: # 致贫
print(f"{instance.username} is getting poor")
if instance.balance < 100 <= value: # 脱贫
print(f"{instance.username} is getting rich")
BankAccount.balance.add_callback(low_balance_warning)
BankAccount.balance.add_callback(low_balance_warning) # 重复warning
jack_account.balance = 101 # 初始0 -> 101; getting rich
jack_account.balance = 99 # 101 -> 99; getting poor
总结
“描述符”是Python语法中看上去比较“酷炫”的一种操作,但是并非花拳绣腿。
python内置的诸多机制、包括有一些流行的第三方库中都有用到。
可以大致将它理解为 “python内置@property语法的加强版”,可以灵活控制广义属性的任意读、写、删行为。包括且不限于本文中介绍的3种典型用法: 惰性求值、参数检查、属性监听
参考材料