news 2026/10/14 1:50:08

深入Python数据模型:用Vector掌握序列、散列与切片协议

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入Python数据模型:用Vector掌握序列、散列与切片协议

读《Fluent Python》第10章,我的第一反应是标题里"序列、散列、切片"这三个词其实是一条暗线,它们表面各管一摊,实际全被一个叫 Vector 的示例类串在一起。如果你和我一样,之前写自定义类时只是被动地补一两个__getitem__、__len__,从没认真想过"协议"到底意味着什么,这一章就是逼你把 Python 数据模型真正内化的一次集训。我花了两个晚上把书里的 Vector 从零复现了一遍,又额外做了十几个小实验,这篇笔记就是复现过程的完整记录。内容适合已经掌握了装饰器、迭代器基础,准备深入数据模型的读者;如果你正被__getitem__的切片参数或者__hash__的返回值搞晕,也可以直接翻到对应小节。

1. Vector 类为什么能当第10章的"实验台"

1.1 从二维向量到任意维序列的跨度

先明确这本书想做什么。它要用一个自定义类把"序列"这件事做透,目标是Vector—— 可以是一维、二维、三维乃至任意维的向量。你给Vector([3.0, 4.0, 5.0]),它要能像list一样被索引、切片、迭代,同时还要能取模、比较、散列,甚至能通过v.x、v.y这类属性访问分量。

这就有意思了。内置类型list、tuple本身就把len()、iter()、索引、切片这些行为全部实现好了,你直接继承、组合都行;但那样学不到协议。Vector 的内部存储用了array('d', components),这意味着它不满足于"存了一串数字",而是要用一个紧凑的实数数组承载任意维度。这个选择直接决定了后面每个协议方法的写法:所有面向序列的操作,最终都要落到self._components上。

1.2 三大协议的最小交集

我读完这章后最大的收获是:你不需要一次把所有特殊方法铺开,而是先满足"最小可用集"。第10章涉及三个协议,各有各的独立性和联合约束:

  1. 序列协议:__len__负责长度,__getitem__负责索引和切片,__iter__负责迭代,__contains__负责in判断,这四者共同决定一个对象"像不像序列"。
  2. 散列协议:__hash__和__eq__必须成对出现,且要满足"散列一致律"——两个对象相等则散列值必须相等。这就逼着你思考:一个可变对象到底该不该开放散列?
  3. 切片协议:切片本质上不是__getitem__的第四个参数,而是 Python 在走*args路径时传入的一个slice对象。很多新手以为切片就是start:stop:step三个整数,真到了自定义序列里才发现,v[1:5]传给__getitem__的其实是一个整体。

这三个协议里,序列协议是最基础的,散列协议是最有约束的,切片协议是最容易踩坑的。Vector 恰好把三者全部压在一个类里,于是这本书可以围绕着"把一个类逐步打磨成完整的内置风格对象"来讲,而不是东一段西一段地贴方法签名。

1.3 为什么不直接用 list 当例子

可能有人会问:直接用list加几个方法不就有切片、有散列了吗?问题的关键在于,list的切片返回的是一个新 list,而你自定义的 Vector,如果想要v[:2]返回还是一个 Vector,就必须自己控制切片的返回类型。这种情况下,如果底子直接暴露原生列表,你会被动接受不想要的类型和可变性。所以 Vector 内部用array存组件,外层用自己的__getitem__包装返回对象,才能体现"用协议重新定义行为"这一点。

我实际复现时,刻意先写了一个"能跑但很丑"的版本:所有方法都在外部操作v._components。跑了几个例子后立刻发现,这种写法虽然能出结果,但类的一致性非常差——slice返回 list、迭代返回生成器、repr输出怪异。这之后我才理解书里逐步重构的意义:协议不是炫技,是让对象在自己定义的语义边界内保持统一。

2. 切片在__getitem__里的真实形态与返向传播

2.1 写v[1:5]时解释器到底传了什么

