流畅的 Python 第二版(GPT 重译)(十二)(3)

简介: 流畅的 Python 第二版(GPT 重译)(十二)

流畅的 Python 第二版(GPT 重译)(十二)(2)https://developer.aliyun.com/article/1485176

示例 22-25. 新类属性遮蔽现有实例属性(续自示例 22-24)
>>> obj.data  # ①
'bar' >>> Class.data  # ②
'the class data attr' >>> Class.data = property(lambda self: 'the "data" prop value')  # ③
>>> obj.data  # ④
'the "data" prop value' >>> del Class.data  # ⑤
>>> obj.data  # ⑥
'bar'

obj.data 检索实例 data 属性。

Class.data 检索类 data 属性。

用新属性覆盖 Class.data

obj.data 现在被 Class.data 属性遮蔽。

删除属性。

obj.data 现在再次读取实例 data 属性。

本节的主要观点是,像 obj.data 这样的表达式并不会从 obj 开始搜索 data。搜索实际上从 obj.__class__ 开始,只有在类中没有名为 data 的属性时,Python 才会在 obj 实例本身中查找。这适用于一般的覆盖描述符,其中属性只是一个例子。对描述符的进一步处理必须等到第二十三章。

现在回到属性。每个 Python 代码单元——模块、函数、类、方法——都可以有一个文档字符串。下一个主题是如何将文档附加到属性上。

属性文档

当工具如控制台的 help() 函数或 IDE 需要显示属性的文档时,它们会从属性的 __doc__ 属性中提取信息。

如果与经典调用语法一起使用,property 可以将文档字符串作为 doc 参数:

weight = property(get_weight, set_weight, doc='weight in kilograms')

getter 方法的文档字符串——带有 @property 装饰器本身——被用作整个属性的文档。图 22-1 展示了从示例 22-26 中的代码生成的帮助屏幕。

图 22-1. Python 控制台的屏幕截图,当发出命令 help(Foo.bar)help(Foo) 时。源代码在示例 22-26 中。
示例 22-26. 属性的文档
class Foo:
    @property
    def bar(self):
        """The bar attribute"""
        return self.__dict__['bar']
    @bar.setter
    def bar(self, value):
        self.__dict__['bar'] = value

现在我们已经掌握了这些属性的基本要点,让我们回到保护 LineItemweightprice 属性只接受大于零的值的问题上来,但不需要手动实现两个几乎相同的 getter/setter 对。

编写属性工厂

我们将创建一个工厂来创建 quantity 属性,因为受管属性代表应用程序中不能为负或零的数量。示例 22-27 展示了 LineItem 类使用两个 quantity 属性实例的清晰外观:一个用于管理 weight 属性,另一个用于 price

示例 22-27. bulkfood_v2prop.py:使用 quantity 属性工厂
class LineItem:
    weight = quantity('weight')  # ①
    price = quantity('price')  # ②
    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight  # ③
        self.price = price
    def subtotal(self):
        return self.weight * self.price  # ④

使用工厂定义第一个自定义属性 weight 作为类属性。

这第二次调用构建了另一个自定义属性 price

这里属性已经激活,确保拒绝负数或 0weight

这些属性也在此处使用,检索存储在实例中的值。

请记住属性是类属性。在构建每个 quantity 属性时,我们需要传递将由该特定属性管理的 LineItem 属性的名称。在这一行中不得不两次输入单词 weight 是不幸的:

weight = quantity('weight')

但避免重复是复杂的,因为属性无法知道将绑定到它的类属性名称。记住:赋值语句的右侧首先被评估,因此当调用 quantity() 时,weight 类属性甚至不存在。

注意

改进 quantity 属性,使用户无需重新输入属性名称是一个非常棘手的元编程问题。我们将在第二十三章中解决这个问题。

示例 22-28 列出了 quantity 属性工厂的实现。¹¹

示例 22-28. bulkfood_v2prop.py:quantity 属性工厂
def quantity(storage_name):  # ①
    def qty_getter(instance):  # ②
        return instance.__dict__[storage_name]  # ③
    def qty_setter(instance, value):  # ④
        if value > 0:
            instance.__dict__[storage_name] = value  # ⑤
        else:
            raise ValueError('value must be > 0')
    return property(qty_getter, qty_setter)  # ⑥

