第十二章 序列的特殊方法

第十二章 序列的特殊方法

不要通过叫声、走路姿势等像不像鸭子来检查它是不是鸭子,具体检查什么取决于你想使用语言的哪些行为。

书接上文,在信息检索 领域,常使用高维向量(如 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)实现了这两个方法,它就可以在任何需要序列的地方使用------无需继承自 listtuple 或任何特定基类

鸭子类型(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 对象),以保持类型一致性和功能完整性。

内置序列(如 liststr)的切片结果仍是同类型对象,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.xv[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]

xy类定义时就明确存在的属性;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 这个 arrayx, y, z, t 不是独立的存储字段 ,它们只是 self._components[0] 等的别名(alias)或视图(view)没有为 x 分配额外内存 ,也没有在实例字典中创建 x(除非你错误地赋值,如 v.x = 10,但这被 __setattr__ 禁止了)。所以**"动态存取" = 动态 解析访问 + 禁止 存储修改 ** ,数据始终只有一份 (在 _components 中),属性只是读取接口

__setattr__ 起到保护动态只读语义 的作用,如果没有 __setattr__

python 复制代码
v.x = 10  # 会在实例字典中创建真实属性 'x'

此时 v.xv[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)向它要值时,才逐个计算并返回
python 复制代码
functools.reduce(operator.xor, hashes, 0)

reduce 内部大致这样工作:

python 复制代码
result = 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

python 复制代码
from functools import reduce

reduce(function, iterable[, initializer])
  • function :一个接受两个参数 的函数(如 lambda a, b: a + b);
  • iterable:可迭代对象(如列表、元组、生成器);
  • initializer(可选):初始值(推荐提供,避免空序列错误)。

reduce 通过累积应用 function,将序列逐步合并为一个结果:

text 复制代码
reduce(f, [x1, x2, x3, x4]) 
→ f(f(f(x1, x2), x3), x4)

如果提供了 initializer(设为 init):

text 复制代码
reduce(f, [x1, x2, x3], init)
→ f(f(f(init, x1), x2), x3)

reduce 不是更快,而是更通用

优化 __eq__

若两个对象相等(a == bTrue),则它们的哈希值必须相同(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

python 复制代码
zip(*iterables, strict=False)  # Python 3.10+ 支持 strict 参数
  • 接收 任意数量 的可迭代对象(如列表、元组、字符串、生成器等);
  • 返回一个 zip 对象 (迭代器),每次产出一个元组 ,包含各输入对象的当前元素
  • 在最短的输入耗尽时停止(默认行为)。
python 复制代码
a = [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>'
相关推荐
ZhengEnCi3 小时前
FastAPI 项目结构完全指南-从零基础到企业级应用的 Python Web 开发利器
服务器·python·web3
林炳然3 小时前
Python的“一行搞定”风格
python
java1234_小锋3 小时前
TensorFlow2 Python深度学习 - 使用Dropout层解决过拟合问题
python·深度学习·tensorflow·tensorflow2
wanfeng_093 小时前
python爬虫学习
爬虫·python·学习
唐叔在学习3 小时前
Pyinstaller - Python桌面应用打包的首选工具
后端·python·程序员
Victory_orsh4 小时前
“自然搞懂”深度学习系列(基于Pytorch架构)——01初入茅庐
人工智能·pytorch·python·深度学习·算法·机器学习
大模型真好玩4 小时前
LangGraph实战项目:从零手搓DeepResearch(二)——DeepResearch架构设计与实现
人工智能·python·langchain
濑户川4 小时前
基于DDGS实现图片搜索,文本搜索,新闻搜索
人工智能·爬虫·python
深蓝电商API5 小时前
快速上手 Scrapy:5 分钟创建一个可扩展的爬虫项目
爬虫·python·scrapy