这是整章我最想先写明白的一点。你定义一个__getitem__(self, index),当 Python 执行v[1:5]时,index并不是元组(1, 5),也不是两个参数,而是一个slice对象:

class Probe: def __getitem__(self, index): print(type(index), repr(index)) return index p = Probe() p[1:5] # <class 'slice'> slice(1, 5, None) p[1:7:2] # <class 'slice'> slice(1, 7, 2) p[:] # <class 'slice'> slice(None, None, None) p[3] # <class 'int'> 3

这个发现对我冲击挺大,因为之前用list时你完全不需要关心切片的内部结构,解释器已经把逻辑吞掉了。但自定义序列里,你必须自己处理slice对象。书里 Vector 的写法是:如果index是 slice 对象,就切片后再包一个同类对象;如果是整数索引,就直接返回_components[index]。这是一个非常好的分支结构,它把两种调用语义提前隔离了。

2.2slice对象的indices()方法:把切片语法翻译成区间

但只做"切片后包一层"还不够。真正的切片难点在于边界条件:负索引、步长为负、越界,这些内置list都帮我们处理了,自定义序列里如果直接self._components[index],那其实是在偷懒——你依赖了底层 sequence 的切片实现。一旦你的数据源不是原生切片对象(比如是数据库查询结果、是流式数据),就必须知道 slice 的完整语义。

slice对象自带indices(length),这是第10章里最实用的工具之一。它把start、stop、step三个字段连同目标长度一起翻译成四个整数:起点、终点、步长、切片长度。我写了一个独立演示:

s = slice(1, 7, 2) print(s.indices(20)) # (1, 7, 2, 3) print(s.indices(3)) # (1, 3, 2, 1) s2 = slice(-3, None, None) print(s2.indices(10)) # (7, 10, 1, 3) s3 = slice(None, None, -1) print(s3.indices(5)) # (4, -1, -1, 5)

注意slice.indices()的返回是(start, stop, step, length),前三个参数可以直接传给range()生成真正的索引序列。这个特性在实现逆序切片、负步长时尤其好用,因为手工写边界判断非常容易出错。

我还做过一个实验:给 Vector 增加一个方法返回切出的元素列表,而不是返回 Vector,然后用indices()分批取出数据。在处理大数据集时,你可以避免一次性复制整个底层数组。

2.3 切片返回同类型对象的价值与边界

书里强调切片返回"同类对象"的意义:v[:2]应该返回Vector,而不是返回array或者list。实现其实很简单:

def __getitem__(self, index): cls = type(self) if isinstance(index, slice): return cls(self._components[index]) return self._components[index]

这样Vector([3.0, 4.0, 5.0])[:2]会得到Vector([3.0, 4.0]),而v[1]得到的是普通的 float。这里有个很容易忽略的细节:type(self)而不是写死Vector,是为了让子类继承时仍然返回正确的类型。我曾经一度写成return Vector(...),后来为 Vector 加子类时就出了类型错误。这个cls = type(self)的习惯,值得在任何返回同类型对象的方法里保留。

切片赋值是另一个方向。Vector 本身是仿照不可变序列设计的,没有__setitem__,所以v[0] = 99会抛TypeError。但书里没有详细展开切片赋值,我在测试中自己补了一段:

class ExtVector(Vector): def __setitem__(self, key, value): if isinstance(key, slice): start, stop, step, _ = key.indices(len(self._components)) if step != 1: raise ValueError("step != 1 的切片赋值暂不支持") self._components[start:stop] = value else: self._components[key] = value

这个改动让我意识到:indices()不只是用来"读"的,它对"写"同样关键。原生切片赋值的语义是"用右侧序列替换切片区间",你自己实现时,需要同时处理步长不为1时的对齐规则,否则很容易写出和内置行为不一致的代码。

2.4 切片相关的一个容易概念混淆的点

很多人会把__getitem__里的index参数理解成"只能是一个整数或 slice 对象"。但事实上,元组索引也会进入同一个方法,例如v[1, 2],这常见于多维数组。Vector 是单维序列,不需要理会元组;但如果你想兼容多维场景,就需要在__getitem__分支里先判断isinstance(index, tuple)。