storage_name 参数确定每个属性的数据存储位置;对于 weight,存储名称将是 'weight'

qty_getter 的第一个参数可以命名为 self,但这将很奇怪,因为这不是一个类体;instance 指的是将存储属性的 LineItem 实例。

qty_getter 引用 storage_name,因此它将在此函数的闭包中保留;值直接从 instance.__dict__ 中检索,以绕过属性并避免无限递归。

qty_setter 被定义,同时将 instance 作为第一个参数。

value 直接存储在 instance.__dict__ 中,再次绕过属性。

构建自定义属性对象并返回它。

值得仔细研究的 示例 22-28 部分围绕着 storage_name 变量展开。当你以传统方式编写每个属性时,在 getter 和 setter 方法中硬编码了存储值的属性名称。但在这里,qty_getterqty_setter 函数是通用的,它们依赖于 storage_name 变量来知道在实例 __dict__ 中获取/设置托管属性的位置。每次调用 quantity 工厂来构建属性时,storage_name 必须设置为一个唯一的值。

函数 qty_getterqty_setter 将被工厂函数最后一行创建的 property 对象包装。稍后,当调用执行它们的职责时,这些函数将从它们的闭包中读取 storage_name,以确定从哪里检索/存储托管属性值。

在 示例 22-29 中,我创建并检查一个 LineItem 实例,暴露存储属性。

示例 22-29. bulkfood_v2prop.py:探索属性和存储属性
>>> nutmeg = LineItem('Moluccan nutmeg', 8, 13.95) >>> nutmeg.weight, nutmeg.price # ①
 (8, 13.95) >>> nutmeg.__dict__ # ②
 {'description': 'Moluccan nutmeg', 'weight': 8, 'price': 13.95}

通过遮蔽同名实例属性的属性来读取 weightprice

使用 vars 检查 nutmeg 实例:这里我们看到用于存储值的实际实例属性。

注意我们的工厂构建的属性如何利用 “属性覆盖实例属性” 中描述的行为:weight 属性覆盖了 weight 实例属性,以便每个对 self.weightnutmeg.weight 的引用都由属性函数处理,而绕过属性逻辑的唯一方法是直接访问实例 __dict__

示例 22-28 中的代码可能有点棘手,但很简洁:它的长度与仅定义 weight 属性的装饰的 getter/setter 对相同,如 示例 22-21 中所示。在 示例 22-27 中,LineItem 定义看起来更好,没有 getter/setter 的干扰。

在一个真实的系统中,同样类型的验证可能出现在许多字段中,跨越几个类,并且 quantity 工厂将被放置在一个实用模块中,以便反复使用。最终,这个简单的工厂可以重构为一个更可扩展的描述符类,具有执行不同验证的专门子类。我们将在 第二十三章 中进行这样的操作。

现在让我们结束对属性的讨论,转向属性删除的问题。

处理属性删除

我们可以使用 del 语句来删除变量,也可以删除属性:

>>> class Demo:
...    pass
...
>>> d = Demo()
>>> d.color = 'green'
>>> d.color
'green'
>>> del d.color
>>> d.color
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Demo' object has no attribute 'color'

在实践中,删除属性并不是我们在 Python 中每天都做的事情,而且要求使用属性处理它更加不寻常。但是它是被支持的,我可以想到一个愚蠢的例子来演示它。

在属性定义中,@my_property.deleter 装饰器包装了负责删除属性的方法。正如承诺的那样,愚蠢的 示例 22-30 受到了《Monty Python and the Holy Grail》中黑骑士场景的启发。¹²

示例 22-30. blackknight.py
class BlackKnight:
    def __init__(self):
        self.phrases = [
            ('an arm', "'Tis but a scratch."),
            ('another arm', "It's just a flesh wound."),
            ('a leg', "I'm invincible!"),
            ('another leg', "All right, we'll call it a draw.")
        ]
    @property
    def member(self):
        print('next member is:')
        return self.phrases[0][0]
    @member.deleter
    def member(self):
        member, text = self.phrases.pop(0)
        print(f'BLACK KNIGHT (loses {member}) -- {text}')

blackknight.py 中的文档测试在 示例 22-31 中。

