资讯动态

Python序列协议:自定义类的切片与散列实现

发布时间:2026/10/14 1:14:56 来源:尧图企业网站定制
这段时间把《Fluent Python》第10章来回翻了几遍越读越觉得这一章是全书里被低估的一章。标题叫“序列的修改、散列和切片”英文原文是“Sequence Hacking, Hashing, and Slicing”这里的“修改”直译过来容易让人误会其实它讲的是怎么把一个自定义类改造成真正的序列类型同时支持切片返回同类型对象、还能安全地放进字典当键。全章围绕一个多维向量的实现展开把序列协议、切片机制、散列原理三个话题串成一条线。如果你写过一段时间Python但总觉得自定义类“差点意思”或者说不上来为什么list用起来就是顺手这一章值得反复读。我自己看完之后最大的收获不是记住几个魔术方法而是理解了Python对象设计里最核心的一套取舍逻辑。这一章适合三类人想系统搞懂Python对象模型的人、在写库或者框架需要暴露自定义容器的人、以及经常被TypeError: object does not support indexing这类报错折磨的人。下面就把我拆解这一章的过程整理出来核心代码基本按照原书的思路复刻同时补一些我实际调试中踩过的坑和工程上的取舍思考。1. 序列、散列和切片为什么被放进同一章读懂这一章的隐藏主线1.1 三个主题不是拼盘而是一条主线第一次翻目录的时候我一度以为这一章是把三个独立话题硬凑在一起因为“序列”“散列”“切片”听起来确实没什么必然关联。但跟着Vector类完整走一遍之后才发现它们其实是同一个目标的不同侧面把一个二维向量扩展成支持任意维度的向量并且让这个向量用起来和Python内置的不可变序列一样自然。这条主线是层层递进的。先要让Vector可迭代、可索引、可求长度这是“序列”的部分索引做得顺手之后用户自然会写v[1:3]这种切片于是要处理slice对象这是“切片”的部分切完片发现有些场景需要把Vector当作字典的key来缓存计算结果又要求对象可散列这是“散列”的部分。每一部分都是顺着前一部分的“自然延伸”长出来的不是作者随便加戏。书里原来的Vector2d类处理的是二维坐标点内部用两个浮点数分量表示。第10章做的事情是把它重新实现为任意维度向量Vector内部把分量存进一个数组。这个重构过程非常有代表性因为你会发现二维到多维的跃迁表面上只是“分量数量变了”实际上所有方法都要重新思考。比如__repr__不能再直接把两个数打出来因为一个多维向量可能有几百个分量再比如__eq__不能再判断坐标是否相等而要考虑长度不同时怎么处理。这些问题正好逼着你把每个特性都想透。1.2 协议优先于继承Python风格的底层逻辑这一章真正想传递的观念是在Python里判断一个对象是不是序列不看它继承自谁而看它有没有实现序列该有的行为。这就是所谓的“鸭子类型”也是Fluent Python全书的核心理念之一。作者反复强调“协议”这个词只要实现了__len__和__getitem__两个方法Python就承认你是序列哪怕你的类和list没有任何继承关系。对比一下Java或者C的思路会更清楚。在静态语言里要实现一个可迭代的容器通常要显式声明实现某个接口比如Java的IterableT。编译器会在编译期帮你检查“你是否写全了接口要求的方法”没写全直接报错。Python不一样它不做这种强制声明而是默认你会主动去实现协议要求的方法然后它只在你真正调用某个方法时才去查找。这套设计的好处是灵活坏处是如果你漏了某个方法错误往往不是在你定义类的时候暴露而是在运行很久之后的某个调用点才爆出来。书里对此的处理方式是循序渐进地给Vector类添加协议要求的方法每加一个就用一个简单的测试用例验证。这种做法我后来在写自己的代码时也用上了效果很好。你不一定要一口气实现所有魔术方法先搭骨架再按需补全每次只做一个小改动并用测试锁住行为这样出问题时定位成本非常低。2. 核心细节拆解序列协议、切片原理与散列计算的三个关键点2.1 序列协议的最小实现只有两个方法但别小看它们前面说了Python的序列协议最核心的就是__len__和__getitem__。__len__返回元素个数__getitem__根据索引返回元素。这两个方法一实现你的对象就能被for循环迭代、能用下标访问、能被in判断成员资格、还能和len()内置函数配合。但这里有一个非常容易忽略的细节__getitem__接收的参数不一定是你想象的那个int。调用v[0]时Python确实传一个整数进来但调用v[1:3]时传进来的就是一个slice对象调用v[1, 2]这种带逗号的多维索引时传进来的甚至是一个tuple。所以处理__getitem__的第一要务是把输入参数的类型边界搞清楚。书里的做法是先用isinstance(key, slice)判断是不是切片如果是就直接返回一个新Vector如果不是再用内置的operator.index(key)把参数规范化成整数。我特别推荐这个写法理由后面在常见问题里细说。这里先记住一点把__getitem__写好的关键是“分路线处理参数”而不是默认传入的就是一个整数。__len__相对简单但工程上仍然要注意性能。如果求长度需要遍历整个数据源才能得到结果那么len()调用就会拖慢所有依赖它的逻辑。像Vector这样内部直接维护一个数组的类直接len(self._components)就是O(1)的这才是理想形态。你自己写容器类时如果长度能维护成即时值就不要临时去算。2.2 切片背后是slice对象深入理解slice.indices才行新手最容易在切片这里一头雾水因为当你写v[1:4]时表面上只是多了一个冒号实际上Python悄悄构建了一个slice(1, 4, None)对象传给你的__getitem__。这个对象有三个属性start、stop、step分别对应冒号前后的值和步长。你当然可以直接把这个slice对象再传给内部数组的索引。比如Vector内部维护着一个数组_components那么self._components[key]也能正确返回一个切片后的数组因为数组本身也是序列也支持切片。这一步能让Vector切片功能立刻可用也是原书第一版里的做法。但你一旦想对切片做更多控制比如判断切片结果是否为空、或者把切片后的结果再转成Vector就需要理解slice对象的完整语义。slice对象真正强大的地方在于indices(n)方法。slice.indices(n)会返回一个三元组(start, stop, step)这三个值已经被规范化过负索引被转换成对应的正索引越界索引被裁剪到边界内步长方向的错误也被修正。比如对长度为5的序列求slice(10, -3, -2).indices(5)你能精确知道这个切片会命中哪些索引。用这个三元组配合range来生成索引序列是处理切片逻辑最稳的姿势。这里要提一个我在实践中见过很多次的问题有人写自定义序列的切片时直接判断isinstance(key, slice)然后原样返回一个list。这样做看起来能用但会破坏“切片返回同类型对象”的约定。Python内置的不可变序列切片后都返回同类型对象abc[1:2]返回str(1,2,3)[1:2]返回tuple。你的自定义序列也应该遵守这个约定让v[1:2]返回Vector否则用户在使用时就不得不写额外的类型转换代码。2.3 散列不可变是前提xor只是手段散列部分是全章中原理性最强的一节。为什么要给Vector加__hash__因为一个不可变对象如果实现了__eq__却没实现__hash__那么它就无法被放进set或作为dict的key。设计Vector时我们希望它能作为字典键因此散列是绕不开的。__hash__和__eq__之间有一条硬规矩两个对象如果相等那么它们的散列值必须相等反过来不成立散列值相等不代表对象相等。所以散列值的计算必须基于__eq__中参与判断的那些数据。Vector的相等性比较的是全部分量按顺序逐一相等那么散列值就应该把这些全部分量都“掺和”进去。书里给出的实现很有巧思def __hash__(self): hashes (hash(x) for x in self._components) return functools.reduce(operator.xor, hashes, 0)这段代码先对每个分量求出散列值再用reduce把operator.xor应用到所有散列值上初始值为0。选择异或而不是求和是因为异或运算满足交换律和结合律而且不依赖处理顺序。初始值设成0是个关键细节任何数和0异或都不变这保证了空向量能有一个稳定的散列值0。书里也点出这个实现的一个有趣性质因为异或操作和顺序无关所以Vector([1,2])和Vector([2,1])会有相同的散列值但它们并不相等因为__eq__是严格按顺序比较的。这在规则上完全合法只是会让这两个对象在散列表里落到同一个桶产生一次“无谓的碰撞”。从正确性角度没问题从性能角度看有一点损耗。如果你在自己的容器类里实现散列并且顺序对你来说是重要的语义那么可以考虑用能保留顺序信息的混合算法比如把每个分量的散列值先移位再异或。这是我在工程实践中会额外注意的地方。3. 实操过程重现从零实现一个可切片、可散列的多维向量3.1 第一步用数组撑起序列的基本骨架原书的Vector类内部用array存储分量而不是Python的list。选用array(d)的原因有两个一是它比list更节省内存对于可能存储大量浮点数的多维向量来说这很重要二是array(d)明确声明了元素类型为双精度浮点数在数据规模上去之后访问效率也更高。先搭一个最小可用的骨架from array import array class Vector: def __init__(self, components): self._components array(d, components) def __len__(self): return len(self._components) def __getitem__(self, key): if isinstance(key, slice): cls type(self) return cls(self._components[key]) index operator.index(key) return self._components[index]到这里这个类已经支持len(v)、v[0]、v[1:3]这些最基础的序列操作。注意__init__里我用array(d, components)做了类型强制转换传入列表或者元组都没问题。这个骨架里还有一个隐藏技巧切片分支里用type(self)而不是直接写Vector。这是为了让子类在继承Vector时切片也能正确返回子类对象。原书作者专门强调过这个细节我在实际项目里也靠这个写法省过不少继承后的返工。3.2 第二步散列与相等性一起实现序列骨架搭好之后下一步是实现__eq__和__hash__。这里必须成对实现否则Python会给你埋一个坑一旦你定义了__eq__而没有定义__hash__实例就会变成不可散列直接导致放进set时报错。很多人在这个阶段会困惑“为什么我的对象明明实现了__eq__却不能放进set”原因就在这里。Vector.__eq__的实现思路是先判断类型再比长度最后逐元素比较def __eq__(self, other): if isinstance(other, Vector): return (len(self) len(other) and all(a b for a, b in zip(self, other))) return NotImplemented为什么先比长度因为zip在Python 3里默认按最短的输入停止如果长度不同也直接用zip去比结果可能错误地返回True。先比长度能提前挡住大部分不等的情况这也是性能上最经济的做法。这里还有一个值得学习的点当other不是Vector类型时返回NotImplemented而不是直接返回False。这是Python运算符重载的正确姿势。Python看到NotImplemented时会尝试反向调用对方的__eq__方法如果对方也返回NotImplemented最终才落到默认行为。直接返回False看起来也是对的但在某些混合类型比较场景下可能错误短路养成返回NotImplemented的习惯能避免这类隐蔽问题。散列实现沿用前面说的异或方案def __hash__(self): hashes (hash(x) for x in self._components) return functools.reduce(operator.xor, hashes, 0)有的读者可能会想要不要把散列值缓存下来对于完全不可变的Vector来说缓存确实可行。书里没有这么做我猜一方面是代码简洁性优先另一方面是计算散列的代价完全可以接受毕竟向量维度一般都不会太长。如果你的容器元素数量巨大、散列计算很贵再考虑缓存也不迟。3.3 第三步用__getattr__和__match_args__提供v.x属性多维向量还有一个很自然的需求用户可能希望像访问二维坐标那样访问前几个分量比如v.x、v.y、v.z。实现这个需求要用到__getattr__它只在正常属性查找失败时被调用。也就是说实例本身没有x属性时Python才会把名字传给__getattr__。新版Fluent Python这里用了一个非常优雅的做法把允许通过属性访问的分量名定义在__match_args__里。这个变量原本是为了Python 3.10的结构模式匹配服务的也就是match v: case [x, y]:这种语法需要它。第10章把它复用到了__getattr__的实现中class Vector: __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] raise AttributeError(f{cls.__name__!r} object has no attribute {name!r})这样__match_args__既服务于模式匹配又充当了属性名到分量位置的映射表一举两得。我用这个写法的时候忍不住感叹一个设计上为语言新特性准备的数据顺手就把一个老问题给解决了。不过__getattr__一旦存在就要小心它和__setattr__的配合。Vector作为不可变序列按道理v.x 1这个操作应该被禁止。但问题是如果你只实现了__getattr__v.x 1会创建出一个真实的实例属性x之后就再也不会触发__getattr__了等于悄悄破坏了不可变性。书里给出的__setattr__实现非常值得借鉴def __setattr__(self, name, value): cls type(self) if len(name) 1: if name in cls.__match_args__: error readonly attribute {attr_name!r} elif name.islower(): error cant set attributes a to z in {cls_name!r} else: error elif name.isupper(): error else: error if error: raise AttributeError(error.format(cls_namecls.__name__, attr_namename)) super().__setattr__(name, value)这套规则的意思是单字母的小写属性名原则上都禁止赋值因为很有可能是x、y这些分量名大写属性名除外因为_components这种“看似大写字母开头的伪私有名”还是允许写入的。规则看起来有点绕但它其实在做一个权衡既要保护不可变性又不能让__init__里给_components赋值时把自己卡死。实操中我建议把你自定义类的属性命名规则想清楚再照抄这个模式否则很容易出现“连初始化都跑不过”的尴尬局面。我初次把这段代码敲进自己的类里时就在__init__里给一个叫_data的属性赋值结果它被当成大写开头放行了但换成一个叫m_data的属性就会直接报错。这类规则适合原书Vector的场景但不一定适合你所有的类。3.4 第四步让repr对大向量保持可读最后一个容易被忽略的是__repr__。如果说用户对一个自定义类型的印象往往先从repr开始那么一个几百维的Vector打印出来就刷屏体验会很差。原书用reprlib模块做了截断显示def __repr__(self): components reprlib.repr(self._components) components components[components.find([):-1] return fVector({components})reprlib.repr会把过长的序列截断成类似array(d, [0.0, 1.0, 2.0, ...])的样子然后通过切片把数组前缀去掉只保留方括号内的部分最后拼成Vector([0.0, 1.0, ...])。这样即使向量维度很大repr输出也始终清晰可控。如果你想对这个类做更完整的补全还可以顺手实现__iter__、__abs__、__bool__。__iter__返回分量的迭代器__abs__用math.hypot(*self._components)计算向量模长__bool__直接返回bool(self._components)。这几个方法虽然不在序列协议的最核心范围内但它们是让Vector“用起来像内置类型”的关键一环。我在自己的项目里一般会把这些一次性补齐省得后面用的时候发现某个运算符不支持又回来加。4. 常见问题与排查技巧实录4.1 切片返回了list而不是Vector这是实现自定义序列类时出现频率最高的问题之一。表现是索引v[0]返回的是元素没问题但切片v[1:3]返回的竟然是一个list用户想要继续做向量运算时直接报错。原因通常是在__getitem__的切片分支里没有把结果包装回原类型。我见过不少简化版代码长这样def __getitem__(self, index): return self._components[index]这样写索引和切片都会返回内部数组的结果。切片返回的是数组切片结果自然地就变成了array或list。修正方式就是前面说过的判断isinstance(key, slice)然后走独立分支用type(self)(self._components[key])包回来。这个坑几乎每个写自定义序列的人都会踩一次踩过之后就再也不会忘。4.2 用type(key)判断类型导致bool被误认成整数Python里bool是int的子类True和False在某些场景下可以当1和0用。这个问题在散列逻辑里尤其隐蔽如果你用isinstance(key, (int, slice))去判断__getitem__的参数类型True会被当成整数索引行为可能变得很奇怪。这也是原书建议用operator.index(key)而不是直接isinstance(key, int)的原因。operator.index会检查对象是否实现了__index__协议bool没有实现所以True不会被当成合法索引。numpy里的整数类型也实现了__index__所以能被顺利接受。用operator.index是唯一的稳路径它对混合类型的包容性和安全性都要好得多。4.3 定义了__eq__之后对象突然不可散列这个坑容易发生在你最不经意的时候。你在类里加上一个__eq__然后想把实例放进set结果报错说unhashable type。原因是Python的默认规则一个类如果定义了__eq__而没有定义__hash__那么它的__hash__会被置为None。这个规则为了防止一个经典错误——对象放进set之后又被修改而导致散列值变化最终破坏散列表结构。如果你的对象是可变的比如它是一个“可编辑的Vector”那么它确实不该可散列Python这个行为是在保护你。但如果你设计的是不可变类型那就必须同时实现__hash__。注意这里要求的是“同时”你甚至可以故意把__hash__设为None来禁用散列只是大多数场景下你不会想这么做。4.4 负数索引和越界索引处理不一致内置序列支持负数索引比如v[-1]返回最后一个元素。你的__getitem__如果内部直接把索引转给数组负数索引天然可用这没问题。但如果你的实现里做了“先加长度再判断越界”这类手动处理就很容易出现边界条件漏判。处理切片时负索引和越界问题会被放大。比如v[-3:]、v[2:100]这些写法在Python里完全合法你要让切片行为正确最好的办法就是不要自己造轮子直接用slice.indices(n)归一化后的三元组或者干脆把slice对象原样交给内部数组处理。我在代码里习惯用indices方法显式拿到索引序列因为这样可以更直观地控制边界也方便调试。4.5 比较类型的NotImplemented陷阱__eq__里返回NotImplemented这个细节很多人觉得无所谓。但实际上如果两个不同类型的对象比较时都返回NotImplemented最终Python会退回到身份比较结果是False。这个行为符合预期。但如果你在__eq__里直接return False当别人主动拿他的对象和你的Vector比较时他的__eq__可能压根没机会被调用导致整个比较链路出错。书上的原话大意是我不知道另一个对象怎么比较自己所以我不该替它做决定。这个思路非常值得推广到其他运算符上__lt__、__le__、__gt__这些方法遇到不认识的类型时都应该返回NotImplemented而不是强行返回一个值。这是我写自定义类型时遵循的一条铁律。5. 影响范围与延伸思考协议式设计如何改变写代码的方式5.1 从Vector到日常开发协议是给用户的一种承诺如果你只是把这一章当作“怎么实现一个向量类”来读那学到的东西是有限的。真正的价值在于当你掌握协议式设计之后写任何需要暴露给其他代码使用的类型时都会多一整套设计约束。你需要想清楚“我的对象能否被迭代能否被索引能否切片能否散列比较行为是什么”这些问题的答案本质上就是你给用户的一种承诺。我在读这一章之后重写过一个内部的数据结构。原来那个类暴露了一堆该死的方法名比如get_item_by_index()、get_length()调用起来啰嗦且不统一。后来我把类改成实现序列协议只需要写obj[i]、len(obj)调用方代码瞬间清爽很多。这种“让你写的对象可以直接被语言内置语法接纳”的能力是Python里最高级的代码复用方式它不依赖继承关系只依赖行为上的一致性。5.2 影响边界散列与性能、字典键的使用散列的影响范围不仅局限在“能放进set”这个表面能力上。当你的对象被当作字典键时散列值的质量直接决定字典查找性能。如果散列实现得糟糕所有对象都返回同一个散列值字典操作就会退化成链表遍历性能一落千丈。Vector这个量级通常不存在性能问题但如果在大型数据系统里把某个自定义实体类放入集合时一定要认真设计散列算法。比如异或方案适合“中序不重要”的场合如果顺序是关键语义可以考虑加权和再加扰动。工程上一个稳妥的快速方案是hash((self.field_a, self.field_b, self.field_c))利用Python元组的散列实现既保留顺序信息又正确高效。这是我在处理更快实现需求时的常用招数。5.3 延伸方向继承collections.abc里的基类书里这一章的Vector类并没有继承collections.abc.Sequence因为它只需要一个最小实现。但如果你希望自己的自定义序列获得更多现成能力比如index()方法、count()方法、__contains__、__reversed__可以让类继承collections.abc.Sequence。这个抽象基类会自动根据你实现的__getitem__和__len__通过混入方法补齐其他能力。这样做还有一个附带好处注册到abc.Sequence之后你的类会被isinstance(obj, Sequence)识别为真。对于接受序列参数的API来说这提供了更强的语义保障。代价是你要尽量遵守不可变序列的约定否则混入的方法可能会在某处悄悄出错。所以我的建议是快速原型可以像书里那样不继承但正式对外提供的容器类尽量把Sequence挂上。5.4 模式匹配和类型协议旧知识点的新玩法Python 3.10加入结构模式匹配之后第10章的价值又被抬高了。__match_args__让自定义类可以直接参与match/case的“按位置解包”。例如match v: case [x, y]:会把向量分量解包给x和y。这意味着你写一个处理向量的函数时可以用非常自然的模式语法去匹配不同维度的情况而不需要写一堆if判断维度。这个方向还让我想到了typing.Protocol。你可以在不修改类的情况下定义一个描述序列行为的Protocol类型然后用它标注函数参数。这不是运行时强制但类型检查工具会帮你守住边界。再回头看这一章其实它是用一种“不依靠类型系统”的方式实现了类似的效果。新老知识放在一起看有一种很通透的感觉这些设计不是彼此替代而是从不同角度让代码更符合人的直觉。对我来说第10章最大的实战价值在于让我建立了“协议完整度”的意识。以前写自定义类只顾着接口方法够不够多很少去想“这类对象在语言层面上应该有哪些标准行为”。当你把索引、切片、迭代、比较、散列这些行为都补齐之后你的类就不再是普通的类而是真正融入了Python的生态。用书里的话说你的类“表现得像序列”而不仅是“有序列的方法”。这两件事之间的差距正好就是这一章讲的全部内容。最后再分享一个我自己的小习惯每次实现完一个自定义类型我会随手跑一段“协议自检”代码——把对象放进set、做一次切片、执行一次for循环、写一个if obj:判断。这四步覆盖了散列、切片、迭代、布尔值四条核心路径。只要这四条链路都顺畅这个类型的可用性就已经超过绝大多数同类实现。这套自检方式就是从这一章学来的一直在用挺管用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