还有一点:Python 的slice对象没有验证索引是否越界,越界与否完全取决于你调用indices()或底层序列时的行为。我最初在实现自定义序列时写了一个while循环去规范化负索引,后来发现底层数组切片已经抛越界错误了,但自定义的边界判断却先挡了一步,导致错误信息不符合预期。这个教训是:当你想模仿内置行为时,最好的策略不是自己写边界逻辑,而是把工作转交给底层序列或slice.indices()。

3. 散列协议没有"加个__hash__"那么简单

3.1 可散列对象的三条铁律

第10章里关于散列的推导顺序很值得反复读:先明确一个对象要作为 dict 的键或 set 的元素,必须满足三条铁律。

第一,等值对象的散列值必须相等。这直接意味着:如果你重写了__eq__,而之前的某个对象已经放进 dict 里了,你的__hash__必须保证相等对象有相同散列,否则查找直接失败。

第二,对象的散列值在生命周期内不能改变。这就是为什么不可变序列比可变序列更适合做 dict 键。Vecotr 用了array,但array本身是可变容器,所以书里刻意不做__setitem__、不做append等可变操作,目的就是阻止用户修改内部组件,保证散列的稳定性。

第三,相等关系必须一致。__eq__返回 True 的两个对象,散列值应该一样;如果两个对象的散列值碰巧一样,__eq__不一定非要返回 True(这是正常碰撞)。但反过来,散列值不同就一定不能相等。

这个规则决定了实现顺序:先写__eq__,再写__hash__。如果你先写__hash__再补__eq__,往往会出现"散列相同但相等判断矛盾"的隐蔽问题。

3.2 组合散列:从functools.reduce到逐位混合

Vector 要散列的是它的所有分量,看起来很像hash((3.0, 4.0)),但直接转换成 tuple 再 hash 会损失"Vector 自身类型"的参与感。书里的思路是:把每个分量的散列值依次 XOR 起来,用reduce做归约:

import functools import operator def __hash__(self): hashes = map(hash, self._components) return functools.reduce(operator.xor, hashes, 0)

我第一次看到这个实现时有两个疑惑:为什么从 0 开始归约?为什么用 XOR 而不是加法或乘法?后来我自己做了实验。归约初始值必须是 0,因为 XOR 的幺元是 0,这样空 Vector 的散列值是 0,不会抛错;用加法或乘法会导致常见的小数值大量碰撞,而且分量顺序会影响结果,用 XOR 天然满足交换律,跟分量的先后顺序无关——这一点对向量来说很重要,因为向量在语义上不是有序元组。

后来我又试过直接用hash(tuple(self._components)),结果也返回一个可用的散列值。但书里坚持手工 XOR 是因为它展示了一种通用的"逐字段混合"思路。在实现更复杂的可变对象时,你会针对每个字段写hash(field),然后一个个混合,而不是把整个对象转成 tuple。从工程角度讲,reduce(xor)的写法通用性更强,也更容易扩展现有字段。

3.3 实现过程中我自己踩过的散列坑

第一个坑是__hash__和__eq__的实现顺序。我先写了__hash__,再去写__eq__,结果测试时发现:两个不同维度的 Vector 某些情况下散列值相同,但__eq__返回 False,这是正常的;可一旦我改了某个分量的类型(float vs int),散列又变得复杂。最后我干脆遵循书里的做法:__eq__只把自己跟类型相同且维度相同的 Vector 比,散列则把类型信息也混进去。

第二个坑是关于浮点数的散列稳定性。3.0和3的散列值相同,但3.0000000001和3.0显然不同。如果你在实现数据类时允许用户修改内部值,散列就会随内容变化失效。Vector 的设计通过"哑属性"和不可变接口规避了这一点,但在真实项目中,我建议任何提供散列能力的对象都尽可能把内部状态设为只读。