示例 22-31. blackknight.py:示例 22-30 的文档测试(黑骑士永不认输)
>>> knight = BlackKnight()
 >>> knight.member
 next member is:
 'an arm'
 >>> del knight.member
 BLACK KNIGHT (loses an arm) -- 'Tis but a scratch.
 >>> del knight.member
 BLACK KNIGHT (loses another arm) -- It's just a flesh wound.
 >>> del knight.member
 BLACK KNIGHT (loses a leg) -- I'm invincible!
 >>> del knight.member
 BLACK KNIGHT (loses another leg) -- All right, we'll call it a draw.

使用经典的调用语法而不是装饰器,fdel 参数配置了删除函数。例如,在 BlackKnight 类的主体中,member 属性将被编码为:

member = property(member_getter, fdel=member_deleter)

如果您没有使用属性,属性删除也可以通过实现更低级的__delattr__特殊方法来处理,如“属性处理的特殊方法”中所述。编写一个带有__delattr__的愚蠢类留给拖延的读者作为练习。

属性是一个强大的功能,但有时更简单或更低级的替代方案更可取。在本章的最后一节中,我们将回顾 Python 为动态属性编程提供的一些核心 API。

处理属性的基本属性和函数

在本章中,甚至在本书之前,我们已经使用了 Python 提供的一些用于处理动态属性的内置函数和特殊方法。本节将它们的概述放在一个地方,因为它们的文档分散在官方文档中。

影响属性处理的特殊属性

下面列出的许多函数和特殊方法的行为取决于三个特殊属性:

__class__

对象的类的引用(即obj.__class__type(obj)相同)。Python 仅在对象的类中查找__getattr__等特殊方法,而不在实例本身中查找。

__dict__

存储对象或类的可写属性的映射。具有__dict__的对象可以随时设置任意新属性。如果一个类具有__slots__属性,则其实例可能没有__dict__。请参阅__slots__(下一节)。

__slots__

可以在类中定义的属性,用于节省内存。__slots__是一个命名允许的属性的字符串tuple。¹³ 如果__slots__中没有'__dict__'名称,那么该类的实例将不会有自己的__dict__,并且只允许在这些实例中列出的属性。更多信息请参阅“使用 slots 节省内存”。

用于属性处理的内置函数

这五个内置函数执行对象属性的读取、写入和内省:

dir([object])

列出对象的大多数属性。官方文档dir用于交互使用,因此它不提供属性的全面列表,而是提供一个“有趣”的名称集。dir可以检查实现了__dict__或未实现__dict__的对象。dir本身不列出__dict__属性,但列出__dict__键。类的几个特殊属性,如__mro____bases____name__,也不被dir列出。您可以通过实现__dir__特殊方法来自定义dir的输出,就像我们在示例 22-4 中看到的那样。如果未提供可选的object参数,则dir列出当前范围中的名称。

getattr(object, name[, default])

object中获取由name字符串标识的属性。主要用例是检索我们事先不知道的属性(或方法)。这可能会从对象的类或超类中获取属性。如果没有这样的属性存在,则getattr会引发AttributeError或返回default值(如果给定)。一个很好的使用getattr的例子是在标准库的cmd包中的Cmd.onecmd方法中,它用于获取和执行用户定义的命令。

hasattr(object, name)

如果命名属性存在于object中,或者可以通过object(例如通过继承)获取,则返回True文档解释说:“这是通过调用 getattr(object, name)并查看它是否引发 AttributeError 来实现的。”

setattr(object, name, value)

如果object允许,将value分配给object的命名属性。这可能会创建一个新属性或覆盖现有属性。

vars([object])

返回object__dict__vars无法处理定义了__slots__且没有__dict__的类的实例(与dir相反,后者处理这些实例)。如果没有参数,vars()locals()执行相同的操作:返回表示局部作用域的dict

用于属性处理的特殊方法

当在用户定义的类中实现时,这里列出的特殊方法处理属性的检索、设置、删除和列出。

使用点符号表示法或内置函数getattrhasattrsetattr访问属性会触发这里列出的适当的特殊方法。直接在实例__dict__中读取和写入属性不会触发这些特殊方法——这是需要绕过它们的常用方式。

章节“3.3.11. 特殊方法查找”中的“数据模型”一章警告:

对于自定义类,只有在对象的类型上定义了特殊方法时,隐式调用特殊方法才能保证正确工作,而不是在对象的实例字典中定义。

换句话说,假设特殊方法将在类本身上检索,即使操作的目标是实例。因此,特殊方法不会被具有相同名称的实例属性遮蔽。

