这篇文章将实现一个多维向量的 Vector 类。这个类的行为与 Python 中标准的不可变扁平序列一样。这篇文章还将讨论一个概念:把协议当作正式接口。我们将说明协议和鸭子类型之间的关系,以及对自定义类型的实际影响。
Vector类:用户定义的序列类型
这里将使用组合的方式实现 Vector 类,而不使用继承。向量的分量存储在浮点数数组中,而且还将实现不可变扁平序列所需的方法。
信息检索领域经常使用 N 维向量(N是很大的数),因为查询的文档和文本使用向量表示,一个单词一个维度。这叫向量空间模型。在这个模型中,一个关键的相关指标是余弦相关性(表示查询的向量与表示文档的向量之间夹角的余弦)。夹角越小,余弦值越趋近于 1,文档与查询的相关性越大。如果在实际使用中需要做向量运算,那么应该使用 NumPy 和 SciPy。
如下是 Vector 类实现的第一版:
1 | from array import array |
- 使用
reprlib.repr用于生成大型结构或递归结构的安全表示形式,它会限制输出字符串的长度,用...表示截断的部分
协议和鸭子类型
在Python中创建功能完善的序列类型无须使用继承,实现符合序列协议的方法即可。那这里的协议是什么呢?在面向对象编程中,协议是非正式的接口,只在文档中定义,不在代码中定义。例如,Python 的序列协议只需要 __len__ 和 __getitem__ 这两个方法。
- 任何类(例如 Spam),只要使用标准的签名和语义实现了这两个方法,就能用在任何预期序列的地方
- Spam 是不是哪个类的子类无关紧要,只要提供了所需的方法即可
1 | import collections |
对于上述代码,任何有经验的 Python 程序员只要看一眼就知道它是序列,即便它是 object 的子类也无妨。我们说它是序列,因为它的行为像序列,这才是重点。
人们称其为鸭子类型(duck typing)。协议是非正式的,没有强制力,因此如果知道类的具体使用场景,那么通常只需要实现协议的一部分。例如,为了支持迭代,只需实现 __getitem__ 方法,没必要提供 __len__ 方法。
实现 PEP 544—Protocols: Structural subtyping (static duck typing) 之后,Python3.8 开始支持协议类(protocol class)。这里的协议与我们上面所讲的 传统协议 有关系但又不完全相同。如果需要区分:
- 可以使用
静态协议指代协议类规定的协议 - 使用动态协议指代传统意义上的协议
- 二者之间主要的区别是,静态协议的实现必须提供静态类中定义的所有方法
可切片的序列
如果能委托给对象中的序列属性(例如 self._components 数组),则支持序列协议特别简单:
1 | class Vector: |
1 | v1 = vector.Vector([3, 4, 5]) |
可以看到,虽然已经支持了切片,但是实现并不算完美,因为最好 Vector 实例的切片也是 Vector 的实例。想想内置序列类型:切片得到的都是各自类型的新实例,而不是其他类型。
为了把 Vector 实例的切片也变成 Vector 实例,不能简单地把切片操作委托给数组。要分析传给 __getitem__ 方法的参数,做适当的处理:
1 | def __getitem__(self, key): |
- 如果 key 是切片,则返回一个 Vector 实例,对应 self._components 的切片结果
- 否则,使用
operator.index函数将 key 转换为索引(如果转换失败,会抛出异常,以确认 key 是否是有效的索引类型) - 其实 key 还是元组类型(包括多个 slice 或 index),以支持多维切片,但是我们这里不支持
大量使用 isinstance 可能表明面向对象设计得不好,不过在 __getitem__ 方法中使用它处理切片是合理的。
动态存取属性
如果能通过单个字母访问前几个分量的话会比较方便。例如,用 x、y 和 z 代替 v[0]、v[1] 和 v[2]。在 Vector2d 中,使用 @property 装饰器把 x 和 y 标记为只读特性。但这样太麻烦,特殊方法 __getattr__ 提供了更好的方式。属性查找失败后,解释器会调用 __getattr__ 方法。简单来说,对于 my_obj.x 表达式:
- Python 会检查 my_obj 实例有没有名为 x 的属性
- 如果没有,就到类(
my_obj.__class__)中查找 - 如果还没有,就沿着继承图继续向上查找
- 如果依旧找不到,则调用 my_obj 所属的类中定义的
__getattr__方法,传入 self 和属性名称的字符串形式(例如 ‘x’)
1 | __match_args__ = ('x', 'y', 'z', 't') |
- 设定
__match_args__,让__getattr__实现的动态属性支持位置模式匹配 __match_args__一般有两个作用:在 case 子句中使用时支持位置模式,而是存储 getattr / setattr_ 的特殊逻辑实现的动态属性名称
1 | v = Vector(range(5)) |
- 这里的行为有些怪,为 v.x 设置新值后,v.x 返回 10,但是向量中的分量数组却没有变化
- 仅当对象没有指定名称的属性时,Python 才会调用
__getattr__方法,这是一种后备机制。 - 像
v.x = 10这样赋值之后,v 对象就有 x 属性了,因此使用v.x获取 x 属性的值时不会再调用__getattr__方法,解释器会直接返回v.x绑定的值,即 10
为了避免这种前后矛盾的现象,需要改写 Vector 类中设置属性的逻辑,即实现 __setattr__ 方法:
1 | def __setattr__(self, name, value): |
- 对名称是单个字符的属性进行检查
- 默认情况,则在超类上调用
__setattr__方法,提供标准行为 - super() 函数用于动态访问超类的方法,对 Python 这种支持多重继承的动态语言来说,必须这么做。程序员经常使用这个函数把子类方法的某些任务委托给超类中适当的方法
- 另外如果想实现修改分量,还可以通过实现
__setitem__方法来支持v[0] = 1.1这样的赋值
我们知道,在类中声明 __slots__ 属性可以防止设置新实例属性。因此,你可能想使用这个功能,而不像这里所做的那样实现 __setattr__ 方法。但是不建议只为了避免创建实例属性而使用 __slots__。__slots__ 只应该用于节省内存,而且仅当内存严重不足时才应该这么做。
大多数时候,如果实现了 __getattr__ 方法,那么也要定义 __setattr__ 方法,以防对象的行为不一致。
哈希和快速等值测试
我们要再次实现 __hash__ 方法,加上现有的 __eq__ 方法,这会把 Vector 实例变成可哈希的对象。我们将使用 ^(异或)运算符依次计算各个分量的哈希值,就像这样:v[0]^ v[1]^ v[2]。这正是 functools.reduce 函数的作用。
reduce()的关键思想是,把一系列值归约成单个值reduce()函数的第一个参数是一个接受两个参数的函数,第二个参数是一个可迭代对象
1 | import functools |
operator 模块以函数的形式提供了所有的 Python 中缀运算符,借此可以减少使用 lambda 表达式的必要。因此 Vector 类通过如下方法支持 hash 测试:
1 | from array import array |
- 创建一个生成器表达式,惰性计算各个分量的哈希值
- 把 hashes 提供给 reduce 函数,使用 xor 函数计算聚合的哈希值。第三个参数(0)是初始值
归约过程则使用 xor 运算符聚合所有的哈希值。把生成器表达式替换成 map 函数,映射过程更明显:
1 | def __hash__(self): |
为了提高 __eq__ 的性能,我们不再构造元组并进行元组的比较了,而是直接比较元素:
1 | def __eq__(self, other): |
- zip 函数生成一个由元组构成的生成器,元组中的元素来自参数传入的各个可迭代对象
- 一旦有一个输入对象耗尽,zip 函数就会立即停止生成值,而且不发出警告。因此首先进行长度比较是必要的
itertools.zip_longest函数的行为有所不同,它使用可选的fillvalue(默认值为 None)来填充缺失的值,因此可以继续生成元组,直到最后一个可迭代对象耗尽- Python3.10 zip 函数增加一个可选的参数 strict,如果各个可迭代对象的长度不同,那么 zip 就应该抛出 ValueError
- zip 函数的名称取自拉链,因为此物品把两边的链牙咬合在一起,这形象地说明了
zip(left, right)的作用
1 | a = [(1, 2, 3), (4, 5, 6)] |
上面的 __eq__ 函数更简单的写法如下:
1 | def __eq__(self, other): |
格式化
如下代码实现了以球面坐标的形式展示 Vector 向量:
1 | def angle(self, n): |
- 使用
itertools.chain函数生成生成器表达式,无缝迭代向量的模和各个角坐标