第三个坑是functools.reduce(operator.xor, hashes, 0)里的零值陷阱。如果hashes里某个值是 0(比如hash(0.0)),XOR 不会改变结果,这没问题;但如果hashes里出现了负数,XOR 也能正常工作。真正需要注意的是:不要把初始值换成None,None不是整数,operator.xor会直接报错。

4.__getattr__与__repr__:让自定义序列更接近内置对象的体验

4.1 动态属性访问的机制,以及为什么不能直接加属性

Vector([3.0, 4.0, 5.0])里的v.x应该等于 3.0,v.y应该等于 4.0。但问题是向量维度是任意的,如果给每个维度都定义属性x、y、z乃至a、b、c,代码会无比冗长。书里用__getattr__解决了这件事:

def __getattr__(self, name): cls = type(self) if len(name) == 1: idx = ord(name) - 97 # 'a' -> 0, 'b' -> 1 ... if 0 <= idx < len(self._components): return self._components[idx] raise AttributeError(f"{cls.__name__!r} object has no attribute {name!r}")

关键点在于__getattr__只在常规属性查找失败之后才被调用。所以当你读取v.x时,Python 会先找实例字典,找不到再找类属性,全都没有后进入__getattr__。这带来一个非常隐蔽的问题:一旦v.x被访问过一次,它并不会被缓存到实例字典里,下一次访问依然重新计算。而如果你在某个地方给v.x = 100,它倒是会写进实例字典,之后__getattr__不再触发,v.x就变成了一个普通属性。

这导致的常见 bug 是"动态属性和实例属性互相污染"。书里用__setattr__做了约束,禁止single-letter属性被赋值:

def __setattr__(self, name, value): if len(name) == 1: raise AttributeError("single-letter attributes are read-only") super().__setattr__(name, value)

这里最常被忽略的是:__setattr__里调用super().__setattr__而不是直接self.__dict__[name] = value,因为后者会绕过基类的逻辑,在更复杂的类里造成初始化顺序混乱。我在自己写动态属性的类时,已经把"继承__setattr__时一律走super()"当成默认习惯。

4.2 动态属性的边界:别把所有字符串都当维度坐标

__getattr__的name参数可以是任意字符串,不只是单个字母。如果你只判断len(name) == 1,那么v.x、v.y能正常工作,但v.x之外的属性(比如v.weight)会直接抛出AttributeError。我也试过把条件放宽到"名字以_comp开头",结果立刻导致_components本身也被拦截,然后递归报错。

再延伸一点:__getattr__很容易让人产生一种"这个对象什么都能访问"的错觉,但在性能敏感代码里,每次属性访问都要经过一次 Python 层函数调用。如果某个属性频繁读取,更好的做法是第一次访问后缓存到self.__dict__。

4.3repr的"精确复用"理想和bytes的关系

内置数据类型之所以调试舒服,很大程度靠的是repr足够还原。书里给 Vector 的__repr__设计目标是:eval(repr(v))应该能重建一个等价的 Vector。这是一个非常高的标准。实际实现时可以先把底层array('d', ...)转化成可读的列表字符串,去掉括号后拼进去:

def __repr__(self): components = repr(self._components)[1:-1] return f"Vector({components})"

运行效果是:Vector([3.0, 4.0, 5.0])。repr(self._components)对数组来说会输出类似array('d', [3.0, 4.0, 5.0])的字符串,切片[1:-1]去掉外层括号,正好得到"array('d', [3.0, 4.0, 5.0])"的内部部分?我第一次写的时候没意识到array的 repr 还包含类型前缀,于是直接把整个 repr 塞进去,输出的就是带array('d', ...)前缀的怪异字符串。这里最稳妥的方式是自己格式化:

def __repr__(self): comps = ", ".join(repr(c) for c in self._components) return f"Vector({comps})"