在以下示例中,假设有一个名为Class的类,objClass的一个实例,attrobj的一个属性。

对于这些特殊方法中的每一个,无论是使用点符号表示法还是“用于属性处理的内置函数”中列出的内置函数之一,都没有关系。例如,obj.attrgetattr(obj, 'attr', 42)都会触发Class.__getattribute__(obj, 'attr')

__delattr__(self, name)

当尝试使用del语句删除属性时始终调用;例如,del obj.attr触发Class.__delattr__(obj, 'attr')。如果attr是一个属性,则如果类实现了__delattr__,则其删除方法永远不会被调用。

__dir__(self)

在对象上调用dir时调用,以提供属性列表;例如,dir(obj)触发Class.__dir__(obj)。在所有现代 Python 控制台中,也被用于制表完成。

__getattr__(self, name)

仅在尝试检索命名属性失败时调用,之后搜索objClass及其超类。表达式obj.no_such_attrgetattr(obj, 'no_such_attr')hasattr(obj, 'no_such_attr')可能会触发Class.__getattr__(obj, 'no_such_attr'),但仅当在objClass及其超类中找不到该名称的属性时。

__getattribute__(self, name)

当尝试直接从 Python 代码中检索命名属性时始终调用(解释器在某些情况下可能会绕过此方法,例如获取__repr__方法)。点符号表示法和getattr以及hasattr内置函数会触发此方法。__getattr__仅在__getattribute__之后调用,并且仅在__getattribute__引发AttributeError时才会调用。为了检索实例obj的属性而不触发无限递归,__getattribute__的实现应该使用super().__getattribute__(obj, name)

__setattr__(self, name, value)

当尝试设置命名属性时始终调用。点符号和setattr内置触发此方法;例如,obj.attr = 42setattr(obj, 'attr', 42)都会触发Class.__setattr__(obj, 'attr', 42)

警告

实际上,因为它们被无条件调用并影响几乎每个属性访问,__getattribute____setattr__特殊方法比__getattr__更难正确使用,后者仅处理不存在的属性名称。使用属性或描述符比定义这些特殊方法更不容易出错。

这结束了我们对属性、特殊方法和其他编写动态属性技术的探讨。

章节总结

我们通过展示简单类的实际示例来开始动态属性的覆盖。第一个示例是FrozenJSON类,它将嵌套的字典和列表转换为嵌套的FrozenJSON实例和它们的列表。FrozenJSON代码演示了使用__getattr__特殊方法在读取属性时动态转换数据结构。FrozenJSON的最新版本展示了使用__new__构造方法将一个类转换为灵活的对象工厂,不限于自身的实例。

然后,我们将 JSON 数据集转换为存储Record类实例的dictRecord的第一个版本只有几行代码,并引入了“bunch”习惯用法:使用self.__dict__.update(**kwargs)从传递给__init__的关键字参数构建任意属性。第二次迭代添加了Event类,通过属性实现自动检索链接记录。计算属性值有时需要缓存,我们介绍了几种方法。

在意识到@functools.cached_property并非总是适用后,我们了解了一种替代方法:按顺序将@property@functools.cache结合使用。

属性的覆盖继续在LineItem类中进行,其中部署了一个属性来保护weight属性免受没有业务意义的负值或零值的影响。在更深入地了解属性语法和语义之后,我们创建了一个属性工厂,以强制在weightprice上执行相同的验证,而无需编写多个 getter 和 setter。属性工厂利用了微妙的概念——如闭包和属性覆盖实例属性——以使用与手动编码的单个属性定义相同数量的行提供优雅的通用解决方案。

最后,我们简要介绍了使用属性处理属性删除的方法,然后概述了核心 Python 语言中支持属性元编程的关键特殊属性、内置函数和特殊方法。

进一步阅读

属性处理和内省内置函数的官方文档位于Python 标准库第二章,“内置函数”中。相关的特殊方法和__slots__特殊属性在Python 语言参考“3.3.2. 自定义属性访问”中有文档。解释了绕过实例调用特殊方法的语义在“3.3.9. 特殊方法查找”中。在Python 标准库的第四章,“内置类型”中,“4.13. 特殊属性”涵盖了__class____dict__属性。

