流畅的 Python 第二版(GPT 重译)(六)(2)https://developer.aliyun.com/article/1484607
章节总结
本章的目的是演示在构建一个良好的 Python 类时使用特殊方法和约定。
vector2d_v3.py(在 示例 11-11 中显示)比 vector2d_v0.py(在 示例 11-2 中显示)更符合 Python 风格吗?vector2d_v3.py 中的 Vector2d
类显然展示了更多的 Python 特性。但是第一个或最后一个 Vector2d
实现是否合适取决于它将被使用的上下文。Tim Peter 的“Python 之禅”说:
简单胜于复杂。
对象应该尽可能简单,符合需求,而不是语言特性的大杂烩。如果代码是为了一个应用程序,那么它应该专注于支持最终用户所需的内容,而不是更多。如果代码是为其他程序员使用的库,那么实现支持 Python 程序员期望的特殊方法是合理的。例如,__eq__
可能不是支持业务需求所必需的,但它使类更容易测试。
我在扩展 Vector2d
代码的目标是为了讨论 Python 特殊方法和编码约定提供背景。本章的示例演示了我们在 Table 1-1(第一章)中首次看到的几个特殊方法:
- 字符串/字节表示方法:
__repr__
、__str__
、__format__
和__bytes__
- 将对象转换为数字的方法:
__abs__
、__bool__
和__hash__
__eq__
运算符,用于支持测试和哈希(以及__hash__
)
在支持转换为 bytes
的同时,我们还实现了一个替代构造函数 Vector2d.frombytes()
,这为讨论装饰器 @classmethod
(非常方便)和 @staticmethod
(不太有用,模块级函数更简单)提供了背景。frombytes
方法受到了 array.array
类中同名方法的启发。
我们看到 格式规范迷你语言 可通过实现 __format__
方法来扩展,该方法解析提供给 format(obj, format_spec)
内置函数或在 f-strings 中使用的替换字段 '{:«format_spec»}'
中的 format_spec
。
为了使 Vector2d
实例可哈希,我们努力使它们是不可变的,至少通过将 x
和 y
属性编码为私有属性,然后将它们公开为只读属性来防止意外更改。然后,我们使用推荐的异或实例属性哈希的技术实现了 __hash__
。
我们随后讨论了在 Vector2d
中声明 __slots__
属性的内存节省和注意事项。因为使用 __slots__
会产生副作用,所以只有在处理非常大量的实例时才是有意义的——考虑的是百万级的实例,而不仅仅是千个。在许多这种情况下,使用 pandas 可能是最佳选择。
我们讨论的最后一个主题是覆盖通过实例访问的类属性(例如,self.typecode
)。我们首先通过创建实例属性,然后通过子类化和在类级别上重写来实现。
在整个章节中,我提到示例中的设计选择是通过研究标准 Python 对象的 API 而得出的。如果这一章可以用一句话总结,那就是:
要构建 Pythonic 对象,观察真实的 Python 对象的行为。
古老的中国谚语
进一步阅读
本章涵盖了数据模型的几个特殊方法,因此主要参考资料与第一章中提供的相同,该章节提供了相同主题的高层次视图。为方便起见,我将在此重复之前的四个推荐,并添加一些其他的:
Python 语言参考的“数据模型”章节
我们在本章中使用的大多数方法在“3.3.1.基本自定义”中有文档记录。
Python 速查手册, 第 3 版,作者 Alex Martelli, Anna Ravenscroft 和 Steve Holden
深入讨论了特殊方法。
Python 食谱, 第 3 版,作者 David Beazley 和 Brian K. Jones
通过示例演示了现代 Python 实践。特别是第八章“类和对象”中有几个与本章讨论相关的解决方案。
Python 基础参考, 第 4 版,作者 David Beazley
详细介绍了数据模型,即使只涵盖了 Python 2.6 和 3.0(在第四版中)。基本概念都是相同的,大多数数据模型 API 自 Python 2.2 以来都没有改变,当时内置类型和用户定义类被统一起来。
在 2015 年,我完成第一版流畅的 Python时,Hynek Schlawack 开始了attrs
包。从attrs
文档中:
attrs
是 Python 包,通过解除你实现对象协议(也称为 dunder 方法)的繁琐,为编写类带来乐趣。
我在“进一步阅读”中提到attrs
作为@dataclass
的更强大替代品。来自第五章的数据类构建器以及attrs
会自动为你的类配备几个特殊方法。但了解如何自己编写这些特殊方法仍然是必要的,以理解这些包的功能,决定是否真正需要它们,并在必要时覆盖它们生成的方法。
在本章中,我们看到了与对象表示相关的所有特殊方法,除了__index__
和__fspath__
。我们将在第十二章中讨论__index__
,“一个切片感知的 getitem”。我不会涉及__fspath__
。要了解更多信息,请参阅PEP 519—添加文件系统路径协议。
早期意识到对象需要不同的字符串表示的需求出现在 Smalltalk 中。1996 年 Bobby Woolf 的文章“如何将对象显示为字符串:printString 和 displayString”讨论了该语言中printString
和displayString
方法的实现。从那篇文章中,我借用了“开发者想要看到的方式”和“用户想要看到的方式”这两个简洁的描述,用于定义repr()
和str()
在“对象表示”中。
¹ 来自 Faassen 的博客文章“什么是 Pythonic?”
² 我在这里使用eval
来克隆对象只是为了说明repr
;要克隆一个实例,copy.copy
函数更安全更快。
³ 这一行也可以写成yield self.x; yield.self.y
。关于__iter__
特殊方法、生成器表达式和yield
关键字,我在第十七章中还有很多要说。
⁴ 我们在“内存视图”中简要介绍了memoryview
,解释了它的.cast
方法。
⁵ 本书的技术审阅员之一 Leonardo Rochael 不同意我对 staticmethod
的低评价,并推荐 Julien Danjou 的博文“如何在 Python 中使用静态、类或抽象方法的权威指南”作为反驳意见。Danjou 的文章非常好;我推荐它。但这并不足以改变我的对 staticmethod
的看法。你需要自己决定。
⁶ 私有属性的利弊是即将到来的“Python 中的私有和‘受保护’属性”的主题。
⁷ 来自“粘贴风格指南”。
⁸ 在模块中,顶层名称前的单个 _
确实有影响:如果你写 from mymod import *
,带有 _
前缀的名称不会从 mymod
中导入。然而,你仍然可以写 from mymod import _privatefunc
。这在Python 教程,第 6.1 节,“关于模块的更多内容”中有解释。
⁹ 一个例子在gettext 模块文档中。
¹⁰ 如果这种情况让你沮丧,并且让你希望 Python 在这方面更像 Java,那就不要阅读我对 Java private
修饰符相对强度的讨论,见“Soapbox”。
¹¹ 参见“可能的最简单的工作方式:与沃德·坎宁安的对话,第五部分”。
第十二章:序列的特殊方法
不要检查它是否是一只鸭子:检查它是否像一只鸭子一样嘎嘎叫,走路,等等,具体取决于你需要与之进行语言游戏的鸭子行为子集。(
comp.lang.python
,2000 年 7 月 26 日)Alex Martelli
在本章中,我们将创建一个表示多维Vector
类的类——这是从第十一章的二维Vector2d
中迈出的重要一步。Vector
将表现得像一个标准的 Python 不可变的扁平序列。它的元素将是浮点数,并且在本章结束时将支持以下功能:
- 基本序列协议:
__len__
和__getitem__
- 安全表示具有许多项目的实例
- 适当的切片支持,生成新的
Vector
实例 - 聚合哈希,考虑每个包含元素的值
- 自定义格式化语言扩展
我们还将使用__getattr__
实现动态属性访问,以替换我们在Vector2d
中使用的只读属性——尽管这不是序列类型的典型做法。
代码密集的展示将被一个关于协议作为非正式接口的概念讨论所打断。我们将讨论协议和鸭子类型的关系,以及当你创建自己的类型时的实际影响。
本章的新内容
本章没有重大变化。在“协议和鸭子类型”末尾附近的提示框中有一个新的typing.Protocol
的简短讨论。
在“一个切片感知的 getitem”中,示例 12-6 中__getitem__
的实现比第一版更简洁和健壮,这要归功于鸭子类型和operator.index
。这种变化延续到了本章和第十六章中对Vector
的后续实现。
让我们开始吧。
Vector:用户定义的序列类型
我们实现Vector
的策略将是使用组合,而不是继承。我们将把分量存储在一个浮点数的数组中,并将实现Vector
所需的方法,使其表现得像一个不可变的扁平序列。
但在我们实现序列方法之前,让我们确保我们有一个基线实现的Vector
,它与我们先前的Vector2d
类兼容——除非这种兼容性没有意义。
Vector 第一版:与 Vector2d 兼容
Vector
的第一个版本应尽可能与我们先前的Vector2d
类兼容。
但是,按设计,Vector
构造函数与Vector2d
构造函数不兼容。我们可以通过在__init__
中使用*args
来接受任意数量的参数使Vector(3, 4)
和Vector(3, 4, 5)
起作用,但是序列构造函数的最佳实践是在构造函数中将数据作为可迭代参数接受,就像所有内置序列类型一样。示例 12-1 展示了实例化我们新的Vector
对象的一些方法。
示例 12-1。Vector.__init__
和Vector.__repr__
的测试
>>> Vector([3.1, 4.2]) Vector([3.1, 4.2]) >>> Vector((3, 4, 5)) Vector([3.0, 4.0, 5.0]) >>> Vector(range(10)) Vector([0.0, 1.0, 2.0, 3.0, 4.0, ...])
除了一个新的构造函数签名外,我确保了我对Vector2d
(例如,Vector2d(3, 4)
)进行的每个测试都通过并产生了与两个分量Vector([3, 4])
相同的结果。
警告
当一个Vector
有超过六个分量时,repr()
产生的字符串会被缩写为...
,就像在示例 12-1 的最后一行中看到的那样。这在可能包含大量项目的任何集合类型中至关重要,因为repr
用于调试,你不希望一个大对象在控制台或日志中跨越数千行。使用reprlib
模块生成有限长度的表示,就像示例 12-2 中那样。reprlib
模块在 Python 2.7 中被命名为repr
。
示例 12-2 列出了我们第一个版本的Vector
的实现(此示例基于示例 11-2 和 11-3 中显示的代码)。
示例 12-2. vector_v1.py:派生自 vector2d_v1.py
from array import array import reprlib import math class Vector: typecode = 'd' def __init__(self, components): self._components = array(self.typecode, components) # ① def __iter__(self): return iter(self._components) # ② def __repr__(self): components = reprlib.repr(self._components) # ③ components = components[components.find('['):-1] # ④ return f'Vector({components})' def __str__(self): return str(tuple(self)) def __bytes__(self): return (bytes([ord(self.typecode)]) + bytes(self._components)) # ⑤ def __eq__(self, other): return tuple(self) == tuple(other) def __abs__(self): return math.hypot(*self) # ⑥ def __bool__(self): return bool(abs(self)) @classmethod def frombytes(cls, octets): typecode = chr(octets[0]) memv = memoryview(octets[1:]).cast(typecode) return cls(memv) # ⑦
①
self._components
实例“受保护”的属性将保存带有Vector
组件的array
。
②
为了允许迭代,我们返回一个self._components
上的迭代器。¹
③
使用reprlib.repr()
获取self._components
的有限长度表示(例如,array('d', [0.0, 1.0, 2.0, 3.0, 4.0, ...])
)。
④
在将字符串插入Vector
构造函数调用之前,删除array('d',
前缀和尾随的)
。
⑤
直接从self._components
构建一个bytes
对象。
⑥
自 Python 3.8 起,math.hypot
接受 N 维点。我之前使用过这个表达式:math.sqrt(sum(x * x for x in self))
。
⑦
与之前的frombytes
唯一需要更改的地方在于最后一行:我们直接将memoryview
传递给构造函数,而不像之前那样使用*
进行解包。
我使用reprlib.repr
的方式值得一提。该函数通过限制输出字符串的长度并用'...'
标记截断来生成大型或递归结构的安全表示。我希望Vector
的repr
看起来像Vector([3.0, 4.0, 5.0])
而不是Vector(array('d', [3.0, 4.0, 5.0]))
,因为Vector
内部有一个array
是一个实现细节。因为这些构造函数调用构建了相同的Vector
对象,我更喜欢使用带有list
参数的更简单的语法。
在编写__repr__
时,我本可以使用这个表达式生成简化的components
显示:reprlib.repr(list(self._components))
。然而,这样做是浪费的,因为我需要将每个项从self._components
复制到一个list
中,只是为了使用list
的repr
。相反,我决定直接将reprlib.repr
应用于self._components
数组,并在[]
之外截断字符。这就是示例 12-2 中__repr__
的第二行所做的事情。
提示
由于在调试中的作用,对对象调用repr()
不应引发异常。如果在__repr__
的实现中出现问题,您必须处理该问题,并尽力产生一些可用的输出,以便用户有机会识别接收者(self
)。
请注意,__str__
、__eq__
和__bool__
方法与Vector2d
中保持不变,frombytes
中只有一个字符发生了变化(最后一行删除了一个*
)。这是使原始Vector2d
可迭代的好处之一。
顺便说一句,我们本可以从Vector2d
中派生Vector
,但出于两个原因我选择不这样做。首先,不兼容的构造函数确实使得子类化不可取。我可以通过在__init__
中进行一些巧妙的参数处理来解决这个问题,但第二个原因更重要:我希望Vector
是一个独立的实现序列协议的类的示例。这就是我们接下来要做的事情,在讨论术语协议之后。
协议和鸭子类型
早在第一章中,我们就看到在 Python 中创建一个完全功能的序列类型并不需要继承任何特殊类;你只需要实现满足序列协议的方法。但我们在谈论什么样的协议呢?
在面向对象编程的上下文中,协议是一种非正式接口,仅在文档中定义,而不在代码中定义。例如,在 Python 中,序列协议仅包括__len__
和__getitem__
方法。任何实现这些方法的类Spam
,具有标准签名和语义,都可以在期望序列的任何地方使用。Spam
是这个或那个的子类无关紧要;重要的是它提供了必要的方法。我们在示例 1-1 中看到了这一点,在示例 12-3 中重现。
示例 12-3。示例 1-1 中的代码,这里为方便起见重现
import collections Card = collections.namedtuple('Card', ['rank', 'suit']) class FrenchDeck: ranks = [str(n) for n in range(2, 11)] + list('JQKA') suits = 'spades diamonds clubs hearts'.split() def __init__(self): self._cards = [Card(rank, suit) for suit in self.suits for rank in self.ranks] def __len__(self): return len(self._cards) def __getitem__(self, position): return self._cards[position]
示例 12-3 中的FrenchDeck
类利用了许多 Python 的功能,因为它实现了序列协议,即使在代码中没有声明。有经验的 Python 编程人员会查看它并理解它是一个序列,即使它是object
的子类。我们说它是一个序列,因为它行为像一个序列,这才是重要的。
这被称为鸭子类型,源自亚历克斯·马特利在本章开头引用的帖子。
因为协议是非正式且不受强制执行的,所以如果您知道类将被使用的特定上下文,通常可以只实现协议的一部分。例如,为了支持迭代,只需要__getitem__
;不需要提供__len__
。
提示
使用PEP 544—Protocols: Structural subtyping (static duck typing),Python 3.8 支持协议类:typing
构造,我们在“静态协议”中学习过。Python 中这个新用法的“协议”一词具有相关但不同的含义。当我需要区分它们时,我会写静态协议来指代协议类中规范化的协议,而动态协议则指传统意义上的协议。一个关键区别是静态协议实现必须提供协议类中定义的所有方法。第十三章的“两种协议”有更多细节。
我们现在将在Vector
中实现序列协议,最初没有适当的切片支持,但稍后会添加。
Vector 第二版:可切片序列
正如我们在FrenchDeck
示例中看到的,如果您可以将对象中的序列属性委托给一个序列属性,比如我们的self._components
数组,那么支持序列协议就非常容易。这些__len__
和__getitem__
一行代码是一个很好的开始:
class Vector: # many lines omitted # ... def __len__(self): return len(self._components) def __getitem__(self, index): return self._components[index]
有了这些补充,现在所有这些操作都可以正常工作:
>>> v1 = Vector([3, 4, 5]) >>> len(v1) 3 >>> v1[0], v1[-1] (3.0, 5.0) >>> v7 = Vector(range(7)) >>> v7[1:4] array('d', [1.0, 2.0, 3.0])
如您所见,即使支持切片,但并不是很好。如果Vector
的切片也是Vector
实例而不是array
,那将更好。旧的FrenchDeck
类也有类似的问题:当您对其进行切片时,会得到一个list
。在Vector
的情况下,当切片产生普通数组时,会丢失很多功能。
考虑内置序列类型:每一个,在切片时,都会产生自己类型的新实例,而不是其他类型的实例。
要使Vector
生成Vector
实例作为切片,我们不能简单地将切片委托给array
。我们需要分析在__getitem__
中获得的参数并做正确的事情。
现在,让我们看看 Python 如何将语法my_seq[1:3]
转换为my_seq.__getitem__(...)
的参数。
切片的工作原理
一个示例胜过千言万语,所以看看示例 12-4。
示例 12-4。检查__getitem__
和切片的行为
>>> class MySeq: ... def __getitem__(self, index): ... return index # ① ... >>> s = MySeq() >>> s[1] # ② 1 >>> s[1:4] # ③ slice(1, 4, None) >>> s[1:4:2] # ④ slice(1, 4, 2) >>> s[1:4:2, 9] # ⑤ (slice(1, 4, 2), 9) >>> s[1:4:2, 7:9] # ⑥ (slice(1, 4, 2), slice(7, 9, None))
①
对于这个演示,__getitem__
只是返回传递给它的任何内容。
②
单个索引,没什么新鲜事。
③
表示1:4
变为slice(1, 4, None)
。
④
slice(1, 4, 2)
意味着从 1 开始,到 4 结束,步长为 2。
⑤
惊喜:[]
内部有逗号意味着__getitem__
接收到一个元组。
⑥
元组甚至可以包含多个slice
对象。
现在让我们更仔细地看看slice
本身在示例 12-5 中。
示例 12-5。检查slice
类的属性
>>> slice # ① <class 'slice'> >>> dir(slice) # ② ['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'indices', 'start', 'step', 'stop']
①
slice
是一个内置类型(我们在“切片对象”中首次看到它)。
②
检查一个slice
,我们发现数据属性start
、stop
和step
,以及一个indices
方法。
在示例 12-5 中调用dir(slice)
会显示一个indices
属性,这个属性实际上是一个非常有趣但鲜为人知的方法。以下是help(slice.indices)
的内容:
S.indices(len) -> (start, stop, stride)
假设长度为len
的序列,计算由S
描述的扩展切片的start
和stop
索引以及stride
长度。超出边界的索引会像在正常切片中一样被截断。
换句话说,indices
暴露了内置序列中实现的复杂逻辑,以优雅地处理缺失或负索引以及比原始序列长的切片。这个方法生成针对给定长度序列的非负start
、stop
和stride
整数的“标准化”元组。
这里有几个例子,考虑一个长度为len == 5
的序列,例如,'ABCDE'
:
>>> slice(None, 10, 2).indices(5) # ① (0, 5, 2) >>> slice(-3, None, None).indices(5) # ② (2, 5, 1)
①
'ABCDE'[:10:2]
等同于'ABCDE'[0:5:2]
。
②
'ABCDE'[-3:]
等同于'ABCDE'[2:5:1]
。
在我们的Vector
代码中,我们不需要使用slice.indices()
方法,因为当我们得到一个切片参数时,我们将把它的处理委托给_components
数组。但是如果你不能依赖底层序列的服务,这个方法可以节省大量时间。
现在我们知道如何处理切片了,让我们看看改进的Vector.__getitem__
实现。
一个了解切片的__getitem__
示例 12-6 列出了使Vector
表现为序列所需的两个方法:__len__
和__getitem__
(后者现在已实现以正确处理切片)。
示例 12-6。vector_v2.py 的一部分:向Vector
类添加了__len__
和__getitem__
方法,这些方法来自 vector_v1.py(参见示例 12-2)
def __len__(self): return len(self._components) def __getitem__(self, key): if isinstance(key, slice): # ① cls = type(self) # ② return cls(self._components[key]) # ③ index = operator.index(key) # ④ return self._components[index] # ⑤
①
如果key
参数是一个slice
…
②
…获取实例的类(即Vector
)并…
③
…调用该类以从_components
数组的切片构建另一个Vector
实例。
④
如果我们可以从key
中得到一个index
…
⑤
…返回_components
中的特定项。
operator.index()
函数调用__index__
特殊方法。该函数和特殊方法在PEP 357—允许任何对象用于切片中定义,由 Travis Oliphant 提出,允许 NumPy 中的众多整数类型用作索引和切片参数。operator.index()
和int()
之间的关键区别在于前者是为此特定目的而设计的。例如,int(3.14)
返回3
,但operator.index(3.14)
会引发TypeError
,因为float
不应该用作索引。
注意
过度使用isinstance
可能是糟糕的面向对象设计的迹象,但在__getitem__
中处理切片是一个合理的用例。在第一版中,我还对key
进行了isinstance
测试,以测试它是否为整数。使用operator.index
避免了这个测试,并且如果无法从key
获取index
,则会引发带有非常详细信息的TypeError
。请参见示例 12-7 中的最后一个错误消息。
一旦将示例 12-6 中的代码添加到Vector
类中,我们就具有了适当的切片行为,正如示例 12-7 所示。
示例 12-7。增强的Vector.__getitem__
的测试,来自示例 12-6
>>> v7 = Vector(range(7)) >>> v7[-1] # ① 6.0 >>> v7[1:4] # ② Vector([1.0, 2.0, 3.0]) >>> v7[-1:] # ③ Vector([6.0]) >>> v7[1,2] # ④ Traceback (most recent call last): ... TypeError: 'tuple' object cannot be interpreted as an integer
①
整数索引仅检索一个分量值作为float
。
②
切片索引会创建一个新的Vector
。
③
长度为 1 的切片也会创建一个Vector
。
④
Vector
不支持多维索引,因此索引或切片的元组会引发错误。
向量第三版:动态属性访问
从Vector2d
到Vector
的演变中,我们失去了通过名称访问向量分量的能力(例如,v.x
,v.y
)。我们现在正在处理可能具有大量分量的向量。尽管如此,使用快捷字母(如x
,y
,z
)而不是v[0]
,v[1]
和v[2]
访问前几个分量可能更方便。
这是我们想要提供的用于读取向量前四个分量的替代语法:
>>> v = Vector(range(10)) >>> v.x 0.0 >>> v.y, v.z, v.t (1.0, 2.0, 3.0)
在Vector2d
中,我们使用@property
装饰器提供了对x
和y
的只读访问(示例 11-7)。我们可以在Vector
中编写四个属性,但这样做会很繁琐。__getattr__
特殊方法提供了更好的方法。
当属性查找失败时,解释器会调用__getattr__
方法。简单来说,给定表达式my_obj.x
,Python 会检查my_obj
实例是否有名为x
的属性;如果没有,搜索会到类(my_obj.__class__
)然后沿着继承图向上走。² 如果未找到x
属性,则会调用my_obj
类中定义的__getattr__
方法,传入self
和属性名称作为字符串(例如,'x'
)。
示例 12-8 列出了我们的__getattr__
方法。基本上,它检查正在寻找的属性是否是字母xyzt
中的一个,如果是,则返回相应的向量分量。
示例 12-8。vector_v3.py的一部分:Vector
类中添加的__getattr__
方法
__match_args__ = ('x', 'y', 'z', 't') # ① def __getattr__(self, name): cls = type(self) # ② try: pos = cls.__match_args__.index(name) # ③ except ValueError: # ④ pos = -1 if 0 <= pos < len(self._components): # ⑤ return self._components[pos] msg = f'{cls.__name__!r} object has no attribute {name!r}' # ⑥ raise AttributeError(msg)
①
设置__match_args__
以允许在__getattr__
支持的动态属性上进行位置模式匹配。³
②
获取Vector
类以备后用。
③
尝试获取__match_args__
中name
的位置。
④
.index(name)
在未找到name
时引发ValueError
;将pos
设置为-1
。(我更愿意在这里使用类似str.find
的方法,但tuple
没有实现它。)
⑤
如果pos
在可用分量的范围内,则返回该分量。
⑥
如果执行到这一步,请引发带有标准消息文本的AttributeError
。
实现__getattr__
并不难,但在这种情况下还不够。考虑示例 12-9 中的奇怪交互。
示例 12-9。不当行为:对v.x
赋值不会引发错误,但会引入不一致性。
>>> v = Vector(range(5)) >>> v Vector([0.0, 1.0, 2.0, 3.0, 4.0]) >>> v.x # ① 0.0 >>> v.x = 10 # ② >>> v.x # ③ 10 >>> v Vector([0.0, 1.0, 2.0, 3.0, 4.0]) # ④
①
将元素 v[0]
作为 v.x
访问。
②
将新值分配给 v.x
。这应该引发异常。
③
读取 v.x
显示新值 10
。
④
然而,矢量组件没有发生变化。
你能解释发生了什么吗?特别是,如果向矢量组件数组中没有的值尝试 v.x
返回 10
,那么为什么第二次会这样?如果你一时不知道,那就仔细研究一下在示例 12-8 之前给出的 __getattr__
解释。这有点微妙,但是是理解本书后面内容的重要基础。
经过一番思考后,继续进行,我们将详细解释发生了什么。
示例 12-9 中的不一致性是由于 __getattr__
的工作方式引入的:Python 仅在对象没有命名属性时才调用该方法作为后备。然而,在我们分配 v.x = 10
后,v
对象现在有一个 x
属性,因此 __getattr__
将不再被调用来检索 v.x
:解释器将直接返回绑定到 v.x
的值 10
。另一方面,我们的 __getattr__
实现不关心除 self._components
外的实例属性,从中检索列在 __match_args__
中的“虚拟属性”的值。
我们需要自定义在我们的 Vector
类中设置属性的逻辑,以避免这种不一致性。
回想一下,在第十一章中关于最新 Vector2d
示例的情况,尝试分配给 .x
或 .y
实例属性会引发 AttributeError
。在 Vector
中,我们希望任何尝试分配给所有单个小写字母属性名称时都引发相同的异常,以避免混淆。为此,我们将实现 __setattr__
,如示例 12-10 中所列。
示例 12-10. Vector
类中的 __setattr__
方法的一部分,位于 vector_v3.py
中。
def __setattr__(self, name, value): cls = type(self) if len(name) == 1: # ① if name in cls.__match_args__: # ② error = 'readonly attribute {attr_name!r}' elif name.islower(): # ③ error = "can't set attributes 'a' to 'z' in {cls_name!r}" else: error = '' # ④ if error: # ⑤ msg = error.format(cls_name=cls.__name__, attr_name=name) raise AttributeError(msg) super().__setattr__(name, value) # ⑥
①
对单个字符属性名称进行特殊处理。
②
如果 name
是 __match_args__
中的一个,设置特定的错误消息。
③
如果 name
是小写的,设置关于所有单个字母名称的错误消息。
④
否则,设置空白错误消息。
⑤
如果存在非空错误消息,则引发 AttributeError
。
⑥
默认情况:调用超类上的 __setattr__
以获得标准行为。
提示
super()
函数提供了一种动态访问超类方法的方式,在像 Python 这样支持多重继承的动态语言中是必不可少的。它用于将某些任务从子类中的一个方法委托给超类中的一个合适的方法,就像在示例 12-10 中所看到的那样。关于 super
还有更多内容,请参阅“多重继承和方法解析顺序”。
在选择与 AttributeError
一起显示的错误消息时,我的第一个检查对象是内置的 complex
类型的行为,因为它们是不可变的,并且有一对数据属性,real
和 imag
。尝试更改 complex
实例中的任一属性都会引发带有消息 "can't set attribute"
的 AttributeError
。另一方面,尝试设置只读属性(如我们在“可散列的 Vector2d”中所做的)会产生消息 "read-only attribute"
。我从这两个措辞中汲取灵感,以设置 __setitem__
中的 error
字符串,但对于被禁止的属性更加明确。
注意,我们并不禁止设置所有属性,只是单个字母、小写属性,以避免与支持的只读属性x
、y
、z
和t
混淆。
警告
知道在类级别声明__slots__
可以防止设置新的实例属性,很容易就会想要使用这个特性,而不是像我们之前那样实现__setattr__
。然而,正如在“总结与__slots__
相关的问题”中讨论的所有注意事项,仅仅为了防止实例属性创建而使用__slots__
是不推荐的。__slots__
应该仅用于节省内存,而且只有在这是一个真正的问题时才使用。
即使不支持写入Vector
分量,这个示例中有一个重要的要点:当你实现__getattr__
时,很多时候你需要编写__setattr__
,以避免对象中的不一致行为。
如果我们想允许更改分量,我们可以实现__setitem__
以启用v[0] = 1.1
和/或__setattr__
以使v.x = 1.1
起作用。但Vector
将保持不可变,因为我们希望在接下来的部分使其可哈希。
Vector 第四版:哈希和更快的==
再次我们要实现一个__hash__
方法。连同现有的__eq__
,这将使Vector
实例可哈希。
Vector2d
中的__hash__
(示例 11-8)计算了由两个分量self.x
和self.y
构建的tuple
的哈希值。现在我们可能正在处理成千上万个分量,因此构建tuple
可能成本太高。相反,我将对每个分量的哈希值依次应用^
(异或)运算符,就像这样:v[0] ^ v[1] ^ v[2]
。这就是functools.reduce
函数的用途。之前我说过reduce
不像以前那样流行,⁴但计算所有向量分量的哈希值是一个很好的使用案例。图 12-1 描述了reduce
函数的一般思想。
图 12-1。减少函数——reduce
、sum
、any
、all
——从序列或任何有限可迭代对象中产生单个聚合结果。
到目前为止,我们已经看到functools.reduce()
可以被sum()
替代,但现在让我们正确解释它的工作原理。关键思想是将一系列值减少为单个值。reduce()
的第一个参数是一个二元函数,第二个参数是一个可迭代对象。假设我们有一个二元函数fn
和一个列表lst
。当你调用reduce(fn, lst)
时,fn
将被应用于第一对元素——fn(lst[0], lst[1])
——产生第一个结果r1
。然后fn
被应用于r1
和下一个元素——fn(r1, lst[2])
——产生第二个结果r2
。现在fn(r2, lst[3])
被调用以产生r3
… 依此类推,直到最后一个元素,当返回一个单一结果rN
。
这是如何使用reduce
计算5!
(5 的阶乘)的方法:
>>> 2 * 3 * 4 * 5 # the result we want: 5! == 120 120 >>> import functools >>> functools.reduce(lambda a,b: a*b, range(1, 6)) 120
回到我们的哈希问题,示例 12-11 展示了通过三种方式计算累积异或的想法:使用一个for
循环和两个reduce
调用。
示例 12-11。计算从 0 到 5 的整数的累积异或的三种方法
>>> n = 0 >>> for i in range(1, 6): # ① ... n ^= i ... >>> n 1 >>> import functools >>> functools.reduce(lambda a, b: a^b, range(6)) # ② 1 >>> import operator >>> functools.reduce(operator.xor, range(6)) # ③ 1
①
使用for
循环和一个累加变量进行聚合异或。
②
使用匿名函数的functools.reduce
。
③
使用functools.reduce
用operator.xor
替换自定义lambda
。
在示例 12-11 中的备选方案中,最后一个是我最喜欢的,for
循环排在第二位。你更喜欢哪种?
正如在“operator 模块”中所看到的,operator
以函数形式提供了所有 Python 中缀运算符的功能,减少了对lambda
的需求。
要按照我喜欢的风格编写Vector.__hash__
,我们需要导入functools
和operator
模块。示例 12-12 展示了相关的更改。
流畅的 Python 第二版(GPT 重译)(六)(4)https://developer.aliyun.com/article/1484610