了解描述符不仅可以使用更大的工具集,还可以更深入地了解 Python 的工作原理以及对其优雅设计的欣赏
----------------Raymond Hettinger, Python core developer and guru
描述符是在多个属性中重用相同访问逻辑的一种方式。例如,像 Django ORM 和 SQL Alchemy 这样的 ORM 中的字段类型是描述符,用于管理从数据库记录中的字段到 Python 对象属性的数据流,反之亦然。
描述符是一个实现由 __get__、__set__ 和 __delete__ 方法组成的动态协议的类。特性类实现了完整的描述符协议。与动态协议一样,只实现协议中的部分方法也是可以的。事实上,我们在实际代码中看到的大多数描述符只实现了 __get__ 和 __set__,而且很多只实现了其中一种方法。
描述符是 Python 的一个特色,不仅部署在应用程序级别,还部署在语言基础架构中。用户定义的函数是描述符。我们将看到描述符协议如何允许方法作为绑定或非绑定方法运行,具体取决于它们的调用方式。
理解描述符是掌握 Python 的关键。这就是本章的内容。
在本章中,我们将重构我们在“Using a Property for Attribute Validation”中第一次看到的食品订单示例,并将特性替换为描述符。这将使跨不同类重用属性验证的逻辑变得更加容易。我们将推敲覆盖和非覆盖描述符的概念,并理解 Python 函数是描述符。最后,我们将看到一些关于实现描述符的技巧。
本章新增内容
由于在 Python 3.6 中添加到描述符协议中的 __set_name__ 特殊方法,“LineItem Take #4: Automatic Naming of Storage Attributes”中的 Quantity 描述符示例得到了极大的简化。
我之前在“LineItem Take #4: Automatic Naming of Storage Attributes”中删除了属性工厂示例,因为它变得无关紧要:重点是展示解决Quantity问题的另一种方法,但是随着 __set_name__ 的添加,描述符解决方案变得更加简单。
曾经出现在“LineItem Take #5: A New Descriptor Type”中的 AutoStorage 类也消失了,因为 __set_name__ 使其成为过去时。
描述符示例:属性验证
正如我们在“Coding a Property Factory”中看到的那样,特性工厂是一种通过应用函数式编程模式来避免重复编码 getter 和 setter 的方法。特性工厂是一个高阶函数,它创建一组参数化的访问器函数并从它们构建一个自定义属性实例,并使用闭包来保存诸如 storage_name 之类的设置。解决这个问题的面向对象方法是描述符类。
我们将从 “Coding a Property Factory”继续 LineItem 示例,将quantity特性工厂重构为数量描述符类。这将使其更易于使用。
LineItem Take #3: 一个简单的描述符
正如我们在介绍中所说,实现 __get__、__set__ 或 __delete__ 方法的类是描述符。可以通过将描述符的实例声明为另一个类的类属性来使用描述符。
我们将创建一个 Quantity 描述符,LineItem 类将使用 Quantity 的两个实例:一个用于管理weight属性,另一个用于管理price。图表可以帮助理解其中的逻辑,因此请查看图 23-1。
请注意,单词 weight 在图 23-1 中出现了两次,因为实际上有两个不同的属性名为 weight:一个是 LineItem 的类属性,另一个是每个 LineItem 对象中都会存在的实例属性。这个逻辑也适用于price。
用于理解描述符的术语
实现和使用描述符涉及多个组件,因此精确命名这些组件非常有必要。我将使用下面的术语和定义来描述本章中的示例。一旦你看到代码,它们会更容易理解,但我想把定义放在前面,以便你在需要时可以参考它们。
Descriptor class(描述符类):
实现描述符协议的类。这就是图 23-1 中的Quantity。
Managed class(托管类):
描述符实例被声明为类属性的类。在图 23-1 中,LineItem 是托管类。
Descriptor instance(描述符示例):
描述符类的每个实例,声明为托管类的类属性。在图 23-1 中,每个描述符实例由一个带有下划线名称的组合箭头表示(下划线表示 UML 中的类属性)。黑色菱形触及包含描述符实例的 LineItem 类。
Managed instance(托管实例):
托管类的一个实例。在此示例中, LineItem类的 实例将是托管实例(它们未显示在类图中)。
Storage attribute(存储属性):
托管实例的一个属性,它将保存该特定实例的托管属性的值。在图 23-1 中,LineItem 实例属性 weight 和 price 是存储属性。它们与描述符实例不同,描述符实例始终是类属性。
Managed attribute(托管属性):
托管类中的公共属性,将由描述符实例处理,其值存储在存储属性中。换句话说,描述符实例和存储属性为托管属性提供了基础设施。
重要的是要意识到 Quantity 实例是 LineItem 的类属性。图 23-2 中的机器和小怪兽突出了这一关键点。
INTRODUCING MILLS & GIZMOS NOTATION
在多次解释描述符之后,我意识到 UML 不太擅长展示涉及类和实例的关系,例如托管类和描述符实例之间的关系。所以我发明了我自己的“语言”,Mills & Gizmos Notation (MGN),我用它来注释 UML 图。
MGN 用于清楚地区分类和实例。请参见图 23-3。在 MGN 中,一个类被绘制为“工厂”,一种生产小怪兽的复杂机器。class/工厂是带有拉杆和刻度盘的机器。小怪兽就是实例,它们看起来要简单得多。当这本书被印刷成彩色时,小怪兽的颜色与制作它的机器的颜色相同。
在本例中,我将 LineItem 实例绘制为表格发票中的行,其中三个单元格代表三个属性(description
, weight
和price
)。因为 Quantity 实例是描述符,所以它们对 __get__ 值有一个放大镜,对 __set__ 值有一个爪子。当我们谈到元类时,你会感谢我的这些涂鸦。
现在已经可以涂鸦了。下面是代码:示例 23-1 显示了 Quantity 描述符类,示例 23-2 列出了使用两个 Quantity 实例的新 LineItem 类。
示例 23-1。 bulkfood_v3.py:不接受负值的Quantity描述符。
class Quantity: 1
def __init__(self, storage_name):
self.storage_name = storage_name 2
def __set__(self, instance, value): 3
if value > 0:
instance.__dict__[self.storage_name] = value 4
else:
msg = f'{self.storage_name} must be > 0'
raise ValueError(msg)
def __get__(self, instance, owner): 5
return instance.__dict__[self.storage_name]
- 描述符是一个基于协议的特性;实现一个描述符类不需要继承。
- 每个 Quantity 实例都有一个 storage_name 属性:这是存储属性的名称,用于保存托管实例中的值。
- __set__ 在尝试给托管属性赋值时被调用。这里,self 是描述符实例(即 LineItem.weight 或 LineItem.price),instance 是托管实例(LineItem 实例),value 是要赋的值。
-
我们必须将属性值直接存入__dict__;调用 setattr(instance, self.storage_name) 会再次触发 __set__ 方法,导致无限递归。
-
我们需要实现 __get__ 因为托管属性的名称可能与 storage_name 不同。稍后将解释owner参数。
实现 __get__ 是必要的,因为用户可以编写如下内容:
class House:
rooms = Quantity('number_of_rooms')
在 House 类中,托管属性是room,但存储属性是 number_of_rooms。给定一个名为 chaos_manor 的 House 实例,读取和写入 chaos_manor.rooms 会通过附加到room的 Quantity 描述符实例,但读取和写入 chaos_manor.number_of_rooms 会绕过描述符。
请注意,__get__ 接收三个参数:self、instance 和 owner。owner参数是对托管类(例如,Lineitem)的引用,如果您希望描述符支持检索类属性,这个参数非常有用——也许可以模拟 Python 在实例中找不到名称时检索类属性的默认行为。
如果通过像 LineItem.weight这样 的类检索托管属性(例如weight),则描述符 __get__ 方法接收的instance参数的值为None。
为了支持用户的自省和其他元编程技巧,当通过类访问托管属性时,让 __get__ 返回描述符实例是一个很好的做法。为此,我们修改了 __get__ 的代码:
def __get__(self, instance, owner):
if instance is None:
return self
else:
return instance.__dict__[self.storage_name]
示例 23-2 演示了 在LineItem 中使用 Quantity 。
实施例23-2。 bulkfood_v3.py:Quantity描述符在LineItem类中管理属性。
class LineItem:
weight = Quantity('weight') 1
price = Quantity('price') 2
def __init__(self, description, weight, price): 3
self.description = description
self.weight = weight
self.price = price
def subtotal(self):
return self.weight * self.price
- 第一个描述符实例将管理weight属性。
- 第二个描述符的实例会管理的price属性。
- 类体的其余部分和bulkfood_v1.py原代码一样简洁(示例22-19)。
示例 23-2 中的代码按预期工作,防止以 0美元的价格售卖松露
>>> truffle = LineItem('White truffle', 100, 0)
Traceback (most recent call last):
...
ValueError: value must be > 0
WARNING:
在编写描述符 __get__ 和 __set__ 方法时,请记住 self 和 instance 参数的含义:self 是描述符实例,instance 是托管实例。管理实例属性的描述符应将值存储在托管实例中。这就是 Python 为描述符方法提供instance参数的原因。
将每个托管属性的值存储在描述符实例本身中可能很诱人,但也是错误的。换句话说,在 __set__ 方法体里面,如果不是下面实现:
instance.__dict__[self.storage_name] = value
看上去不错但是实际错误的实现是:
self.__dict__[self.storage_name] = value
要理解为什么这是错误的,请考虑 __set__ 的前两个参数的含义:self 和 instance。这里的self是描述符实例,实际上是托管类的一个类属性。您可能一次在内存中有数千个 LineItem 实例,但您只会有两个描述符实例:类属性 LineItem.weight 和 LineItem.price。因此,您存储在描述符实例本身中的任何内容实际上都是 LineItem 类属性的一部分,因此在所有 LineItem 实例之间共享。
示例 23-2 的一个缺点是在托管类主体中实例化描述符时需要重复属性名称。如果 LineItem 类可以这样声明,那就太好了:
class LineItem:
weight = Quantity()
price = Quantity()
# remaining methods as before
就目前而言,示例 23-2 需要显式命名每个 Quantity,这不仅不方便而且很容易出问题:如果程序员在复制和粘贴代码时忘记编辑这两个名称并写了诸如 price = Quantity('weight') 之类的内容,则程序就会出错,在设置price时破坏 weight 的值。
幸运的是,描述符协议现在支持恰当命名的 __set_name__ 特殊方法。接下来我们将看到如何使用它。
NOTE:
描述符存储属性的自动命名曾经是一个棘手的问题。.在 Fluent Python 第一版中,我在本章和下一章中专门介绍了几页和几行代码,介绍了不同的解决方案,包括使用类装饰器,然后在第 24 章中使用元类来解决。这在 Python 3.6 中得到了极大的简化
LineItem Take #4:存储属性的自动命名
为了避免在描述符实例中重新输入属性名称,我们将实现 __set_name__ 来设置每个 Quantity 实例的 storage_name。__set_name__ 特殊方法被添加到 Python 3.6 中的描述符协议中。解释器调用每个描述符中类主体中找到的__set_name__ ——如果描述符实现了这个特殊方法。
在示例 23-3 中,LineItem 描述符类不再需要 __init__。相反, __set_item__ 保存存储属性的名称。
示例 23-3。 bulkfood_v4.py: __set_name__ 设置每个Quantity描述符实例的名称
class Quantity:
def __set_name__(self, owner, name): 1
self.storage_name = name 2
def __set__(self, instance, value): 3
if value > 0:
instance.__dict__[self.storage_name] = value
else:
msg = f'{self.storage_name} must be > 0'
raise ValueError(msg)
# no __get__ needed 4
class LineItem:
weight = Quantity() 5
price = Quantity()
def __init__(self, description, weight, price):
self.description = description
self.weight = weight
self.price = price
def subtotal(self):
return self.weight * self.price
- self 是描述符实例(不是托管实例); owner 是托管类; name 是 owner 的属性名称,描述符实例在 owner 的类主体中赋值给这个属性。
- 这就是示例 23-1 中的 __init__ 所做的。
- 这里的 __set__ 方法与示例 23-1 中的方法完全相同。
- 不需要实现 __get__ ,因为存储属性的名称与托管属性的名称是匹配的。表达式 product.price 直接从 LineItem 实例获取price属性。
- 现在我们不需要将托管属性名称传递给 Quantity 构造函数。这就是这个版本的目标。
查看示例 23-3,您可能会认为增加了这些代码仅仅用于管理几个属性,但重要的是要意识到描述符逻辑现在被抽象为一个单独的代码单元:Quantity 类。通常我们不会在使用描述符的相同模块中进行定义,而是在一个单独的实用程序模块中定义,该模块旨在跨应用程序使用——如果您正在开发库或框架,这可能会发生在许多应用程序之间。
考虑到这一点,示例 23-4 更好地表现了描述符的典型用法。
示例 23-4。 bulkfood_v4c.py:LineItem 定义会更加简洁;Quantity描述符类现在定义在导入的 model_v4c 模块中
import model_v4c as model 1
class LineItem:
weight = model.Quantity() 2
price = model.Quantity()
def __init__(self, description, weight, price):
self.description = description
self.weight = weight
self.price = price
def subtotal(self):
return self.weight * self.price
- 导入实现 Quantity描述符 的 model_v4c 模块。
- 使用
model.Quantity
Django 用户会注意到示例 23-4 看起来很像模型定义。这并非巧合:Django 模型字段就是描述符。
因为描述符是作为类实现的,所以我们可以利用继承来实现新的描述符。这就是我们将在下一节中所做的。
LineItem Take #5:创建新的描述符类型
想象现在有机食品商店遇到了问题:不知何故,创建了一个带有空白描述的订单项实例,并且无法完成订单。为了防止这种情况,我们将创建一个新的描述符 NonBlank。在我们设计 NonBlank 时,我们意识到它与 Quantity 描述符非常相似,只是验证逻辑不同。
这样会让我们重构出一个Validated类,一个覆盖 __set__ 方法的抽象类,新的方法调用必须使用由子类实现的 validate 方法。
然后,我们将重写 Quantity类 ,并通过从 Validated类 继承,重写 validate 方法来实现 NonBlank。
Validated、Quantity 和 NonBlank 之间的关系是设计模式经典中描述的模板方法的应用:
模板方法根据抽象行为定义算法,子类通过重写以提供具体行为
在示例 23-5 中,Validated.__set__ 是模板方法,self.validate 是抽象行为。
示例 23-5。 model_v5.py:Validated ABC
import abc
class Validated(abc.ABC):
def __set_name__(self, owner, name):
self.storage_name = name
def __set__(self, instance, value):
value = self.validate(self.storage_name, value) 1
instance.__dict__[self.storage_name] = value 2
@abc.abstractmethod
def validate(self, name, value): 3
"""return validated value or raise ValueError"""
- __set__ 将验证委托给 validate 方法……
- …然后使用返回的值来更新存储的值。
- validate 是一个抽象方法;这就是模板方法。
Alex Martelli 更喜欢称这种设计模式为 Self-Delegation,我同意这样的描述更好:__set__ 的第一行将行为自委托给validate。
此示例中的具体 Validated 子类是 Quantity 和 NonBlank,如示例 23-6 所示。
示例 23-6。 model_v5.py:Quantity 和 NonBlank,具体的 Validated 子类
class Quantity(Validated):
"""a number greater than zero"""
def validate(self, name, value): 1
if value <= 0:
raise ValueError(f'{name} must be > 0')
return value
class NonBlank(Validated):
"""a string with at least one non-space character"""
def validate(self, name, value):
value = value.strip()
if not value: 2
raise ValueError(f'{name} cannot be blank')
return value 3
- Validated.validate 抽象方法所需的模板方法的实现。
- 如果在去除前缀空格和后缀空格后没有任何内容,则抛出异常
- 要求具体的validate方法返回经过验证的值,使它们有机会清理、转换或规范化接收到的数据。在本例中,返回的值已经被去掉前面和后面的空格。
model_v5.py 的用户不需要知道所有的细节。重要的是他们可以使用 Quantity 和 NonBlank 来自动验证实例属性。请参阅示例 23-7 中的最新 LineItem 类。
示例 23-7。 bulkfood_v5.py:使用 Quantity 和 NonBlank 描述符的 LineItem
import model_v5 as model 1
class LineItem:
description = model.NonBlank() 2
weight = model.Quantity()
price = model.Quantity()
def __init__(self, description, weight, price):
self.description = description
self.weight = weight
self.price = price
def subtotal(self):
return self.weight * self.price
- 导入 model_v5 模块,重命名为一个更友好的名称。
- 使用 model.NonBlank。其余代码不变。
我们在本章中看到的 LineItem 示例演示了使用描述符来管理数据属性的典型用法。像 Quantity 这样的描述符被称为覆盖描述符,因为它的 __set__ 方法覆盖(即拦截和覆盖)托管实例中同名的实例属性的设置。但是,也有非覆盖描述符。我们将在下一节中详细探讨这种区别。
覆盖与非覆盖描述符
回想一下,Python 处理属性的方式有一个重要的不对称性。通过实例读取属性通常会返回实例中定义的属性,但如果实例中没有这个属性,则会检索类属性。另一方面,实例中的属性赋值通常会在实例中创建属性,从而不会影响类本身。
这种不对称性也会影响描述符,实际上根据是否实现了 __set__ 方法创建了两大类描述符。如果存在 __set__ ,则该类是一个覆盖描述符;否则,它是一个非覆盖描述符。当我们在下一个示例中研究描述符行为时,这些术语会给我们提供帮助。
观察不同的描述符类别需要几个类,因此我们将使用示例 23-8 中的代码作为以下部分的测试平台。
TIP
### auxiliary functions for display only ###
def cls_name(obj_or_cls):
cls = type(obj_or_cls)
if cls is type:
cls = obj_or_cls
return cls.__name__.split('.')[-1]
def display(obj):
cls = type(obj)
if cls is type:
return f'<class {obj.__name__}>'
elif cls in [type(None), int]:
return repr(obj)
else:
return f'<{cls_name(obj)} object>'
def print_args(name, *args):
pseudo_args = ', '.join(display(x) for x in args)
print(f'-> {cls_name(args[0])}.__{name}__({pseudo_args})')
### essential classes for this example ###
class Overriding: 1
"""a.k.a. data descriptor or enforced descriptor"""
def __get__(self, instance, owner):
print_args('get', self, instance, owner) 2
def __set__(self, instance, value):
print_args('set', self, instance, value)
class OverridingNoGet: 3
"""an overriding descriptor without ``__get__``"""
def __set__(self, instance, value):
print_args('set', self, instance, value)
class NonOverriding: 4
"""a.k.a. non-data or shadowable descriptor"""
def __get__(self, instance, owner):
print_args('get', self, instance, owner)
class Managed: 5
over = Overriding()
over_no_get = OverridingNoGet()
non_over = NonOverriding()
def spam(self): 6
print(f'-> Managed.spam({display(self)})')
- 具有 __get__ 和 __set__ 的覆盖描述符类。
- 此示例中的每个描述符方法都会调用 print_args 函数。
- 没有 __get__ 方法的覆盖描述符。
- 这里没有 __set__ 方法,所以这是一个非覆盖描述符。
- 托管类,使用每个描述符类的一个实例。
- spam 方法在这里是为了比较,因为方法也是描述符。
在接下来的部分中,我们将检查 Managed 类及其一个实例上的属性读取和写入行为,并遍历定义的每个不同的描述符。
覆盖描述符
实现 __set__ 方法的描述符是覆盖描述符,虽然它是类属性,但实现 __set__ 的描述符会覆盖给实例属性赋值的操作。这就是示例 23-3 的实现方式。property也是重写描述符:如果没有实现 setter 函数,则property类中的默认 __set__ 将抛出 AttributeError 来表明属性是只读的。根据示例 23-8 中的代码,可以在示例 23-9 中看到使用覆盖描述符的实验。
WARNING
Python 贡献者和作者在讨论这些概念时使用不同的术语。我采用了 Python in a Nutshell 一书中的“覆盖描述符”。官方 Python 文档使用“数据描述符”,但“覆盖描述符”突出显示了特殊行为。覆盖描述符也称为“强制描述符”。非覆盖描述符的同义词包括“非数据描述符”或“可阴影描述符”。
示例 23-9。覆盖描述符的行为。
>>> obj = Managed() 1
>>> obj.over 2
-> Overriding.__get__(<Overriding object>, <Managed object>, <class Managed>)
>>> Managed.over 3
-> Overriding.__get__(<Overriding object>, None, <class Managed>)
>>> obj.over = 7 4
-> Overriding.__set__(<Overriding object>, <Managed object>, 7)
>>> obj.over 5
-> Overriding.__get__(<Overriding object>, <Managed object>, <class Managed>)
>>> obj.__dict__['over'] = 8 6
>>> vars(obj) 7
{'over': 8}
>>> obj.over 8
-> Overriding.__get__(<Overriding object>, <Managed object>, <class Managed>)
- 创建用于测试的Managed对象。
- obj.over 触发描述符的 __get__ 方法,将托管实例 obj 作为第二个参数传递。
- Managed.over 触发描述符 __get__ 方法,将None 作为第二个参数(instance)传递。
- 给 obj.over 赋值会触发描述符 __set__ 方法,将值 7 作为最后一个参数传递。
- 读取 obj.over 仍会调用描述符 __get__ 方法。
- 绕过描述符,直接在obj.__dict__ 设置一个值。
- 证该 obj.__dict__ 中 over 键对应的值
- 但是,即使使用名为 over 的实例属性,读取obj.over属性仍会被Managed.over 描述符覆盖。
没有 __get__ 的覆盖描述符
特性和其他覆盖描述符(例如 Django 模型字段)同时实现了 __set__ 和 __get__,但也可以只实现 __set__,如示例 23-2 所示。在这种情况下,描述符只处理写入。由于没有 __get__ 来处理该访问,通过实例读取描述符将返回描述符对象本身。如果通过直接访问实例的__dict__ 创建同名实例属性,__set__ 方法仍将覆盖对该属性的赋值操作,但读取该属性将直接从实例的__dict__中读取,而不是返回描述符对象。换句话说,仅在读取时实例属性将隐藏描述符。参见示例 23-10。
示例 23-10。没有 __get__ 的覆盖描述符。
>>> obj.over_no_get 1
<__main__.OverridingNoGet object at 0x665bcc>
>>> Managed.over_no_get 2
<__main__.OverridingNoGet object at 0x665bcc>
>>> obj.over_no_get = 7 3
-> OverridingNoGet.__set__(<OverridingNoGet object>, <Managed object>, 7)
>>> obj.over_no_get 4
<__main__.OverridingNoGet object at 0x665bcc>
>>> obj.__dict__['over_no_get'] = 9 5
>>> obj.over_no_get 6
9
>>> obj.over_no_get = 7 7
-> OverridingNoGet.__set__(<OverridingNoGet object>, <Managed object>, 7)
>>> obj.over_no_get 8
9
- 这个覆盖描述符没有 __get__ 方法,因此读取 obj.over_no_get 会从类中返回描述符实例。
- 如果我们直接从托管类中检索描述符实例,也会发生同样的事情。
- 尝试为 obj.over_no_get 赋值会调用 __set__ 描述符方法。
- 因为我们的 __set__ 没有进行更改,所以再次读取 obj.over_no_get 会从托管类中检索描述符实例。
- 在实例 的__dict__ 中设置一个名为 over_no_get 的实例属性。
- 现在 over_no_get 实例属性隐藏了描述符,但仅用于读取。
- 尝试为 obj.over_no_get 赋值仍然会通过描述符集。
- 但是对于读取操作,只要存在同名实例属性,该描述符就会被隐藏。
非覆盖描述符
未实现 __set__ 的描述符是非覆盖描述符。设置具有相同名称的实例属性将隐藏描述符,使其无法在该特定实例中处理该属性。方法和 @functools.cached_property 被实现为非覆盖描述符。示例 23-11 显示了非覆盖描述符的操作。
示例 23-11。非覆盖描述符的行为。
>>> obj = Managed()
>>> obj.non_over 1
-> NonOverriding.__get__(<NonOverriding object>, <Managed object>, <class Managed>)
>>> obj.non_over = 7 2
>>> obj.non_over 3
7
>>> Managed.non_over 4
-> NonOverriding.__get__(<NonOverriding object>, None, <class Managed>)
>>> del obj.non_over 5
>>> obj.non_over 6
-> NonOverriding.__get__(<NonOverriding object>, <Managed object>, <class Managed>)
- obj.non_over 触发描述符 __get__ 方法,将 obj 作为第二个参数传递。
- Managed.non_over 是一个非覆盖描述符,所以没有 __set__ 来覆盖这个赋值。
- obj 现在有一个名为 non_over 的实例属性,它隐藏了 Managed 类中的同名描述符属性。
- Managed.non_over 描述符仍然存在,并且通过类进行访问。
- 如果 non_over 实例属性被删除...
- 然后读取 obj.non_over 会命中类中描述符的 __get__ 方法,但请注意第二个参数是托管实例。
在前面的示例中,我们看到了对与描述符同名的实例属性的多次赋值,并且根据描述符中 __set__ 方法是否存在而产生不同的结果。类中的属性设置不能由附加到同一类的描述符控制。特别是,这意味着描述符属性本身可以通过赋值给类来破坏,如下一节所述。
覆盖类中的描述符
不管描述符是否被覆盖,它都可以通过赋值给类来覆盖。这是一种猴子补丁技术,但在示例 23-12 中,描述符被整数替换,这将破坏任何依赖描述符才能正确操作的类。
示例 23-12。任何描述符都可以在类本身上被覆盖
>>> obj = Managed() 1
>>> Managed.over = 1 2
>>> Managed.over_no_get = 2
>>> Managed.non_over = 3
>>> obj.over, obj.over_no_get, obj.non_over 3
(1, 2, 3)
- 为测试创建一个新实例。
- 覆盖类中的描述符属性。
- 描述符真的没有了。
示例 23-12 揭示了另一个关于读写属性的不对称性:
虽然类属性的读取可以由附加到托管类的 __get__ 描述符控制,但类属性的写入不能由附加到同一类的 __set__ 描述符处理。
TIP:
为了控制类中属性的设置,您必须将描述符附加到该类的类本身——也就是元类。默认情况下,自定义类的元类是type,不能给type添加属性。但在第 24 章中,我们将创建自己的元类。
现在让我们关注如何使用描述符来实现 Python 中的方法
方法是描述符
类中的函数在实例上调用时成为绑定方法,因为所有用户定义的函数都有一个 __get__ 方法,因此,它们在类中的表现就是描述符。示例 23-13 演示了从示例 23-8 中介绍的Managed类读取spam方法。
示例 23-13。方法是非覆盖描述符
>>> obj = Managed()
>>> obj.spam 1
<bound method Managed.spam of <descriptorkinds.Managed object at 0x74c80c>>
>>> Managed.spam 2
<function Managed.spam at 0x734734>
>>> obj.spam = 7 3
>>> obj.spam
7
- 读取obj.spam 会返回绑定的方法对象。
- 但是读取Managed.spam返回一个函数。
- 为 obj.spam 赋值会影响类属性,从而使 obj 实例无法访问 spam 方法。
函数没有实现 __set__,因此它们是非覆盖描述符,如示例 23-13 的最后一行所示。
示例 23-13 的另一个关键要点是 obj.spam 和 Managed.spam 返回不同的对象。与描述符一样,当通过托管类进行访问时,函数的 __get__ 会返回对自身的引用。但是通过实例访问时,函数的 __get__ 返回一个绑定的方法对象:包装函数并将托管实例(例如,obj)绑定到函数的第一个参数(即,self)的可调用对象,就像 functools.partial 函数一样(如“使用 functools.partial 冻结参数”中所示)。
要更深入地了解这种机制,请查看示例 23-14。
示例 23-14。 method_is_descriptor.py:Text类,派生自UserString
import collections
class Text(collections.UserString):
def __repr__(self):
return 'Text({!r})'.format(self.data)
def reverse(self):
return self[::-1]
现在让我们研究一下 Text.reverse 方法。参见示例 23-15。
示例 23-15。用一种方法进行实验
>>> word = Text('forward')
>>> word 1
Text('forward')
>>> word.reverse() 2
Text('drawrof')
>>> Text.reverse(Text('backward')) 3
Text('drawkcab')
>>> type(Text.reverse), type(word.reverse) 4
(<class 'function'>, <class 'method'>)
>>> list(map(Text.reverse, ['repaid', (10, 20, 30), Text('stressed')])) 5
['diaper', (30, 20, 10), Text('desserts')]
>>> Text.reverse.__get__(word) 6
<bound method Text.reverse of Text('forward')>
>>> Text.reverse.__get__(None, Text) 7
<function Text.reverse at 0x101244e18>
>>> word.reverse 8
<bound method Text.reverse of Text('forward')>
>>> word.reverse.__self__ 9
Text('forward')
>>> word.reverse.__func__ is Text.reverse 10
True
- Text 实例的 repr 看起来像一个 Text 构造函数调用,它会生成一个等价的实例。
- reverse 方法返回反向拼写的文本。
- 在类上调用的方法作为函数工作。
- 注意不同的类型:function和method。
- Text.reverse 作为一个函数运行,甚至可以处理不是 Text 实例的对象。
- 任何函数都是非覆盖描述符。在 __get__方法中传入实例对象会返回一个绑定到这个实例对象的方法。
- 使用 None 作为instance参数调用函数的 __get__ 函数会返回函数本身。
- 表达式 word.reverse 实际上调用 Text.reverse.__get__(word),返回绑定的方法。
- 绑定的方法对象有一个 __self__ 属性,其中包含对调用该方法的实例的引用。
- 绑定方法的 __func__ 属性是对附加到托管类的原始函数的引用。
绑定的方法对象还有一个 __call__ 方法,它处理实际的调用。此方法调用 __func__ 中引用的原始函数,将方法的 __self__ 属性作为第一个参数传递。这就是传统 self 参数的隐式绑定的工作原理。
将函数转换为绑定方法的方式是描述符如何用作语言中的基础结构的一个主要示例。
在深入了解描述符和方法的工作原理之后,让我们看一下关于它们使用的一些实用建议。
描述符使用技巧
下面的列表解决了刚刚描述的描述符特性的一些实际后果:
使用特性保持简单
即使您没有定义 setter 方法,内置的property也会创建实现 __set__、__get__ 的覆盖描述符.属性的默认 __set__ 抛出 AttributeError: can't set attribute,因此特性是创建只读属性的最简单方法,避免了接下来描述的问题。
只读描述符也需要实现 __set__方法
如果使用描述符类来实现只读属性,则必须记住同时实现 __get__ 和 __set__ ,否则在实例上设置同名属性会隐藏描述符。只读属性的 __set__ 方法应该只使用合适的消息抛出 AttributeError。
验证描述符只能与 __set__ 一起使用
在仅为验证而设计的描述符中,__set__ 方法应检查它获取的 value 参数,如果合法,则使用描述符实例名称作为键直接在实例 __dict__ 中进行保存。这样,从实例中读取具有相同名称的属性将尽可能快,因为不需要使用 __get__。请参见示例 23-3 的代码。
仅使用 __get__ 可以有效地完成缓存
如果只实现 __get__ 方法,则描述符为非覆盖描述符。这些对于进行一些昂贵的计算很有用,然后通过在实例上设置同名属性来缓存结果。同名实例属性将隐藏描述符,因此对该属性的后续访问将直接从实例 __dict__ 中获取它,并且不再触发描述符 __get__。@functools.cached_property 装饰器实际上产生了一个非覆盖描述符。
非特殊方法可以被实例属性隐藏
因为函数和方法只实现 __get__,所以它们是非覆盖描述符。像 my_obj.the_method = 7 这样的简单赋值意味着通过该实例进一步访问 the_method 将返回 7,而不会影响类或其他实例。但是,这个问题不干扰特殊方法。解释器只查找类本身的特殊方法,换句话说,repr(x) 被执行为 x.__class__.__repr__(x),所以在 x 中定义的 __repr__ 属性对 repr(x) 没有影响。同样的原因,实例中存在名为 __getattr__ 的属性不会颠覆通常的属性访问算法。
在实例中可以如此轻松地覆盖非特殊方法这一事实听起来很脆弱且容易出错,但我个人在 20 多年的 Python 编码中从未被这一点困扰过。另一方面,如果您正在创建大量动态属性,其中属性名称来自您无法控制的数据(正如我们在本章前面部分所做的那样),那么您应该意识到这一点,并可能对动态属性名称进行一些过滤或转义以保持您的理智。
NOTE
示例 22-5 中的 FrozenJSON 类不受实例属性隐藏方法的影响,因为它唯一的方法是特殊方法和类方法build。只要始终通过类访问类方法,它们就是安全的,就像我在示例 22-5 中对 FrozenJSON.build 所做的那样——后来在示例 22-6 中被 __new__ 替换。“计算属性”中介绍的 Record 和 Event 类也是安全的:它们只实现特殊方法、静态方法和特性。特性是覆盖描述符,因此它们不受实例属性的影响。
本章结束时,我们将介绍我们在描述符上下文中未涉及的属性的两个特性:文档和处理删除托管属性的尝试。
描述符文档字符串和覆盖删除
描述符类的文档字符串用于记录托管类中描述符的每个实例。图 23-4 显示了带有示例 23-6 和 23-7 中的 Quantity 和 NonBlank 描述符的 LineItem 类的帮助显示。
这有些不尽人意。对于 LineItem,最好添加例如weight必须以千克为单位的信息。这对于特性来说是不太友好的,因为每个特性都处理一个特定的托管属性。但是对于描述符,相同的 Quantity 描述符类用于weight和price。
我们用属性讨论过但没有用描述符解决的第二个细节是处理删除托管属性的尝试。这可以通过在描述符类中实现__delete__或代替通常的 __get__ 和/或 __set__ 方法来实现。我故意省略了 __delete__ 的部分,因为我相信现实世界的使用很少见。如果需要,请参阅 Python 数据模型文档的实现描述符部分。用 __delete__ 编写一个描述符类作为练习留给有余力的读者。