Python Cookbook,第 3 版,作者 David Beazley 和 Brian K. Jones(O’Reilly)包含了本章主题的几个示例,但我将重点介绍三个杰出的示例:“Recipe 8.8. 在子类中扩展属性”解决了从超类继承的属性内部方法覆盖的棘手问题;“Recipe 8.15. 委托属性访问”实现了一个代理类,展示了本书中“属性处理的特殊方法”中的大多数特殊方法;以及令人印象深刻的“Recipe 9.21. 避免重复的属性方法”,这是在示例 22-28 中呈现的属性工厂函数的基础。

Python in a Nutshell, 第三版,由 Alex Martelli, Anna Ravenscroft, 和 Steve Holden (O’Reilly) 是严谨和客观的。他们只用了三页来讨论属性,但这是因为该书遵循了公理化的展示风格:前面的 15 页左右提供了对 Python 类语义的彻底描述,包括描述符,这是属性在幕后实际上是如何实现的。所以当 Martelli 等人讨论属性时,他们在这三页中包含了许多见解—包括我选择用来开启本章的内容。

Bertrand Meyer—在本章开头引用的统一访问原则定义中—开创了契约式设计方法,设计了 Eiffel 语言,并撰写了优秀的 面向对象软件构造,第二版 (Pearson)。前六章提供了我见过的最好的面向对象分析和设计的概念介绍之一。第十一章介绍了契约式设计,第三十五章提供了 Meyer 对一些有影响力的面向对象语言的评估:Simula、Smalltalk、CLOS (Common Lisp Object System)、Objective-C、C++ 和 Java,并简要评论了其他一些语言。直到书的最后一页,他才透露他所使用的易读的伪代码“符号”是 Eiffel。

¹ Alex Martelli, Anna Ravenscroft, 和 Steve Holden, Python in a Nutshell, 第三版 (O’Reilly), 第 123 页。

² Bertrand Meyer, 面向对象软件构造,第二版 (Pearson),第 57 页。

³ OSCON—O’Reilly 开源大会—成为了 COVID-19 大流行的牺牲品。我用于这些示例的原始 744 KB JSON 文件在 2021 年 1 月 10 日之后不再在线。你可以在osconfeed.json 的示例代码库中找到一份副本。

⁴ 两个例子分别是AttrDictaddict

⁵ 表达式 self.__data[name] 是可能发生 KeyError 异常的地方。理想情况下,应该处理它并引发 AttributeError,因为这是从 __getattr__ 中期望的。勤奋的读者被邀请将错误处理编码为练习。

⁶ 数据的来源是 JSON,而 JSON 数据中唯一的集合类型是 dictlist

⁷ 顺便说一句,Bunch 是 Alex Martelli 用来分享这个提示的类的名称,这个提示来自于 2001 年的一篇名为“简单但方便的‘一堆命名东西’类”的食谱

⁸ 这实际上是 Meyer 的统一访问原则的一个缺点,我在本章开头提到过。如果你对这个讨论感兴趣,可以阅读可选的“讲台”。

⁹ 来源:@functools.cached_property 文档。我知道 Raymond Hettinger 撰写了这份解释,因为他是作为我提出问题的回应而撰写的:bpo42781—functools.cached_property 文档应该解释它是非覆盖的。Hettinger 是官方 Python 文档和标准库的主要贡献者。他还撰写了优秀的“描述符指南”,这是第二十三章的重要资源。

¹⁰ 杰夫·贝佐斯在 华尔街日报 的报道“一个推销员的诞生”中的直接引用(2011 年 10 月 15 日)。请注意,截至 2021 年,您需要订阅才能阅读这篇文章。

¹¹ 这段代码改编自《Python Cookbook》第 3 版的“食谱 9.21。避免重复的属性方法”,作者是 David Beazley 和 Brian K. Jones(O’Reilly)。

¹² 这血腥场面在 2021 年 10 月我审阅时在 Youtube 上可供观看

¹³ Alex Martelli 指出,虽然__slots__可以编码为一个list,但最好明确地始终使用一个tuple,因为在类体被处理后更改__slots__中的列表没有效果,因此在那里使用可变序列会产生误导。

¹⁴ Alex Martelli,《Python 速查手册》,第 2 版(O’Reilly),第 101 页。