bytes是另一个方向。书里为 Vector 实现了__bytes__和frombytes类方法,本质是把向量分量的二进制编码导出,方便序列化。这不算序列协议的一部分,但和第10章的"序列"主题关系很紧。我在测试时发现,如果想用bytes(v)导出,只需要把array的 bytes 拼接即可;对应的反序列化则要注意读取时的边界条件。工程上,我一般更推荐直接使用 Python 的pickle或者直接转bytes存数据库,但手写__bytes__能让你理解"自定义对象的二进制表示到底怎么设计"。

5. 协议联动:从头实现一遍第10章的 Vector 核心

5.1 一个可直接运行的最小实现

到这里,我把我复现出的最终版本整理成了一个相对精简的类,你可以直接跑:

import functools import math import operator from array import array class Vector: type_code = 'd' def __init__(self, components): self._components = array(self.type_code, components) def __len__(self): return len(self._components) def __iter__(self): return iter(self._components) def __getitem__(self, index): cls = type(self) if isinstance(index, slice): return cls(self._components[index]) return self._components[index] def __repr__(self): comps = ", ".join(repr(c) for c in self._components) return f"Vector({comps})" def __bytes__(self): return bytes([ord(self.type_code)]) + bytes(self._components) @classmethod def frombytes(cls, octets): type_code = chr(octets[0]) memv = memoryview(octets[1:]).cast(type_code) return cls(memv) def __eq__(self, other): return ( isinstance(other, Vector) and len(self) == len(other) and all(a == b for a, b in zip(self, other)) ) def __hash__(self): hashes = map(hash, self._components) return functools.reduce(operator.xor, hashes, 0) def __abs__(self): return math.sqrt(sum(x * x for x in self)) def __bool__(self): return bool(abs(self)) def __getattr__(self, name): cls = type(self) if len(name) == 1: idx = ord(name) - 97 if 0 <= idx < len(self._components): return self._components[idx] raise AttributeError(f"{cls.__name__!r} object has no attribute {name!r}") def __setattr__(self, name, value): if len(name) == 1: raise AttributeError("single-letter attributes are read-only") super().__setattr__(name, value)

我建议你实际跑一下这些行为:

v = Vector([3.0, 4.0, 5.0]) print(len(v)) # 3 print(v[0], v[1:3]) # 3.0 Vector(4.0, 5.0) print(v.x, v.y, v.z) # 3.0 4.0 5.0 print(abs(v)) # 7.0710678118654755 print(hash(v) == hash(Vector([3.0, 4.0, 5.0]))) # True print(set([Vector([1.0, 2.0]), Vector([1.0, 2.0])])) # 去重

5.2 为什么这套设计在真实项目里意义更大

可能你会觉得,Vector 这个例子太窄,真实业务里哪有那么多向量。但我个人觉得这章的方法论通用性很强。你在做配置类、实体类、数据记录类时,同样会遇到"对象要支持哪些操作行为"的问题。做一个具有散列能力的不可变实体:

  • __eq__决定它能不能放进集合;
  • __hash__决定它能不能做 dict 的键;
  • __repr__决定日志里好不好排查;
  • __getitem__决定它能不能像数组一样被外部代码使用。

这四种能力组合起来,就是"一个合格的值对象"的基本素养。

在第10章末尾,书里还往前迈了一步:让 Vector 的每个分量可以通过angle(n)方法获取角度,并且用format()控制格式化输出。这部分内容其实是在说:一旦基础协议搭好,后续添加行为会非常顺滑。我在实现format()时,发现它需要依赖前面的__len__、__getitem__和角度计算,层层调用,但整个结构依然清晰——这正是基础协议扎实带来的收益。

5.3 我复现时踩过的三个具体坑

第一个坑是frombytes里的memoryview.cast。我一度忘了cast需要先创建 memoryview 再调用.cast(type_code),导致直接octets[1:].cast找不到方法。改成两行后问题解决。这里要记住:memoryview对象是cast的调用者,不是 bytes 的调用者。

第二个坑是__eq__里对"不同类型"的处理。如果严格按isinstance(other, Vector)判断,那么 Vector 和 tuple 永远不相等,这符合数学上"类型不同不比较"的直觉。但如果你希望 Vector 能和同样结构的array比较,就需要把isinstance放宽。这个取舍没有标准答案,完全取决于你的业务语义。

