python综合实验的原理_3个用例+6个实验 讲透Python Descriptor(描述符)

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种典型用法: 惰性求值、参数检查、属性监听

参考材料

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值