¹⁵ 我即将提到的原因在《Dr. Dobbs Journal》的文章中提到,标题为“Java 的新特性有害”,作者是 Jonathan Amsterdam,以及在屡获殊荣的书籍Effective Java第 3 版的“考虑使用静态工厂方法代替构造函数”中,作者是 Joshua Bloch(Addison-Wesley)。

第二十三章:属性描述符

了解描述符不仅提供了更大的工具集,还深入了解了 Python 的工作原理,并欣赏了其设计的优雅之处。

Raymond Hettinger,Python 核心开发者和专家¹

描述符是在多个属性中重用相同访问逻辑的一种方式。例如,在 ORM 中,如 Django ORM 和 SQLAlchemy 中的字段类型是描述符,管理数据从数据库记录中的字段流向 Python 对象属性,反之亦然。

描述符是实现由__get____set____delete__方法组成的动态协议的类。property类实现了完整的描述符协议。与动态协议一样,部分实现是可以的。事实上,我们在实际代码中看到的大多数描述符只实现了__get____set__,许多只实现了这些方法中的一个。

描述符是 Python 的一个显著特征,不仅在应用程序级别部署,还在语言基础设施中部署。用户定义的函数是描述符。我们将看到描述符协议如何允许方法作为绑定或非绑定方法运行,具体取决于它们的调用方式。

理解描述符是掌握 Python 的关键。这就是本章的主题。

在本章中,我们将重构我们在“使用属性进行属性验证”中首次看到的大量食品示例,将属性替换为描述符。这将使在不同类之间重用属性验证逻辑变得更容易。我们将解决覆盖和非覆盖描述符的概念,并意识到 Python 函数也是描述符。最后,我们将看到一些关于实现描述符的提示。

本章的新内容

由于 Python 3.6 中添加了描述符协议的__set_name__特殊方法,“LineItem Take #4: 自动命名存储属性”中的Quantity描述符示例得到了极大简化。

我删除了以前在“LineItem Take #4: 自动命名存储属性”中的属性工厂示例,因为它变得无关紧要:重点是展示解决Quantity问题的另一种方法,但随着__set_name__的添加,描述符解决方案变得简单得多。

以前出现在“LineItem Take #5: 新的描述符类型”中的AutoStorage类也消失了,因为__set_name__使其变得过时。

描述符示例:属性验证

正如我们在“编写属性工厂”中看到的,属性工厂是一种避免重复编写获取器和设置器的方法,通过应用函数式编程模式来实现。属性工厂是一个高阶函数,它创建一个参数化的访问器函数集,并从中构建一个自定义属性实例,使用闭包来保存像storage_name这样的设置。解决相同问题的面向对象方式是使用描述符类。

我们将继续之前留下的LineItem示例系列,在“编写属性工厂”中,通过将quantity属性工厂重构为Quantity描述符类来使其更易于使用。

LineItem Take #3: 一个简单的描述符

正如我们在介绍中所说,实现__get____set____delete__方法的类是描述符。您通过将其实例声明为另一个类的类属性来使用描述符。

我们将创建一个Quantity描述符,LineItem类将使用两个Quantity实例:一个用于管理weight属性,另一个用于price。图表有助于理解,所以看一下图 23-1。

图 23-1。LineItem使用名为Quantity的描述符类的 UML 类图。在 UML 中带有下划线的属性是类属性。请注意,weight 和 price 是附加到LineItem类的Quantity实例,但LineItem实例也有自己的 weight 和 price 属性,其中存储这些值。

请注意,单词weight在图 23-1 中出现两次,因为实际上有两个名为weight的不同属性:一个是LineItem的类属性,另一个是将存在于每个LineItem对象中的实例属性。price也适用于此。

理解描述符的术语

实现和使用描述符涉及几个组件,精确命名这些组件是很有用的。在本章的示例中,我将使用以下术语和定义来描述。一旦看到代码,它们将更容易理解,但我想提前列出这些定义,以便您在需要时可以参考它们。

描述符类

实现描述符协议的类。在图 23-1 中就是Quantity

托管类

声明描述符实例为类属性的类。在图 23-1 中,LineItem是托管类。

描述符实例

每个描述符类的实例,声明为托管类的类属性。在图 23-1 中,每个描述符实例由一个带有下划线名称的组合箭头表示(下划线表示 UML 中的类属性)。黑色菱形接触LineItem类,其中包含描述符实例。

托管实例

