1. 从"为什么不能切片"到序列协议
很多人写自定义类时,大概都遇到过这样一个瞬间:写了一个存放坐标点的类,明明数据是按顺序存的,想用p[0]拿到第一个分量,结果解释器冷冷地甩给你一句TypeError: 'Point' object is not subscriptable。那一刻的感受是:凭什么列表能做的,我的类就不能做?
这个问题的答案,就藏在《Fluent Python》第10章里。这一章表面上是讲"序列的修改、散列和切片",实际核心只有一句话:让一个普通类成为真正的序列,靠的不是继承某个基类,而是实现几个特定的方法。这套方法组合起来,Python里叫"协议"——一个非常松散的约定,你实现了它,你的对象就具备了那类行为,你少实现一个,就少一种能力。
换句话说,Python的"类型"很大程度不是由class声明决定的,而是由你实现了什么方法决定的。这跟现实中招聘很像:面试官通常不看你简历上写"我是高级工程师",而是让你现场写一段代码、讲一个方案,你能做出来,你就是。Python的序列协议就是这一套"现场考核"。
第10章用一个N维向量类Vector作为贯穿全章的案例,依次补上了序列协议、切片、动态属性访问、散列和相等性判断这些能力。我最初读这一章时觉得内容零散,后来才发现,作者是在用一个真实类的演进过程,把序列协议的各个侧面串了起来。这篇笔记,我就按照这个思路,把每一块拆开讲清楚:为什么这么设计、代码怎么写、踩过哪些坑。
如果你是一个写过一点Python、但对自己定义的类总感觉"差点意思"的开发者,这一章和这篇笔记值得你静下心看一遍。它不会教你新的框架或语法糖,而是让你理解Python对象系统最底层的运作方式。
2. 序列协议的第一层:__len__和__getitem__
先从一个最基本的问题说起:Python到底怎么判断一个对象能不能像序列一样被访问?答案是看两个方法:__len__和__getitem__。实现了这两个方法,你的对象就可以被len()取长度、用下标取值、被for循环迭代,甚至可以用in做成员判断——当然,最后这条严格来说依赖__contains__或迭代扫描,后面细说。
__len__负责告诉解释器这个序列有多长,__getitem__负责按下标取元素。拿向量类来说,数据本质是一个tuple,所以实现起来非常直觉:
class Vector: def __init__(self, components): self._components = tuple(components) def __len__(self): return len(self._components) def __getitem__(self, index): return self._components[index]写到这里,Vector([3, 4, 5])就可以用len(v)拿到3,用v[0]拿到3,用v[-1]拿到5。看起来平平无奇,但注意一件事:__getitem__里的index,既能接收整数,也能接收切片。如果来了一个切片,你直接把它丢给self._components[index],返回的是一个tuple切片,而不是Vector。这个行为符合直觉吗?不符合。用户对v[1:3]的预期是"还是一个向量",而不是"一个我根本不认识的元组"。所以要手动拦截切片,返回新的Vector。这个细节,正是切片这一节的重点,后面单独说。
到这里,Vector已经具备了一个序列最基本的骨架:能取长度、能按下标取值、能迭代(因为__getitem__是迭代的兜底方案),但还远不是完整的序列。完整序列涉及的方法,我用一张表列出来,方便对照:
| 协议方法 | 作用 | 是否必须 |
|---|---|---|
__len__ | 返回序列长度 | 序列的基本能力 |
__getitem__ | 按索引取值,支持切片 | 序列的基本能力 |
__setitem__ | 按索引赋值 | 可变序列需要 |
__delitem__ | 按索引删除 | 可变序列需要 |
__contains__ | 成员判断,in运算 | 可选 |
__iter__ | 返回迭代器 | 迭代首选,推荐实现 |
第10章的Vector是一个不可变序列,所以只需要关注前两行以及后面的散列、动态属性这些内容,不需要实现__setitem__。很多初学者一上来就纠结"为什么我的类改了成员变量不生效",正是因为把可变序列的职责安在了不可变序列的头上,这个认知先掰过来,后面少踩一半的坑。
单在这里停一下,我想说一个经验:实现__getitem__时,很多人会顺手把整个逻辑写进方法里,这是完全正常的,但更推荐的做法是,先把索引处理逻辑抽成一个小工具方法,或者至少在__getitem__内部清晰地分两条分支——一条处理整数,一条处理切片。因为后续不管是做动态属性还是做__contains__,你都有可能复用"按索引取分量"的逻辑。代码结构上的这种小调整,在类变复杂之后能显著降低维护成本。
3. 切片不再是"看上去能用就完事"
切片这一节,我单独拿出来讲,因为这可能是全章中让人"觉得自己会了,实际一写就错"的地方。我先抛一个最简单的切片的执行过程,然后逐层展开。
当你写下v[1:3]的时候,Python解释器会把中括号里的东西解析成一个slice对象,然后调用v.__getitem__(slice(1, 3, None))。这个转换过程,对于任何实现了__getitem__的类都是一样的。问题在于,你的__getitem__收到这个slice对象后,知道怎么处理吗?
第一种偷懒写法是,直接把index透传给内部数据结构:
def __getitem__(self, index): return self._components[index]这样对v[1:3]来说,你会得到一个tuple。功能上没报错,但类型语义上已经不对了。用户期望的是"向量的切片还是向量",你给的是"元组的切片"。
正确的实现需要判断index类型:
def __getitem__(self, index): if isinstance(index, slice): return Vector(self._components[index]) return self._components[index]这里有个细节值得展开:为什么slice分支要重新包装成Vector?因为Vector是不可变序列,从设计上就应该保证"对原对象的任何切片操作,都产生一个新的、独立的对象"。这不只是类型正确,还牵扯到不可变性的一致性:原向量没有被修改,切片的结果也不必与原向量共享任何可变状态。tuple切片本来就是新对象,所以直接透传也没有别名问题,但类型正确性依然要靠Vector(...)包装来保证。
还有一个容易忽略的参数:slice对象本身带有start、stop、step三个属性,而且这三个属性可能是None。如果你要做更精细的逻辑,比如把自己实现的序列与内置序列的切片行为对齐,可以调用切片对象的indices()方法,它能把len考虑进去,返回一组真正的起始、终止、步长:
def __getitem__(self, index): if isinstance(index, slice): start, stop, step = index.indices(len(self)) return Vector(self._components[start:stop:step]) return self._components[index]这一步不是必需的,因为self._components本身是tuple,它的切片已经处理好了边界问题。但如果你自己设计的数据结构不是用内置序列存储的,比如你决定用更紧凑的数组、或者自定义的存储结构,那slice.indices(len(self))就是你处理和边界对齐的第一道工具。我建议即使现在用不到,也把这个方法记住,它是处理自定义切片时最可靠的一扇门。
再往后延伸一点,如果你实现的是多维序列,切片的处理会更复杂,因为index可能是一个元组,比如v[1:3, 2]。第10章没有展开这个场景,但如果你脑子里有这个意识,以后再去看NumPy的索引机制时,会发现一切都从同一个__getitem__入口出发。这个章节里讲的Vector只是一维序列,切片处理就是判断类型、包装新对象,但底层思路完全通用。
对于切片,最后说一个容易被人忽略的测试点:空切片和步长为负数的反向切片。v[::-1]返回逆序的向量,v[10:20]返回空向量。这两个边界写在测试用例里,能挡住一大批只写了正常路径的代码。我自己一开始也栽在反向切片上——因为self._components[::-1]是正常工作的,但如果你手写循环来构造新序列,步长为负时特别容易写错。
4. 动态属性访问:让v.x等于v[0]
这一节的内容,属于那种"不看不知道,一看觉得JavaScript都白学了(不是)"的巧妙设计。向量类现在有了序列的下标访问能力,但如果能让v.x直接等价于v[0]、v.y等价于v[1],用起来会舒服很多。这个需求,正是靠__getattr__来实现的。
先解释__getattr__的触发机制:当访问一个对象的属性时,Python会先在实例字典、类、父类里依次查找;只有全部找不到的时候,才调用__getattr__。换句话说,__getattr__是"属性查找流程走投无路时的最后一张底牌"。
基于这个机制,写起来就直白了:
def __getattr__(self, name): if len(name) == 1: pos = ord(name) - ord('x') if 0 <= pos < len(self._components): return self._components[pos] raise AttributeError(f'{type(self).__name__!r} object has no attribute {name!r}')逻辑很清晰:访问v.x时,__getattr__收到字符串'x',通过ASCII码差值转成索引0,然后从分量序列里取值。访问v.z,就是索引2,如果向量只有2维,索引越界,就抛AttributeError。这是一个非常典型的"约定式命名"——用单个字母映射到分量位置。
想清楚原理之后,有几个边缘情况需要处理:
第一个是属性名不只是单字母的情况。比如v.alpha,按照上面的逻辑,len(name) != 1,直接抛AttributeError。但如果写成len(name) == 1只是一个粗略的检查,你还得考虑pos是否超出范围。超出范围时,应该抛AttributeError而不是返回一个错误的值。这一点容易漏:如果开着上面的代码,Vector([3, 4, 5])访问v.z,pos = ord('z') - ord('x') = 2,在范围内,正常返回第3个分量。但如果向量是Vector([3, 4]),v.z就不能返回任何东西,必须抛异常。
第二个坑是__getattr__里访问了不存在的属性可能导致无限递归。比如你在__getattr__内部写了self._components,如果_components属性本身没被找到,就会再次触发__getattr__,传进来的名字变成'_components',然后你的len(name) != 1分支抛AttributeError,递归终止。但如果你的代码写得不小心,比如直接调用了self.foo,就有可能陷入无限循环。我自己在写类似逻辑时,会先在__getattr__里加上对_components是否存在的判断,避免这种幽灵递归。
第三个坑,也是书上专门花篇幅强调的:实现了__getattr__还不够,还得处理__setattr__。原因很简单:如果复制一个向量类,不实现__setattr__,那么v.x = 100会正常通过——但它不是修改_components[0],而是创建一个名为x的实例属性。以后访问v.x时,属性查找流程会先找到这个真实存在的实例属性,而__getattr__根本不会触发。这会导致两个不一致:一是分量值和下标访问对不上,二是v.x和v[0]从此分道扬镳。这在设计上是个灾难。
解决方法是,让__setattr__拦截单字母属性的赋值,把它转发到序列的对应位置上。因为Vector在第10章里被设计为不可变序列,所以正确做法是阻止这种赋值,或者如果你把类设计成可变序列,就把_components换成list并真正改对应元素。我在自己的实现中,处理方式是这样的辅助逻辑:
def __setattr__(self, name, value): if len(name) == 1: pos = ord(name) - ord('x') if 0 <= pos < len(self._components): raise AttributeError('cannot set component') super().__setattr__(name, value)这里的关键是:单字母且在分量范围内的名字,直接拒绝赋值,其它名字走super().__setattr__正常设置。这样才能保证_components不被意外篡改,也保证v.x = 100不会静默创建一个古怪的属性。
实现动态属性时,我的一个体会是:不要只关注__getattr__这一个方法,而是要把"读"和"写"两条路径一起设计。只做读,不做写,就会出现"读起来像序列,写起来却变成普通对象属性"的撕裂状态。这类问题很难在测试里立刻暴露,往往要等到某个下游代码真的给v.x赋值了才炸出来。
5. 散列与相等性:一对待办事项必须同时交付
序列协议讲得差不多了,第10章的另一半重心落在__hash__和__eq__上。这一对方法的关系,可以类比成现实中的身份证和姓名:两个对象如果相等(姓名相同、出生日期相同),它们的散列值必须相同(身份证号相同);反过来,散列值相同,不代表对象一定相等。映射到Python的规则,就是"如果a == b,那么hash(a) == hash(b)"——这是强制约定,违反它,对象放进set或者作为dict的键,就会出现诡异行为。
很多初学者第一次看到这个规则会问:为什么?原因是哈希表依赖散列值来快速定位"可能的候选",如果两个相等的对象落在不同的散列值上,查找时就会漏掉它们。你可以想象一个图书馆,每本书的索书号规则是"姓名的拼音字母前缀",如果两本完全相同的书被分到不同的索书号区间,你要找其中一本时就可能翻到另一本的位置,找不到。反过来,不同书籍落在同一个字母段很常见,检索时再多做一次精确比较就行。哈希表对这个问题的容忍度就是"散列值相同是必要条件,不是充分条件"。
既然Vector打算做不可变序列,就有资格被放进set、被用作dict的键。但我前面提到过,Vector虽然设计上"不可变",__setattr__却允许你设置任意非单字母属性。从严格意义上说,这已经破坏了不可变性前提。只不过,官方约定中__hash__和__eq__配对实现时,如果对象包含可变属性,自动将__hash__设为None(也就是不可散列)。设计Vector时,你要自己决定是否承担这个风险。书里的示例是单字母分量不可改,但保留其他属性可写的灵活性,这样的设计在实际使用中足够安全。
真正写代码时,先看__eq__的实现。两个向量相等的标准有两个:维度相同,对应分量全部相同。于是:
def __eq__(self, other): if not isinstance(other, Vector): return NotImplemented if len(self) != len(other): return False return all(a == b for a, b in zip(self, other))这里的NotImplemented很有讲究:它告诉Python"我不知道如何比较这个类型",让Python有机会去调用另一个对象的反向比较方法。如果两个对象类型完全不同,硬着头皮比较没有意义,返回NotImplemented才是协议的正确姿势。
这里还有个容易踩的细节:zip(self, other)的长度行为。如果两个向量长度不同,zip会截断到较短的那个,所以循环内永远比不到不存在的分量。为此要在进入zip之前先比较长度,否则会出现Vector([1, 2]) == Vector([1, 2, 3])误判为True的情况。我见过不少实现,因为漏了这一步,在长度不同的场景下给出了错误结果。
再看__hash__。目标是让所有分量都参与散列值计算,并且分量顺序敏感(Vector([1, 2])和Vector([2, 1])要有不同散列值)。最简单的做法是:
def __hash__(self): hashes = (hash(x) for x in self._components) return functools.reduce(operator.xor, hashes, 0)这里用了异或运算把每个分量的哈希值融合起来。functools.reduce(operator.xor, hashes, 0)的含义是:从0开始,依次对每个分量哈希做异或。为什么用异或而不是直接求和?因为hash(x)的分布已经是散列化的,异或操作能更好地保持分布的均匀性,而且对称性相对较差(顺序改变会影响结果,但异或本身是可交换的,所以理论上Vector([1,2])和Vector([2,1])的哈希值会一样——这是这个写法的局限,书里也说这是一种简化的处理)。
这里要额外提醒一个细节:如果某个分量的哈希值恰好为-1,Python解释器会把-1特殊处理为-2,因为-1有特殊含义。你在写自己的__hash__时不用管这个,因为hash()已经处理好了。但如果你拿operator.xor去融合多个哈希时,得到的结果本身又要作为哈希值返回,理论上也应该检查是否等于-1。实际中,CPython的hash()返回-1的场景极少,但严谨一点的话,可以在返回前做一次判断。这一点属于"知道即可"级别的细节,写测试的人很少会去追求。
__hash__和__eq__还有一个天然绑定关系:重写__eq__之后,__hash__会被置为None。如果你在自定义类里只写了__eq__,没有写__hash__,那么这个类的实例会变成"不可散列"的——放进set会直接报TypeError: unhashable type。第10章专门用大篇幅讲这个配对,就是为了避免这个常见的坑。凡是重写了__eq__的类,记得问自己一句:我需不需要它可散列?需要,就接着写__hash__。
顺着这个逻辑再想一层:为什么书中把__hash__放在"序列修改"这一章来讲?因为不可变序列天然适合做散列。列表因为可变,所以不可散列;元组不可变,所以可散列。你把自己写的类变成不可变序列之后,把它放到set里做去重、放到dict里做键,都非常自然。这不是语法层面的配合,而是数据结构特性的一致性:你设计了"创建一个对象之后,它的分量不会变",那么它就有资格享受散列带来的优化。
6. 协议方法之外:__contains__、__iter__和那些边角料
Vector的序列能力在__len__和__getitem__之后已经基本可用,但要把体验补齐,还需要另外两个方法:__iter__和__contains__。
__iter__很好理解:让它返回一个迭代器。对Vector来说,最简单的是直接复用内部tuple的迭代器:
def __iter__(self): return iter(self._components)为什么要显式实现__iter__,而不是依赖__getitem__的兜底迭代呢?因为__getitem__的兜底迭代是Python早期版本的兼容方案,专门给那些"没想过实现迭代器但支持下标"的对象用的。它每次从0开始递增取下标,直到IndexError为止。这在功能上没问题,但性能比真正的迭代器差很多,而且语义上也不够清晰。所以,序列协议中__iter__优先级高于__getitem__,只要实现了它就优先使用。你自己写类时,不管__getitem__写得多么顺手,__iter__都值得多写两行。
__contains__更有意思。如果你不实现它,Python会用迭代的方式逐个扫描元素,也能得到正确结果——只是慢一点。但如果你的类内部有更高效的查找方式,比如数据已经排序,或者是一个集合,那实现__contains__就能显著缩短查找时间。对Vector来说,数据是元组,没有特别的加速空间,所以简单写法就够了:
def __contains__(self, item): return item in self._components或者为了体现"我们是在讲序列协议",你也可以写一个更"教学式"的版本:
def __contains__(self, item): return any(self[i] == item for i in range(len(self)))两种写法结果一致。第一种更快,第二种更贴合"序列协议"的主题。我个人倾向第一种,原因很简单:_components已经是序列,用in作用于元组时,元组内部会走它自己的__contains__,而这个实现是C级别的,比自己写Python循环快得多。这种"能用内置就不手写"的原则,对写业务代码的人来说可以省掉大量不必要的性能焦虑。
除了这两个方法,第10章还有一个容易让人忽略的辅助类细节:__repr__里用reprlib.repr来控制长向量的显示。看代码:
def __repr__(self): components = reprlib.repr(self._components) return f'Vector({components})'reprlib.repr会把很长的序列截断成类似(3.0, 4.0, 5.0, ...)的形式。为什么要这样做?因为Vector可能是N维的,如果维度特别大,默认的repr会把所有分量都打印出来,一屏都放不下。在交互环境里稍微测试一下,Vector(range(100))这种对象的显示输出会立刻变得不可读。这是"表示层"的细节,但对日常调试体验影响很大。我在自己的项目里也养成了习惯:凡是容器类,__repr__都要考虑"太长怎么办"。
还有个小坑:如果你在Vector定义了__bytes__,就要注意bytes(self)会尝试使用__iter__。这本身没问题,因为Vector内部是数值分量。但如果你某个分量的类型不能被bytes()处理,就会在转换时报错。第10章里只是把这章需要的__bytes__提了一下,详细讨论放在后续章节。这里我只提醒一句:实现了__iter__之后,任何依赖"可迭代对象"的内置函数,比如tuple()、list()、bytes(),都会主动调用你的__iter__,这是一种隐式的"能力注入",用好它,也要防着它。
7. 动手验证:把Vector写在测试里跑一圈
理论讲了一大堆,不如实际调用一遍。我建议你跟着下面的步骤,把Vector完整写下来,逐项验证。这段测试不是标准答案,但它能覆盖本节提到的绝大多数坑。
第一步,先搭一个基础版本:
import functools import operator import reprlib class Vector: def __init__(self, components): self._components = tuple(components) def __len__(self): return len(self._components) def __getitem__(self, index): if isinstance(index, slice): return Vector(self._components[index]) return self._components[index] def __iter__(self): return iter(self._components) def __repr__(self): return f'Vector({reprlib.repr(self._components)})' def __contains__(self, item): return item in self._components def __eq__(self, other): if not isinstance(other, Vector): return NotImplemented if len(self) != len(other): return False return all(a == b for a, b in zip(self, other)) def __hash__(self): hashes = (hash(x) for x in self._components) return functools.reduce(operator.xor, hashes, 0) def __getattr__(self, name): if len(name) == 1: pos = ord(name) - ord('x') if 0 <= pos < len(self._components): return self._components[pos] raise AttributeError(f'{type(self).__name__!r} object has no attribute {name!r}') def __setattr__(self, name, value): if len(name) == 1: pos = ord(name) - ord('x') if 0 <= pos < len(self._components): raise AttributeError('cannot set component') super().__setattr__(name, value)第二步,逐项验证:
v = Vector([3, 4, 5, 6]) # 基本序列能力 assert len(v) == 4 assert v[0] == 3 assert v[-1] == 6 # 切片返回Vector s = v[1:3] assert isinstance(s, Vector) assert len(s) == 2 assert s[0] == 4 # 反向切片边界 r = v[::-1] assert list(r) == [6, 5, 4, 3] # 动态属性访问 assert v.x == 3 assert v.y == 4 assert v.w == 6 # 越界属性应该抛AttributeError try: v.q assert False, "should raise AttributeError" except AttributeError: pass # 修改分量应该被阻止 try: v.x = 100 assert False, "should raise AttributeError" except AttributeError: pass # 成员判断 assert 5 in v assert 99 not in v # 相等性 v2 = Vector([3, 4, 5, 6]) v3 = Vector([3, 4, 5]) assert v == v2 assert v != v3 # 散列 assert hash(v) == hash(Vector([3, 4, 5, 6])) assert hash(v) != hash(Vector([6, 5, 4, 3])) # set使用 s_set = {v, v2} assert len(s_set) == 1第三步,我要专门讲一个很多人问过的问题:为什么assert hash(v) != hash(Vector([6, 5, 4, 3]))这个断言在逻辑上不总是成立?因为异或操作是可交换的,理论上hash(3) ^ hash(4) ^ hash(5) ^ hash(6)和hash(6) ^ hash(5) ^ hash(4) ^ hash(3)完全相等,所以这个断言在下一次执行时很可能失败。如果真要让顺序敏感,应该用tuple的hash作为基础——这也是为什么Python的tuple本身就是按顺序计算哈希的。书中示例用异或,更多是为了展示functools.reduce和operator.xor这种函数式写法,如果你在自己的代码中真正需要"顺序敏感的顺序哈希",我建议直接hash(tuple(self._components)),一行代码,效果可靠。把这一点写成测试断言时尤其要注意,不然你会收获一个"时灵时不灵"的测试套件。
这段小测试跑下来,你对Vector的每个协议方法都会有直观的感知。它已经像一个完整的、可用的Python内置序列了。
8. 常见问题速查:协议实现路上的坑
把这一章的坑集中整理成一张速查表,方便你之后写类似代码时直接对照。这些坑都是我实际写代码时遇到过的,不是从书里抄来的空话。
| 症状 | 原因 | 处理方式 |
|---|---|---|
对自定义对象使用v[1:3]得到tuple | __getitem__没区分slice类型 | 在方法里用isinstance(index, slice)分支返回新对象 |
访问v.x后重新赋值,v[0]不再同步 | 没实现__setattr__,赋值创建了普通实例属性 | 在__setattr__里拦截分量名,转发或拒绝 |
类里有__eq__但没有__hash__,放进set报unhashable | Python默认把__hash__设为None | 必须成对实现__eq__和__hash__ |
比较两个长度不同的向量返回True | zip会截断到短序列 | 比较前先检查长度 |
| 切片结果反向不正确 | 手写切片逻辑时没处理好步长为负的边界 | 直接用内部序列的切片,或用slice.indices |
| 属性访问偶尔导致递归或栈溢出 | __getattr__里访问了不存在的内部属性,触发再次调用自身 | 在__getattr__内部避免访问未预设属性,或先检查存在性 |
用repr打印大向量时输出太长 | 没有处理超长序列的显示 | 用reprlib.repr截断显示 |
自定义类不能for循环 | 既没有__iter__也没有__getitem__ | 至少实现一个;推荐两个都实现 |
把可变数据放进__hash__计算 | 对象可散列但内部状态可变,违反散列约束 | 散列值只基于不可变字段,或在设计上禁止修改 |
这张表里,最值得反复提醒的其实只有三条:一个是__eq__和__hash__必须成对设计;一个是__getattr__和__setattr__要一起考虑,不能只做读取;一个是切片返回类型要保持语义一致。抓住了这三点,第10章的核心基本就掌握了。
如果有人问你"为什么Python让这些方法如此松散地组织",你大可以把它理解为一种实用主义的设计观:Python不强制你继承某个抽象基类,而是相信你愿意遵循约定,并且通过魔法方法让语言在背后为这套约定提供一等公民的支持。这种设计对框架作者和库作者非常友好——你能在不改变继承关系的前提下,让自己的类"表现得像"内置类型。
9. 后续还能怎么扩展
Vector这个例子在第10章止步于不可变序列,但往后的章节里,作者又给这个类添加了__abs__、__bool__、__bytes__等能力,本质上都是在往"更像内置类型"的方向上继续走。你现在掌握了协议方法的思路,完全可以自己尝试把Vector改造成可变序列:把_components换成list,实现__setitem__、__delitem__,然后观察__hash__应该怎么处理——答案是可变序列不应该可散列,所以你要把__hash__显式设为None。这个矛盾点,恰恰是理解"可变性"和"散列性"互斥关系的好习题。
再往后,如果你对"鸭子类型"和"协议"这个概念感兴趣,建议去看collections.abc模块里的Sequence、MutableSequence抽象基类。它们不是用来强制继承的,而是提供一套mixin方法和类型检查的工具。你写的Vector不完全继承它们,但如果愿意,也可以用Sequence.register(Vector)来声明"这个类符合序列协议"。这些内容,第10章虽然不是主线,但顺着这个脉络读下去,你对Python类型系统的理解会更深一层。
我在实际项目中,用这套知识做过一个配置对象的封装:让配置项既能通过config['timeout']读,也能通过config.timeout读,两者行为保持一致,内部统一走序列或映射协议。这种设计在业务代码里并不罕见,但许多人实现时只写了__getattr__,没有处理__setattr__,结果就是"读的时候走了别名,写的时候走了另一条路",后面定位bug时相当折磨。如果你也想做类似的事,记得把读和写、把__getitem__和__getattr__都当成同一套接口的两个面,一次设计完整。
读这一章时我最大的感受是,Python的魔法方法不是一个个孤立的开关,而是一套互相咬合的齿轮:__len__、__getitem__、__iter__、__contains__是序列的面子,__hash__、__eq__是散列和比较的里子,__getattr__、__setattr__则优雅地补上了"属性式访问"这个额外的维度。每个方法单独看都不难,难的是把它们组合在一个类里时,要保证行为一致性。好在第10章用Vector这个例子把所有齿轮都装了一遍,照着拆一遍、再自己装一遍,比看十遍书都有用。