读《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章涉及三个协议各有各的独立性和联合约束序列协议__len__负责长度__getitem__负责索引和切片__iter__负责迭代__contains__负责in判断这四者共同决定一个对象像不像序列。散列协议__hash__和__eq__必须成对出现且要满足散列一致律——两个对象相等则散列值必须相等。这就逼着你思考一个可变对象到底该不该开放散列切片协议切片本质上不是__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 也能正常工作。真正需要注意的是不要把初始值换成NoneNone不是整数operator.xor会直接报错。4.__getattr__与__repr__让自定义序列更接近内置对象的体验4.1 动态属性访问的机制以及为什么不能直接加属性Vector([3.0, 4.0, 5.0])里的v.x应该等于 3.0v.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 fVector({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 fVector({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 fVector({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章不只是在学几个魔法方法更是在建立一套自定义序列自查表。我现在接手一个新类如果它要扮演序列角色会按下面的顺序过一遍长度和迭代__len__、__iter__必须成对否则很多需要len()的场景会退化。索引和切片__getitem__处理 int 和 slice 两种分支返回类型是否保持同类型。成员测试默认情况下in是通过迭代实现的对性能敏感就加__contains__。相等和散列要么都实现要么都不实现实现一个而漏掉另一个会让对象在集合和字典里的行为不可预测。字符串形态__repr__尽量满足eval可重建__str__才是给人看的。可变性不确定需要变化时优先做成不可变后续需求来了再加__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)一下子就把问题定位了。这个小习惯可能比整章笔记更值钱。