托管类的一个实例。在这个例子中,LineItem实例是托管实例(它们没有显示在类图中)。

存储属性

托管实例的属性,保存该特定实例的托管属性的值。在图 23-1 中,LineItem实例的属性weightprice是存储属性。它们与描述符实例不同,后者始终是类属性。

托管属性

托管类中的公共属性,由描述符实例处理,值存储在存储属性中。换句话说,描述符实例和存储属性为托管属性提供基础设施。

重要的是要意识到Quantity实例是LineItem的类属性。这一关键点在图 23-2 中由磨坊和小玩意突出显示。

图 23-2。使用 MGN(磨坊和小玩意符号)注释的 UML 类图:类是生产小玩意的磨坊。Quantity磨坊生成两个带有圆形头部的小玩意,它们附加到LineItem磨坊:weight 和 price。LineItem磨坊生成具有自己的 weight 和 price 属性的矩形小玩意,其中存储这些值。

现在足够涂鸦了。这里是代码:示例 23-1 展示了Quantity描述符类,示例 23-2 列出了使用两个Quantity实例的新LineItem类。

示例 23-1。bulkfood_v3.py:Quantity描述符不接受负值
class Quantity:  # ①
    def __init__(self, storage_name):
        self.storage_name = storage_name  # ②
    def __set__(self, instance, value):  # ③
        if value > 0:
            instance.__dict__[self.storage_name] = value  # ④
        else:
            msg = f'{self.storage_name} must be > 0'
            raise ValueError(msg)
    def __get__(self, instance, owner):  # ⑤
        return instance.__dict__[self.storage_name]

描述符是基于协议的特性;不需要子类化来实现。

每个Quantity实例都将有一个storage_name属性:这是用于在托管实例中保存值的存储属性的名称。

当尝试对托管属性进行赋值时,将调用__set__。在这里,self是描述符实例(即LineItem.weightLineItem.price),instance是托管实例(一个LineItem实例),value是正在分配的值。

我们必须直接将属性值存储到__dict__中;调用setattr(instance, self.storage_name)将再次触发__set__方法,导致无限递归。

我们需要实现__get__,因为被管理属性的名称可能与storage_name不同。owner参数将很快解释。

实现__get__是必要的,因为用户可能会编写类似于这样的内容:

class House:
    rooms = Quantity('number_of_rooms')

House类中,被管理的属性是rooms,但存储属性是number_of_rooms。给定一个名为chaos_manorHouse实例,读取和写入chaos_manor.rooms会通过附加到roomsQuantity描述符实例,但读取和写入chaos_manor.number_of_rooms会绕过描述符。

