第十二章 序列的特殊方法
不要通过叫声、走路姿势等像不像鸭子来检查它是不是鸭子,具体检查什么取决于你想使用语言的哪些行为。
书接上文,在信息检索 领域,常使用高维向量(如 1000 维)表示文档或查询,每个维度对应一个词项(term),这称为向量空间模型 。文档与查询的相关性常用余弦相似度 衡量:夹角越小,余弦值越接近 1,相关性越高。本章将继续对Vector
类进行拓展。采用组合模式 (而非继承)实现 Vector
类,将向量分量存储在浮点数数组 (如 array('d', ...)
)中,实现不可变扁平序列所需的所有特殊方法。
代码实现
Vector
第 1 版尽量与 Vector2d
行为兼容(测试结果一致),但故意不兼容构造函数签名 。不采用 Vector(3, 4)
这类调用方式 (即不使用 *args
接收任意位置参数),因为序列类型的构造函数应接受可迭代对象 ,这与所有内置序列类型(如 list
, tuple
)一致。
python
# 支持一下实例化方式
Vector([3.1, 4.2])
Vector((3, 4, 5))
Vector(range(10))
python
from array import array
import reprlib
import math
class Vector:
# 用于储存双精度浮点数
typecode = 'd'
def __init__(self, components):
# 命名以下划线开头,表明其为内部属性
# 使用 array('d', ...) 存储浮点分量,作为受保护的内部实现细节
self._components = array(self.typecode, components)
# 返回 self._components 的迭代器,使 Vector 可迭代
def __iter__(self):
return iter(self._components)
def __repr__(self):
# 标准库模块 reprlib生成有限长度的安全表示
components = reprlib.repr(self._components)
# 从上面的 components 提取出 [...] 内部的内容 包括 [ 但不包括结尾的 )
components = components[components.find('['):-1]
return f'Vector({components})'
def __str__(self):
# tuple() 构造函数在创建元组时,会对传入的对象进行迭代
# Python 中实现迭代的标准方式就是调用对象的 __iter__() 方法
# tuple(self) 会隐式调用 self.__iter__(),将 Vector 实例转换为元组
# 这依赖于 Vector 是可迭代的
return str(tuple(self))
def __bytes__(self):
return (bytes([ord(self.typecode)]) +
bytes(self._components))
def __eq__(self, other):
# 同__str__
return tuple(self) == tuple(other)
def __abs__(self):
# *self 语法要求 self 可迭代,以便将元素作为位置参数传给 math.hypot
# 所以__abs__也依赖于可迭代的实现
return math.hypot(*self)
def __bool__(self):
return bool(abs(self))
@classmethod
# 从字节串重建 Vector 实例
# 直接将 memoryview 传给 cls(),无需解包 因构造函数接受可迭代对象
def frombytes(cls, octets):
typecode = chr(octets[0])
memv = memoryview(octets[1:]).cast(typecode)
return cls(memv)
协议和鸭子类型
协议(Protocol)
在 Python 中,协议是一种非正式的接口 ,不在代码中强制声明或继承 , 仅通过文档或约定定义 , 只要对象实现了协议规定的方法(具有标准签名和语义),就能被当作该类型使用。
例如,序列协议的核心要求是实现以下两个方法:
__len__(self)
:返回元素个数;__getitem__(self, index)
:通过整数索引或切片获取元素。
只要一个类(如 Spam
)实现了这两个方法,它就可以在任何需要序列的地方使用------无需继承自 list
、tuple
或任何特定基类。
鸭子类型(Duck Typing)
源自谚语:"如果它走起来像鸭子,叫起来也像鸭子,那它就是鸭子。"在 Python 中体现为:关注对象的行为(方法),而非其类型或继承关系。
示例:FrenchDeck
类(见下文)虽直接继承自 object
,但因实现了 __len__
和 __getitem__
,就被视为序列。
python
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]
尽管 FrenchDeck
没有声明"我是序列",但它能被 len()
调用,支持索引(如 deck[0]
),支持切片(如 deck[:3]
),支持迭代(因 __getitem__
支持从 0 开始的整数索引),与 random.choice()
等函数兼容。因为它实现了序列协议所需的方法。
协议是灵活的,无需实现协议的全部方法 ,只需满足具体使用场景的需求。例如:若仅需支持 for
循环迭代,仅实现 __getitem__
(配合整数索引)就足够 ,不一定需要 __len__
。这种"按需实现"的特性是动态语言灵活性的体现。
可切片的序列
当前的目标是使 Vector
支持完整的序列协议 ,特别是通过 __len__
和 __getitem__
实现基本序列行为;切片操作返回新的 Vector
实例 (而非底层 array
对象),以保持类型一致性和功能完整性。
内置序列(如 list
、str
)的切片结果仍是同类型对象,Vector
应遵循这一惯例。
切片机制
当使用 obj[start:stop:step]
语法时,Python 会将切片转换为一个 slice
对象,并作为参数传给 __getitem__
。
python
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, 7:9] # → (slice(1, 4, 2), slice(7, 9, None)) (元组)
slice
对象
slice 是内置的类型,有 start
, stop
, step
三个属性,并实现了indices(len)
方法,输入序列长度 len
,返回标准化的 (start, stop, step)
,自动处理负索引、越界、省略值(如 None
)。
python
# 等价于对长度为5的序列使用 [:10:2]
slice(None, 10, 2).indices(5) # → (0, 5, 2) 从索引 0 开始,到索引 5(不包含),每隔 2 个元素取一个
slice(-3, None).indices(5) # → (2, 5, 1)
虽然 Vector
可借助底层 array
处理切片细节(无需手动调用 indices
),但理解该机制对实现自定义序列至关重要。
改进 __getitem__
实现
python
import operator
class Vector:
# ...
def __len__(self):
return len(self._components)
def __getitem__(self, key):
# 检测是否为切片
if isinstance(key, slice):
# 获取当前类(Vector)
cls = type(self)
# 用切片结果构造新 Vector
return cls(self._components[key])
else:
# 将 key 转为整数索引
index = operator.index(key)
# 返回单个元素
return self._components[index]
python
v7 = Vector(range(7))
v7[-1] # → 6.0 (单个元素,float)
v7[1:4] # → Vector([1.0, 2.0, 3.0]) (新 Vector 实例)
v7[-1:] # → Vector([6.0]) (长度为 1 的切片仍是 Vector)
v7[1, 2] # → TypeError: 'tuple' object cannot be interpreted as an integer
动态存取属性
本节的目标是为 Vector
提供类似 v.x
, v.y
, v.z
, v.t
的便捷属性访问 ,分别对应前 4 个分量(即 v[0]
到 v[3]
),同时保持不可变性。
虽然 Vector
是多维序列,但为前几个分量提供命名访问(如 x
, y
, z
)在几何或物理场景中很常见且方便。
使用 __getattr__
实现动态属性
若为 x
, y
, z
, t
分别写 @property
,代码重复且难以扩展,所以不使用 @property
。__getattr__
提供了一种统一处理未定义属性 的机制。仅当通过常规属性查找(实例字典、类、继承链)找不到属性时 ,Python 才调用 __getattr__(self, name)
。它是后备机制,不是每次属性访问都调用。
python
# 类可以通过定义 __match_args__ 告诉解释器位置参数的含义 yong
__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)
python
v = Vector(range(5))
v.x # → 0.0 (调用 __getattr__)
v.x = 10 # → 成功 但未修改 _components
v.x # → 10 (直接读取实例属性 x)
v # → Vector([0.0, 1.0, 2.0, 3.0, 4.0]) (未变)
v.x = 10
在 v
的实例字典中创建了一个新属性 x
;后续 v.x
访问不再触发 __getattr__
(因为属性已存在);导致:v.x
与 v[0]
不一致 ,破坏了不可变性和逻辑一致性。这是仅实现 __getattr__
而不控制赋值的典型陷阱。
实现 __setattr__
禁止非法赋值
为保持不可变性和行为一致,禁止为 x
, y
, z
, t
及所有单个小写字母属性赋值。
python
def __setattr__(self, name, value):
cls = type(self)
# 如果名字在是 x/y/z/t
if len(name) == 1:
# 处理是 x/y/z/t
if name in cls._match_args_:
error = "只读属性 {attr_name!r}"
# 处理其余字母
elif name.islower():
error = "禁止设置 a~z 属性 {cls_name!r}"
# 处理非小写字母
else:
error = '' # ❹ 非小写字母(如 '_'、'A')允许
# 抛出异常
if error: # ❺ 抛出异常
msg = error.format(cls_name=cls.__name__, attr_name=name)
raise AttributeError(msg)
# 其他属性仍可通过 super() 正常设置
super().__setattr__(name, value)
动态指的是按需解析,而非预先声明
在传统方式中(如 @property
),属性是静态声明的:
python
class Vector2d:
@property
def x(self): return self._components[0]
@property
def y(self): return self._components[1]
x
和 y
是类定义时就明确存在的属性;Python 在类创建时就知道这些属性存在。
而在 Vector
中:
python
__match_args__ = ('x', 'y', 'z', 't')
def __getattr__(self, name):
if name in __match_args__:
return self._components[__match_args__.index(name)]
类中并没有真正定义 x
, y
, z
, t
这些属性 ;只有当用户首次访问 v.x
时,Python 找不到 x
,才动态触发 __getattr__
;然后在运行时临时计算 :x
→ 索引 0 → 返回 self._components[0]
。这就是"动态"的本质,属性的存在性和值是在访问时动态决定的,而不是类结构的一部分。
Vector
的真实数据只存储在 self._components
这个 array
中 ;x
, y
, z
, t
不是独立的存储字段 ,它们只是 self._components[0]
等的别名(alias)或视图(view) ;没有为 x
分配额外内存 ,也没有在实例字典中创建 x
(除非你错误地赋值,如 v.x = 10
,但这被 __setattr__
禁止了)。所以**"动态存取" = 动态 解析访问 + 禁止 存储修改 ** ,数据始终只有一份 (在 _components
中),属性只是读取接口。
__setattr__
起到保护动态只读语义 的作用,如果没有 __setattr__
,
python
v.x = 10 # 会在实例字典中创建真实属性 'x'
此时 v.x
和 v[0]
不再一致;动态属性的"视图"语义被破坏。
有了 __setattr__
,
python
v.x = 10 # 抛出 AttributeError: 只读属性 'x'
确保 x
永远不能成为实例的真实属性 ;所有对 x
的访问始终走 __getattr__
路径 ,永远返回 _components[0]
;动态只读视图的语义得以保持。
哈希和快速等值测试
本节的目标在于实现 __hash__
方法,使 Vector
实例可哈希 (从而可用作字典键或集合元素);优化 __eq__
方法,避免为大型向量构建完整元组 ,提升性能和内存效率;并保持与 Vector2d
的行为兼容。
实现 __hash__
Vector2d
使用 hash((self.x, self.y))
,但对高维向量(如 1000 维),构建元组开销大;需要一种内存高效、惰性计算的哈希聚合方式。
python
import functools
import operator
# 惰性生成各分量哈希
def __hash__(self):
# 不一次性构建完整列表,而是按需产出哈希值
# 使用了生成器表达式
hashes = (hash(x) for x in self._components) # 惰性生成各分量哈希
return functools.reduce(operator.xor, hashes, 0)
映射-归约模式 中,映射 对每个分量 x
计算 hash(x)
;归约 :用异或(^
)依次聚合所有哈希值。**使用 operator.xor
**避免使用 lambda a, b: a ^ b
,更清晰高效;**初始值 0
**作为异或的单位元(0 ^ x == x
);防止空序列报错(reduce
要求非空序列必须提供初始值)。
!NOTE
之所以说当前的实现是惰性 的,是因为最终所有分量都会被哈希,但不是"一次性全部计算并存入内存",而是"一个接一个、按需计算"**。 这种 "按需逐个计算、不预先存储全部结果" 的行为,就叫 惰性(lazy)。
在 Python 中,"惰性"通常指:
- 不立即计算所有值;
- 不把所有结果存入内存;
- 在需要时才计算下一个值 (例如,在循环中、或被
reduce
拉取时)。
方式 代码 行为 非惰性(急切) hashes = [hash(x) for x in self._components]
立即遍历 _components
,计算所有hash(x)
,并将结果全部存入一个列表(占用 O(n) 内存)惰性 hashes = (hash(x) for x in self._components)
不立即计算任何值 ,只创建一个生成器对象 ;只有当别人(如 reduce
)向它要值时,才逐个计算并返回
pythonfunctools.reduce(operator.xor, hashes, 0)
reduce
内部大致这样工作:
pythonresult = 0 for value in hashes: # ← 这里才开始拉取生成器的值 result = operator.xor(result, value)
- 第一次循环:
hashes
计算hash(self._components[0])
,返回;- 第二次循环:计算
hash(self._components[1])
,返回;- ...
- 每次只计算一个 哈希值,用完就丢,不需要同时保存所有哈希值。
虽然最终每个分量都被哈希了,但任意时刻内存中最多只有一个哈希值 (加上
reduce
的累加器)。假设
self._components
有 1,000,000 个元素:
列表推导式
[hash(x) for ...]
:
- 先花时间计算 100 万个哈希;
- 同时在内存中存 100 万个整数(可能几百 MB);
- 然后
reduce
再遍历这个大列表。生成器表达式
(hash(x) for ...)
:
- 内存中始终只有当前正在处理的那个哈希值;
reduce
边拉取、边计算、边异或;- 内存占用恒定(O(1)),无论向量多长。
!NOTE
类型 语法 示例 列表推导式 [expression for item in iterable if condition]
[x**2 for x in range(10)]
生成器表达式 (expression for item in iterable if condition)
(x**2 for x in range(10))
特性 列表推导式 生成器表达式 返回类型 list
(具体列表对象)generator
(迭代器对象)执行时机 立即执行(eager):创建时就计算所有元素 惰性执行(lazy) :只在需要时(如 next()
、for
循环)才计算下一个元素内存占用 O(n):一次性存储所有结果 O(1):只保存当前状态,不缓存已产出的值 可重复迭代 可多次遍历 只能遍历一次,用完即"耗尽" 适用场景 需要随机访问、多次使用、或数据量小 数据量大、只需遍历一次、或作为中间流处理
!NOTE
pythonfrom functools import reduce reduce(function, iterable[, initializer])
function
:一个接受两个参数 的函数(如lambda a, b: a + b
);iterable
:可迭代对象(如列表、元组、生成器);initializer
(可选):初始值(推荐提供,避免空序列错误)。
reduce
通过累积应用function
,将序列逐步合并为一个结果:
textreduce(f, [x1, x2, x3, x4]) → f(f(f(x1, x2), x3), x4)
如果提供了
initializer
(设为init
):
textreduce(f, [x1, x2, x3], init) → f(f(f(init, x1), x2), x3)
reduce
不是更快,而是更通用。
优化 __eq__
若两个对象相等(a == b
为 True
),则它们的哈希值必须相同(hash(a) == hash(b)
)****。原实现 对高维向量,需复制全部数据构建两个元组;同时时间和内存开销大,尤其当向量不等时仍需完整复制。下面给出几种改进方案。
显式 for
循环
python
def __eq__(self, other):
if len(self) != len(other):
return False
for a, b in zip(self, other):
if a != b:
return False
return True
使用 all()
python
# 最终采用了这个版本
def __eq__(self, other):
return len(self) == len(other) and all(a == b for a, b in zip(self, other))
!NOTE
pythonzip(*iterables, strict=False) # Python 3.10+ 支持 strict 参数
- 接收 任意数量 的可迭代对象(如列表、元组、字符串、生成器等);
- 返回一个
zip
对象 (迭代器),每次产出一个元组 ,包含各输入对象的当前元素;- 在最短的输入耗尽时停止(默认行为)。
pythona = [1, 2, 3] b = ['x', 'y', 'z'] c = (10, 20, 30) list(zip(a, b, c)) # → [(1, 'x', 10), (2, 'y', 20), (3, 'z', 30)]
格式化
本节目标是为 Vector
实现 __format__
方法,支持两种坐标表示:
- 默认(笛卡尔坐标) :如
(3.0, 4.0, 5.0)
- **超球面坐标:通过格式后缀
'h'
启用,如<r, φ₁, φ₂, ..., φₙ₋₁>
由于 Vector
支持任意维度(n ≥ 2),使用"超球面坐标"比 Vector2d
的"极坐标"更通用,因此格式代码从 'p'
改为 'h'
。
扩展格式微语言时应避免冲突 ,不重用内置类型已有的格式代码(如浮点数的 'eEfFgGn%'
、整数的 'bcdoxXn'
、字符串的 's'
),'h'
是安全且语义清晰的选择(hyperspherical)。
python
def __format__(self, fmt_spec=''):
if fmt_spec.endswith('h'): # 超球面坐标模式
fmt_spec = fmt_spec[:-1] # 去掉 'h',保留其余格式(如 '.3e')
coords = itertools.chain([abs(self)], self.angles())
outer_fmt = '<{}>'
else: # 笛卡尔坐标模式
coords = self
outer_fmt = '({})'
# 按需格式化每个坐标分量
components = (format(c, fmt_spec) for c in coords)
return outer_fmt.format(', '.join(components))
python
# 辅助方法:角坐标计算
def angle(self, n):
r = math.hypot(*self[n:]) # 从第 n 维到末尾的模
a = math.atan2(r, self[n-1]) # 计算角度
if (n == len(self) - 1) and (self[-1] < 0):
return math.pi * 2 - a # 调整最后一个角度
else:
return a
python
# 返回所有角坐标的生成器(惰性计算)
# 使用生成器避免为高维向量预先计算所有角度
def angles(self):
return (self.angle(n) for n in range(1, len(self)))
itertools.chain
python
coords = itertools.chain([abs(self)], self.angles())
将模(标量) 和 角坐标序列(生成器) 无缝拼接为单一可迭代对象;避免创建中间列表,保持内存高效。
python
format(Vector([3, 4, 5])) # → '(3.0, 4.0, 5.0)'
format(Vector([3, 4]), '.2f') # → '(3.00, 4.00)'
python
format(Vector([-1, -1, -1, -1]), 'h')
# → '<2.0, 2.094..., 2.186..., 3.926...>'
format(Vector([2, 2, 2, 2]), '.3eh')
# → '<4.000e+00, 1.047e+00, 9.553e-01, 7.854e-01>'
format(Vector([0, 1, 0, 0]), '0.5fh')
# → '<1.00000, 1.57080, 0.00000, 0.00000>'