不要检查它是否是鸭子:而是检查它是否像鸭子一样嘎嘎叫,像鸭子一样走路,等等,具体怎么检查取决于所需的像鸭子一样的行为的子集和。 (comp.lang.python,2000 年 7 月 26 日)
--------Alex Martelli
在本章中,我们将创建一个类来表示多维 Vector 类——这是第 11 章二维 Vector2d 的重要改进。Vector 的行为类似于标准的 Python 不可变扁平序列。它的元素将是浮动的,并且在本章结束时将支持以下内容:
- 基本序列协议:__len__ 和 __getitem__。
- 具有许多元素的实例的安全表示。
- 适当的切片支持,产生新的 Vector 实例。
- 考虑到每个包含的元素值的聚合散列。
- 自定义格式语言扩展。
我们还将使用 __getattr__ 实现动态属性访问,作为替换 Vector2d 中使用的只读属性的一种方式——尽管这不是序列类型的典型特征。
代码演示过程中会去介绍有关协议作为非正式接口的概念的概念性讨论。我们将讨论协议和鸭子类型是如何相关的,以及它在您创建自己的类型时的实际意义。
本章的新内容
本章没有重大变化。在“Protocols and Duck Typing”末尾附近的提示框中,有一个新的、关于typing.Protocol 的简短讨论。
在“A Slice-Aware __getitem__”中,示例 12-6 中 __getitem__ 的实现比第一版中的示例更短、更健壮,这要归功于鸭子类型和 operator.index。此更改延续到本章和第 16 章中 Vector 的后续实现。
让我们开始吧。
Vector:用户定义的序列类型
我们实现 Vector 的策略是使用组合,而不是继承。我们将分量存储在一个浮点array中,并将实现我们的 Vector 所需的方法,使其表现得像一个不可变的平面序列。
但是在我们实现序列方法之前,让我们确保我们有一个与我们早期的 Vector2d 类兼容的 Vector 的基线实现——除非这种兼容性没有意义。
VECTOR 应用的三个维度:
谁需要一个 1000 维的向量?N维向量(N的大值)广泛用于信息检索,其中文档和文本查询被表示为向量,每个词一维。这称为向量空间模型。在这个模型中,一个关键的相关性度量是余弦相似度(即表示查询的向量和表示文档的向量之间夹角的余弦)。随着角度的减小,余弦值接近最大值 1,文档与查询的相关性也是如此。
话虽如此,本章中的 Vector 类是一个教学示例,我们不会在这里做太多数学运算。我们的目标只是在序列类型的上下文中演示一些 Python 特殊方法。
NumPy 和 SciPy 是您进行实际矢量数学运算所需的工具。 Radim Řehůřek 的 PyPI 包 gensim 使用 NumPy 和 SciPy 实现了用于自然语言处理和信息检索的向量空间建模。
Vector 类第一版:兼容 Vector2d
Vector 的第一个版本应该尽可能与我们早期的 Vector2d 类兼容。
但是,根据设计,我们会让Vector 的构造函数与 Vector2d 的构造函数不兼容。我们可以编写类似 Vector(3, 4) 和 Vector(3, 4, 5) 的代码,方法是在 __init__ 中使用带有 *args 的任意参数,但是序列构造函数的最佳实践是将可迭代的数据作为构造函数的参数,就像所有内置序列类型一样。示例 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]) 产生相同的结果。
Warning:当 Vector 有六个以上的分量时,repr() 生成的字符串缩写为 ...,如例 12-1 的最后一行所示。这对于可能包含大量项目的任何集合类型都至关重要,因为 repr 用于调试——并且您不希望单个大对象跨越控制台或日志中的数千行。使用 reprlib 模块生成有限长度的表示,如例 12-2 所示。 reprlib 模块在 Python 2.7 中名称为 repr。
示例 12-2 列出了我们第一个 Vector 版本的实现(此示例基于示例 11-2 和 11-3 中显示的代码)。
from array import array
import reprlib
import math
class Vector:
typecode = 'd'
def __init__(self, components):
self._components = array(self.typecode, components) 1
def __iter__(self):
return iter(self._components) 2
def __repr__(self):
components = reprlib.repr(self._components) 3
components = components[components.find('['):-1] 4
return f'Vector({components})'
def __str__(self):
return str(tuple(self))
def __bytes__(self):
return (bytes([ord(self.typecode)]) +
bytes(self._components)) 5
def __eq__(self, other):
return tuple(self) == tuple(other)
def __abs__(self):
return math.hypot(*self) 6
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) 7
- self._components 实例“protected”属性将保存一个包含 Vector 分量的数组。
- 为了允许迭代,我们在 self._components 上返回一个迭代器。
- 使用 reprlib.repr() 获得 self._components 的有限长度表示(例如,array('d', [0.0, 1.0, 2.0, 3.0, 4.0, ...]))。
- 在将字符串插入 Vector 构造函数调用之前删除数组前缀('d'和后面的)。
- 直接从 self._components 构建一个字节对象。
- 从 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 使用数组是一个实现细节。因为这些构造函数调用构建了相同的 Vector 对象,所以我更喜欢使用list参数的更简单的语法。
在编码 __repr__ 时,我可以使用以下表达式生成简化的分量显示:reprlib.repr(list(self._components))。是,这会很浪费,因为我会将 self._components 中的每个分量复制到列表中,只是为了使用列表的repr。相反,我决定将 reprlib.repr 直接应用于 self._components 数组,然后将 [] 之外的字符砍掉。这就是示例 12-2 中 __repr__ 的第二行所做的。
TIP:由于在调试中的作用,对对象调用 repr() 永远不应该引发异常。如果在 __repr__ 的实现中出现问题,您必须处理问题并尽最大努力产生一些有用的输出,让用户有机会识别接受者(self)。
请注意,__str__、__eq__ 和 __bool__ 方法与 Vector2d 没有变化,并且 frombytes 中仅更改了一个字符(最后一行中删除了 *)。这是将 Vector2d实现为 可迭代对象的好处之一。
顺便说一下,我们可以让 Vector2d 继承 Vector,但我选择不这样做有两个原因。首先,不兼容的构造函数确实使子类化不可取。我可以在 __init__ 中通过一些巧妙的参数处理来解决这个问题,但第二个原因更重要:我希望 Vector 成为实现序列协议的类的独立示例。在讨论了协议这个术语之后,这就是我们接下来要做的。
协议和鸭子类型
早在第 1 章,我们就看到不需要继承任何特殊的类就可以在 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的子类。我们说它是一个序列,因为它的表现行为像一个序列,这才是最重要的。
在本章开头引用亚历克斯·马泰利 (Alex Martelli) 的帖子之后,这被称为鸭子类型。
因为协议是非正式的且未强制执行的,如果您知道将使用类的特定上下文,您通常可以只实现协议的一部分。比如支持迭代,只需要__getitem__;无需提供 __len__。
TIP:通过 PEP 544——协议:结构子类型(静态鸭子类型),Python 3.8 支持协议类:我们在“静态协议”中研究的类型结构。 Python 中协议一词的新用法具有相关但不同的含义。当我需要区分它们时,我编写静态协议来引用协议类中形式化的协议,而动态协议则用于传统意义上。一个关键的区别是静态协议实现必须提供协议类中定义的所有方法。第 13 章中的“Two kinds of protocols”有更多详细信息。
我们现在将在 Vector 中实现序列协议,最初没有对切片的适当支持,但后来添加了这一点。
Vector 第2版:可切片的序列
正如我们在 FrenchDeck 示例中看到的那样,如果您可以委托给对象中的序列属性,例如我们的 self._components 数组