请注意,__get__接收三个参数:selfinstanceownerowner参数是被管理类的引用(例如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')  # ①
    price = Quantity('price')  # ②
    def __init__(self, description, weight, price):  # ③
        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
警告

在编写描述符__get____set__方法时,请记住selfinstance参数的含义:self是描述符实例,instance是被管理实例。管理实例属性的描述符应将值存储在被管理实例中。这就是为什么 Python 提供instance参数给描述符方法的原因。

存储每个被管理属性的值在描述符实例本身中可能很诱人,但是错误的。换句话说,在__set__方法中,而不是编写:

instance.__dict__[self.storage_name] = value

诱人但错误的替代方案是:

self.__dict__[self.storage_name] = value

要理解为什么这样做是错误的,请考虑__set__的前两个参数的含义:selfinstance。这里,self是描述符实例,实际上是被管理类的类属性。您可能在内存中同时拥有成千上万个LineItem实例,但只有两个描述符实例:类属性LineItem.weightLineItem.price。因此,您存储在描述符实例本身中的任何内容实际上是LineItem类属性的一部分,因此在所有LineItem实例之间共享。

示例 23-2 的一个缺点是在被管理类主体中实例化描述符时需要重复属性名称。如果LineItem类可以这样声明就好了:

class LineItem:
    weight = Quantity()
    price = Quantity()
    # remaining methods as before

目前,示例 23-2 需要显式命名每个Quantity,这不仅不方便,而且很危险。如果一个程序员复制粘贴代码时忘记编辑两个名称,并写出类似price = Quantity('weight')的内容,程序将表现糟糕,每当设置price时都会破坏weight的值。

问题在于——正如我们在第六章中看到的——赋值的右侧在变量存在之前执行。表达式Quantity()被评估为创建一个描述符实例,而Quantity类中的代码无法猜测描述符将绑定到的变量的名称(例如weightprice)。

幸运的是,描述符协议现在支持名为__set_name__的特殊方法。我们将看到如何使用它。

注意

描述符存储属性的自动命名曾经是一个棘手的问题。在流畅的 Python第一版中,我在本章和下一章中花了几页和几行代码来介绍不同的解决方案,包括使用类装饰器,然后在第二十四章中使用元类。这在 Python 3.6 中得到了极大简化。

LineItem 第 4 版:自动命名存储属性

为了避免在描述符实例中重新输入属性名称,我们将实现__set_name__来设置每个Quantity实例的storage_name__set_name__特殊方法在 Python 3.6 中添加到描述符协议中。解释器在class体中找到的每个描述符上调用__set_name__——如果描述符实现了它。⁴

在示例 23-3 中,LineItem描述符类不需要__init__。相反,__set_item__保存了存储属性的名称。

示例 23-3. bulkfood_v4.py:__set_name__为每个Quantity描述符实例设置名称
class Quantity:
    def __set_name__(self, owner, name):  # ①
        self.storage_name = name          # ②
    def __set__(self, instance, value):   # ③
        if value > 0:
            instance.__dict__[self.storage_name] = value
        else:
            msg = f'{self.storage_name} must be > 0'
            raise ValueError(msg)
    # no __get__ needed # ④
class LineItem:
    weight = Quantity()  # ⑤
    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是托管类,nameowner的属性的名称,在owner的类体中将此描述符实例分配给的名称。

这就是示例 23-1 中的__init__所做的事情。

这里的__set__方法与示例 23-1 中完全相同。

实现__get__是不必要的,因为存储属性的名称与托管属性的名称匹配。表达式product.price直接从LineItem实例获取price属性。

现在我们不需要将托管属性名称传递给Quantity构造函数。这是这个版本的目标。

查看示例 23-3,您可能会认为这是为了管理几个属性而编写的大量代码,但重要的是要意识到描述符逻辑现在抽象为一个单独的代码单元:Quantity类。通常我们不会在使用它的同一模块中定义描述符,而是在一个专门设计用于跨应用程序使用的实用程序模块中定义描述符——即使在许多应用程序中,如果您正在开发一个库或框架。

有了这个想法,示例 23-4 更好地代表了描述符的典型用法。

流畅的 Python 第二版(GPT 重译)(十二)(4)https://developer.aliyun.com/article/1485178

相关文章
|
13天前
|
存储 缓存 安全
流畅的 Python 第二版(GPT 重译)(十二)(2)
流畅的 Python 第二版(GPT 重译)(十二)
68 1
|
13天前
|
设计模式 存储 缓存
流畅的 Python 第二版(GPT 重译)(十二)(4)
流畅的 Python 第二版(GPT 重译)(十二)
42 1
|
13天前
|
存储 JSON 缓存
流畅的 Python 第二版(GPT 重译)(十二)(1)
流畅的 Python 第二版(GPT 重译)(十二)
63 1
|
13天前
|
网络协议 JavaScript 前端开发
流畅的 Python 第二版(GPT 重译)(十一)(3)
流畅的 Python 第二版(GPT 重译)(十一)
48 1
|
网络协议 JavaScript API
流畅的 Python 第二版(GPT 重译)(十一)(4)
流畅的 Python 第二版(GPT 重译)(十一)
36 0
|
存储 网络协议 Java
流畅的 Python 第二版(GPT 重译)(十一)(2)
流畅的 Python 第二版(GPT 重译)(十一)
79 1
|
JavaScript 前端开发 Java
流畅的 Python 第二版(GPT 重译)(十一)(1)
流畅的 Python 第二版(GPT 重译)(十一)
84 1
|
13天前
|
存储 Python
流畅的 Python 第二版(GPT 重译)(十)(2)
流畅的 Python 第二版(GPT 重译)(十)
21 0
|
Linux 数据库 iOS开发
流畅的 Python 第二版(GPT 重译)(二)(4)
流畅的 Python 第二版(GPT 重译)(二)
48 5
|
13天前
|
存储 IDE JavaScript
流畅的 Python 第二版(GPT 重译)(四)(2)
流畅的 Python 第二版(GPT 重译)(四)
40 1