第三个坑是空 Vector。Vector([])是合法的,长度 0,散列值为 0,布尔值为 False。如果你在实现__abs__时直接对空数组求和再开方,会得到math.sqrt(0)=0.0,没问题;但如果你换成max(abs(c) for c in self)来定义模,空向量会直接抛ValueError。很多真实的序列类都容易在空对象上"翻车",测试时一定不要漏掉这个边界。

6. 几个我在实际开发中沉淀下来的序列设计检查清单

6.1 从这章延伸出来的自查清单

读第10章不只是在学几个魔法方法,更是在建立一套"自定义序列自查表"。我现在接手一个新类,如果它要扮演序列角色,会按下面的顺序过一遍:

  1. 长度和迭代:__len__、__iter__必须成对,否则很多需要len()的场景会退化。
  2. 索引和切片:__getitem__处理 int 和 slice 两种分支,返回类型是否保持同类型。
  3. 成员测试:默认情况下in是通过迭代实现的,对性能敏感就加__contains__。
  4. 相等和散列:要么都实现,要么都不实现;实现一个而漏掉另一个,会让对象在集合和字典里的行为不可预测。
  5. 字符串形态:__repr__尽量满足eval可重建;__str__才是给人看的。
  6. 可变性:不确定需要变化时,优先做成不可变,后续需求来了再加__setitem__成本低而且兼容风险小。

6.2 与collections.abc的关系

这章内容其实和collections.abc.Sequence有强关联。如果你直接继承MutableSequence,抽象基类会逼你实现__getitem__、__len__、__setitem__、__delitem__和insert,然后自动给你补上append、extend等一堆方法。Vector 没有选择这条路,而是手动实现协议方法,因为 Vector 的语义更接近Sequence,不是MutableSequence。

我的经验是:如果只是想要一个"能用原生列表大部分方法"的对象,继承collections.abc是对的;但如果你想深度定制序列行为、又要同时实现散列和动态属性,像 Vector 这样手动组合会更自由。

6.3 最后分享一个小的调试技巧

调试自定义序列时,我总会写一个极简的探针类,像开头那样打印出__getitem__收到的实际参数类型。这个探针类只需要三五行代码,却能快速验证"你写的语法到底传了什么"。我在调试v[1:5, 2]这种多段索引时,也是靠探针发现index是元组(slice(1,5), 2),一下子就把问题定位了。这个小习惯可能比整章笔记更值钱。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 1:49:20

PyGame植物大战僵尸模拟器:可调试可二次开发的游戏引擎实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:46:46

PyTorch入门实战:三天搭出能跑通的神经网络模型

1. 这不是“又一篇PyTorch教程”&#xff0c;而是一份能让你三天后真能跑通自己模型的实操手记你点开这个标题&#xff0c;大概率正站在两个路口之间&#xff1a;一边是满屏“Tensor、autograd、nn.Module”的术语轰炸&#xff0c;一边是“Hello World”式MNIST分类后戛然而止的…

作者头像 李华
网站建设 2026/10/14 1:46:29

Spec Coding:先写清验收标准

Spec Coding让我更在意一件事&#xff1a;我到底有没有想清楚。写不出验收标准&#xff0c;往往是需求还没想透。AI能很快补出一套实现&#xff0c;却无法替我决定&#xff0c;那是不是业务真正需要的结果。#李家贵 #AI顾问李家贵配图由AI辅助生成。

作者头像 李华
网站建设 2026/10/14 1:45:48

Excel 判断相等

等号&#xff1a;不区分大小写和类型&#xff1b;EXACT&#xff1a;区分大小写。 两者都对空格敏感&#xff08;多一个空格就判 FALSE&#xff09;例子公式返回结果说明“Abc” vs “abc”"Abc""abc"TRUE等号忽略大小写“Abc” vs “abc”EXACT("Abc&…

作者头像 李华