python技能详解(Python魔法方法漫游指南)

描述符是 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 的实现,比如属性、方法、静态方法、类方法等等。

完整例子见文档描述符指南。

总结

通过本文,你应该已经感受到描述符的强大功能,并且大致明白应该在哪些场合运用它了:

  • 描述符就是可复用的属性,它将函数调用伪装成对属性的访问。
  • 数据描述符和非数据描述符,在查找链中位于不同的优先级。
  • 描述符在属性托管、缓存和验证器等场景下应用较为常见。

没骗你吧,描述符绝对是个很有意思的特性,也不是炫技用的花拳绣腿。合理运用,可以让你的代码简洁而优雅。

参考
  • implementing-descriptors
  • descriptor
  • why-use-python-descriptors

本系列文章开源发布于 Github,传送门:Python魔法方法漫游指南

看完文章想吐槽?欢迎留言告诉我!

python技能详解(Python魔法方法漫游指南)(1)

python技能详解(Python魔法方法漫游指南)(2)

,

免责声明:本文仅代表文章作者的个人观点,与本站无关。其原创性、真实性以及文中陈述文字和内容未经本站证实,对本文以及其中全部或者部分内容文字的真实性、完整性和原创性本站不作任何保证或承诺,请读者仅作参考,并自行核实相关内容。文章投诉邮箱:anhduc.ph@yahoo.com

    分享
    投诉
    首页