流畅的 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
现在我们已经掌握了这些属性的基本要点,让我们回到保护 LineItem
的 weight
和 price
属性只接受大于零的值的问题上来,但不需要手动实现两个几乎相同的 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
。
③
这里属性已经激活,确保拒绝负数或 0
的 weight
。
④
这些属性也在此处使用,检索存储在实例中的值。
请记住属性是类属性。在构建每个 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_getter
和 qty_setter
函数是通用的,它们依赖于 storage_name
变量来知道在实例 __dict__
中获取/设置托管属性的位置。每次调用 quantity
工厂来构建属性时,storage_name
必须设置为一个唯一的值。
函数 qty_getter
和 qty_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}
①
通过遮蔽同名实例属性的属性来读取 weight
和 price
。
②
使用 vars
检查 nutmeg
实例:这里我们看到用于存储值的实际实例属性。
注意我们的工厂构建的属性如何利用 “属性覆盖实例属性” 中描述的行为:weight
属性覆盖了 weight
实例属性,以便每个对 self.weight
或 nutmeg.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
。
用于属性处理的特殊方法
当在用户定义的类中实现时,这里列出的特殊方法处理属性的检索、设置、删除和列出。
使用点符号表示法或内置函数getattr
、hasattr
和setattr
访问属性会触发这里列出的适当的特殊方法。直接在实例__dict__
中读取和写入属性不会触发这些特殊方法——这是需要绕过它们的常用方式。
章节“3.3.11. 特殊方法查找”中的“数据模型”一章警告:
对于自定义类,只有在对象的类型上定义了特殊方法时,隐式调用特殊方法才能保证正确工作,而不是在对象的实例字典中定义。
换句话说,假设特殊方法将在类本身上检索,即使操作的目标是实例。因此,特殊方法不会被具有相同名称的实例属性遮蔽。
在以下示例中,假设有一个名为Class
的类,obj
是Class
的一个实例,attr
是obj
的一个属性。
对于这些特殊方法中的每一个,无论是使用点符号表示法还是“用于属性处理的内置函数”中列出的内置函数之一,都没有关系。例如,obj.attr
和getattr(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)
仅在尝试检索命名属性失败时调用,之后搜索obj
、Class
及其超类。表达式obj.no_such_attr
、getattr(obj, 'no_such_attr')
和hasattr(obj, 'no_such_attr')
可能会触发Class.__getattr__(obj, 'no_such_attr')
,但仅当在obj
或Class
及其超类中找不到该名称的属性时。
__getattribute__(self, name)
当尝试直接从 Python 代码中检索命名属性时始终调用(解释器在某些情况下可能会绕过此方法,例如获取__repr__
方法)。点符号表示法和getattr
以及hasattr
内置函数会触发此方法。__getattr__
仅在__getattribute__
之后调用,并且仅在__getattribute__
引发AttributeError
时才会调用。为了检索实例obj
的属性而不触发无限递归,__getattribute__
的实现应该使用super().__getattribute__(obj, name)
。
__setattr__(self, name, value)
当尝试设置命名属性时始终调用。点符号和setattr
内置触发此方法;例如,obj.attr = 42
和setattr(obj, 'attr', 42)
都会触发Class.__setattr__(obj, 'attr', 42)
。
警告
实际上,因为它们被无条件调用并影响几乎每个属性访问,__getattribute__
和__setattr__
特殊方法比__getattr__
更难正确使用,后者仅处理不存在的属性名称。使用属性或描述符比定义这些特殊方法更不容易出错。
这结束了我们对属性、特殊方法和其他编写动态属性技术的探讨。
章节总结
我们通过展示简单类的实际示例来开始动态属性的覆盖。第一个示例是FrozenJSON
类,它将嵌套的字典和列表转换为嵌套的FrozenJSON
实例和它们的列表。FrozenJSON
代码演示了使用__getattr__
特殊方法在读取属性时动态转换数据结构。FrozenJSON
的最新版本展示了使用__new__
构造方法将一个类转换为灵活的对象工厂,不限于自身的实例。
然后,我们将 JSON 数据集转换为存储Record
类实例的dict
。Record
的第一个版本只有几行代码,并引入了“bunch”习惯用法:使用self.__dict__.update(**kwargs)
从传递给__init__
的关键字参数构建任意属性。第二次迭代添加了Event
类,通过属性实现自动检索链接记录。计算属性值有时需要缓存,我们介绍了几种方法。
在意识到@functools.cached_property
并非总是适用后,我们了解了一种替代方法:按顺序将@property
与@functools.cache
结合使用。
属性的覆盖继续在LineItem
类中进行,其中部署了一个属性来保护weight
属性免受没有业务意义的负值或零值的影响。在更深入地了解属性语法和语义之后,我们创建了一个属性工厂,以强制在weight
和price
上执行相同的验证,而无需编写多个 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 的示例代码库中找到一份副本。
⁵ 表达式 self.__data[name]
是可能发生 KeyError
异常的地方。理想情况下,应该处理它并引发 AttributeError
,因为这是从 __getattr__
中期望的。勤奋的读者被邀请将错误处理编码为练习。
⁶ 数据的来源是 JSON,而 JSON 数据中唯一的集合类型是 dict
和 list
。
⁷ 顺便说一句,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
实例的属性weight
和price
是存储属性。它们与描述符实例不同,后者始终是类属性。
托管属性
托管类中的公共属性,由描述符实例处理,值存储在存储属性中。换句话说,描述符实例和存储属性为托管属性提供基础设施。
重要的是要意识到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.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
类中,被管理的属性是rooms
,但存储属性是number_of_rooms
。给定一个名为chaos_manor
的House
实例,读取和写入chaos_manor.rooms
会通过附加到rooms
的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') # ① 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__
方法时,请记住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
的值。
问题在于——正如我们在第六章中看到的——赋值的右侧在变量存在之前执行。表达式Quantity()
被评估为创建一个描述符实例,而Quantity
类中的代码无法猜测描述符将绑定到的变量的名称(例如weight
或price
)。
幸运的是,描述符协议现在支持名为__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
是托管类,name
是owner
的属性的名称,在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