到__len__的对象协议解析)
先从一个很基础的问题说起[1] [2]这行代码Python 是怎么知道要返回[1, 2]的你可能会说这是语法层面的加法列表本来就能相加。但再往深一层想这个“本来能相加”的能力并不像 C 那样由编译器针对内置类型写死而是被设计成了一套“协议约定”。在 Python 里这种约定靠的是一组以双下划线开头和结尾的方法也就是大家常说的魔术方法dunder method。写过一段时间 Python 的人基本都会用__init__但很少有人能把这一整套双下划线方法讲清楚。我在刚入行时也有同样的困惑为什么 Python 不像 Java 那样万物皆方法非要搞出len(obj)这种看起来像全局函数的东西后来读了一些内部实现相关的资料才明白双下划线方法不是某种语法装饰而是 Python 整个对象系统的地基。它决定了你的自定义类能不能被len()调用、能不能用for遍历、能不能被放进set、能不能用with管理、能不能像函数一样执行。这篇文章我就把这套东西从头到尾拆开聊一遍包括每个方法什么时候被调用、怎么实现、有哪些坑以及我实际排错时积累的一些经验。这篇文章适合两类人一类是已经会写基础 Python、但想真正理解“对象协议”的开发者另一类是正在设计自己模块或类库、希望自定义类能和内置类型一样好用的同学。我会用大量可运行的代码示例尽量让每个结论都能直接落地上手。1. 魔术方法不是魔法Python 为什么要把接口约定藏在双下划线后面1.1 从len(obj)而不是obj.len()说起在所有编程语言里Python 算是少数几个“内置函数 特殊方法”双重结构的语言。你用len(abc)、len([1,2,3])甚至len(自定义对象)都能得到长度换成 Java你得在类里自己写size()再用obj.size()去调。这两种设计没有绝对优劣但 Python 这种方案有一个非常隐秘的优点它把“一个对象应该具备什么能力”这件事从“继承自某个基类”解耦成了“实现了某个协议”。举个例子Python 的len()在 CPython 内部本质上做的事情差不多是这样的先检查传入对象的类型然后在类型对象上查找名为__len__的方法。如果找到了就调用它并返回结果找不到就抛出TypeError: object of type XXX has no len()。换句话说len()不是一个真正意义上的全局函数而是一个“协议入口”。只要你的类实现了__len__不需要继承任何框架基类它就是 Sized 协议的一员。这种设计的影响很深远。你在写代码时不需要关心传入的到底是一个list、dict、str还是某个第三方库里的自定义集合只要它实现了__len__len()就一定能用。这正是 Python “鸭子类型”哲学的底层支撑不看对象是什么类只看它能不能响应这个操作。1.2 双下划线到底在防什么新手常问为什么这些方法的名字非要长得这么丑前后各加两个下划线这其实是一个“既防冲突又防误用”的设计。先说防冲突。Python 的类里可以有成千上万个普通方法名如果特殊方法叫init、len、add很容易和用户自己的业务方法撞车。一旦撞上解释器行为就会变得不可预期。加上双下划线之后“系统协议”和“用户方法”在命名空间上被天然隔开了。比如你在类里定义一个add(self, x)方法那纯粹是业务方法不会影响运算符但如果你定义的是__add__(self, other)那这个对象就可以被操作。这个区别非常重要我见过有同事在自定义类里写了add方法然后试图用obj1 obj2去触发它结果得到一个TypeError后来排查半天才发现方法名写错了。再说防误用。双下划线前缀在 Python 的语法里还有一层含义类内部的__name形式的成员会被“名称改写”name mangling变成_ClassName__name。虽然__init__这种双下划线开头且结尾的魔法方法不会触发改写规则但双下划线前缀本身已经传递了一个信号“这是解释器预留的协议不是在正常业务代码里应该频繁手动调用的东西。”你当然可以显式调用obj.__len__()但绝大多数场景下你应该写len(obj)。这一层“防误用”的意图既是设计哲学也是写代码时的可读性约定。为了便于理解可以把这套机制类比成充电接口标准。USB-C 接口规定了引脚怎么排列、怎么握手充电头不用关心对面是手机还是耳机Python 的魔术方法就是“协议引脚”len()、、for、with这些语法和内置函数是“充电头”。只要你的对象按照协议把引脚做出来任何通用入口都能直接驱动它。这就是为什么说掌握了魔术方法等于真正掌握了“如何让自定义对象融入 Python 生态”。下面我用一张表把日常最常用的协议入口和触发场景梳理清楚后面章节会逐个展开。语法或内置函数触发的方法典型使用场景len(obj)obj.__len__()容器类、集合类obj[i]/obj[i] vobj.__getitem__()/obj.__setitem__()序列、映射、自定义容器for x in objobj.__iter__()obj.__next__()可迭代对象、迭代器x in objobj.__contains__()成员判断obj otherobj.__add__(other)运算重载str(obj)/print(obj)obj.__str__()用户可读输出repr(obj)obj.__repr__()开发者调试输出with obj as x:obj.__enter__()/obj.__exit__()资源管理obj()obj.__call__()可调用对象、函数式编程bool(obj)/if obj:obj.__bool__()真值判断2. 从零手写一个“活成原生类型”的对象成绩单类的完整实现概念讲多了容易飘这一节我直接带你手写一个类。定义一个大学的成绩单对象ScoreReport它需要能计算总成绩、比较两个成绩单的高低、可以把两个成绩单合并、可以用for遍历每次考试成绩、可以判断某门课在不在成绩单里、还能被len()和bool()调用。我故意选这个稍微综合的场景是因为它能把最常见的魔术方法一次性串起来而不是每讲一个方法就造一个没意义的玩具类。2.1 初始化与展示__init__、__repr__、__str__class ScoreReport: def __init__(self, owner, scoresNone): self.owner owner # 这里用 list(scores or []) 而不是直接赋值避免多个实例共享同一个可变 list self.scores list(scores or []) def __repr__(self): return fScoreReport(owner{self.owner!r}, scores{self.scores!r}) def __str__(self): course_str , .join(f{name}:{score} for name, score in self.scores) return f{self.owner} 的成绩单{course_str}先说一个最重要的坑__init__里的list(scores or [])是必须的不能写成self.scores scores or []。如果直接把参数列表赋值给实例属性那么两个不同的ScoreReport可能会共享同一个底层列表一个实例改了数据另一个也跟着变。这个坑表面上是列表引用问题本质上是你写类时的可变对象复制意识。接下来看__repr__和__str__的区别。这两个方法平时容易混但它们面向的对象完全不同__repr__是给开发者看的它的目标是在异常、日志、IDE 调试器里让你一眼看穿这个对象的内部结构。理想情况下repr(obj)的返回值应该能直接重建一个等价对象所以我在里面用了!r格式符保证字符串内容带引号这样ScoreReport(owner张三, scores[...])可以直接被eval解析。__str__是给最终用户看的print(obj)和str(obj)会优先调用它。它不需要能重建对象但应该让用户读起来舒服。这里有个非常实用的细节如果只实现了__repr__没实现__str__print(obj)也会退回去调用__repr__。反之则不行。所以我个人的习惯是永远先写__repr__它是调试底线当需要面向用户输出时再补__str__。2.2 让对象有长度和真值__len__与__bool__def __len__(self): return len(self.scores) def __bool__(self): return bool(self.scores)实现__len__后len(score_report)就能用了。很多人不知道的是__len__还关系到一个隐藏行为如果你没有定义__bool__Python 会退化成len(obj) 0来判断真假。这就是为什么空列表、空字典、空字符串在if条件里是False——它们都实现了__len__而长度为 0 时会被当成假值。一旦自己实现了__bool__这个退化机制就被覆盖了。在上面的例子里成绩单里哪怕有一门课成绩为 0bool()也返回True因为[(数学, 0)]这个列表非空。如果业务逻辑认为“没有有效成绩才是空”你可以把这个判断写在__bool__里做更精细的控制。2.3 比较和运算__eq__与__lt__、__add__def __eq__(self, other): if not isinstance(other, ScoreReport): return NotImplemented return self.owner other.owner and self.scores other.scores def __lt__(self, other): if not isinstance(other, ScoreReport): return NotImplemented return sum(score for _, score in self.scores) sum(score for _, score in other.scores) def __add__(self, other): if not isinstance(other, ScoreReport): return NotImplemented return ScoreReport( ownerself.owner other.owner, scoresself.scores other.scores, )关于__eq__我最想强调的一点是不要在一个不匹配的类型面前直接返回False而是应该返回NotImplemented。很多初学者会写成def __eq__(self, other): return self.owner other.owner and self.scores other.scores当other是一个整数或字符串时这段代码会抛AttributeError因为int没有owner这个属性。而返回NotImplemented的意思是“我不擅长和这个类型比较请你让对方也试试如果对方也拒绝Python 才会兜底返回False。”同样地__lt__返回NotImplemented是为了支持反向比较比如a b实际会尝试b a。实现了__lt__之后sorted([s1, s2, s3])就能直接用因为排序算法只需要知道“谁小于谁”。如果以后还需要、、Python 会自动从__lt__推导出部分比较结果但你也可以显式实现__gt__等方法来精确控制。__add__的设计也要多说一句我在返回值里直接创建了一个新的ScoreReport而不是修改self。这是符合直觉的——运算在语义上应该是“生成一个新对象”而不是原地修改旧对象。如果你希望支持s1 s2这种原地操作可以单独实现__iadd__。2.4 迭代与成员判断__iter__、__next__、__contains__、__getitem__def __iter__(self): # 注意这里为了演示故意用一个游标属性 self._idx 0 return self def __next__(self): if self._idx len(self.scores): raise StopIteration value self.scores[self._idx] self._idx 1 return value def __contains__(self, item): return any(course item for course, _ in self.scores) def __getitem__(self, index): return self.scores[index]迭代器协议是很多新手卡壳的地方。__iter__必须返回一个迭代器对象这个迭代器对象要有__next__方法。我在例子里直接让ScoreReport自己充当了迭代器这是一种简写但要注意它的副作用对象内部多了一个_idx游标状态如果同一个对象被两个for循环嵌套遍历游标会互相干扰出现很难排查的诡异行为。更稳妥的做法是单独写一个迭代器类或者直接让__iter__返回生成器def __iter__(self): for course, score in self.scores: yield course, score这段代码更符合实际工程里的写法。把__iter__写成生成器函数之后for name, score in report:就能像遍历列表一样遍历成绩单。__contains__是in操作符的底层实现。如果没写__contains__Python 会退化为逐个调用__iter__遍历查找再不行就退回用__getitem__从下标 0 开始逐个取直到抛IndexError。所以你会发现就算只实现了__getitem__不实现__iter__对象也能被for循环遍历这是 Python 为了兼容旧式序列而保留的“退路协议”。但我建议不要依赖这个退路能用__iter__就用__iter__语义更清晰性能也更好。至于__getitem__它实现了下标访问report[0]同时因为它返回的是scores的切片report[0:2]也能工作——列表切片会自动转发给__getitem__的slice参数这一点很多教程都讲得不够细。2.5 把成绩单类放在一起看效果if __name__ __main__: s1 ScoreReport(张三, [(数学, 88), (英语, 92)]) s2 ScoreReport(李四, [(数学, 95), (英语, 80)]) print(repr(s1)) # ScoreReport(owner张三, scores[(数学, 88), (英语, 92)]) print(s1) # 张三 的成绩单数学:88, 英语:92 print(len(s1)) # 2 print(bool(ScoreReport(空))) # False print(s1 s2) # False print(s1 s2) # False张三总分180 李四总分175 不对这里是180 175所以False print(s1 s2) # 张三 李四 的成绩单数学:88, 英语:92, 数学:95, 英语:80 for course, score in s1: print(course, score) # 数学 88 / 英语 92 print(数学 in s1) # True print(s1[1]) # (英语, 92)一个自定义类通过这一组双下划线方法就获得和内置容器几乎一样的体验。这也是这篇文章里最重要的一段代码建议你把它完整跑一遍观察每个操作实际触发了哪个方法。3. 最容易被坑的两组对照初始化与析构、属性访问的底层链路3.1__new__和__init__不是一回事很多教程会把__init__叫“构造函数”这是不严谨的。真正的“构造”发生在__new__里它负责分配内存并返回一个实例__init__只是在这个已经存在的实例上做初始化。完整流程是先__new__后__init__而且有个非常重要的规则如果__new__返回的不是cls类型的实例__init__根本不会被调用。那实际写代码时什么时候要重写__new__答案是场景很少但一旦遇到就必须用它。最典型的案例是单例模式class SingleConnection: _instance None def __new__(cls, *args, **kwargs): if cls._instance is None: cls._instance super().__new__(cls) return cls._instance重写__new__时一定要返回super().__new__(cls)的结果否则创建出来的可能不是cls的实例导致后续逻辑全部错乱。另一个场景是继承不可变类型比如你想写一个元组子类在__init__里修改字段是无效的必须在__new__里做好全部处理——不可变类型的字段在__init__阶段已经不能改了。3.2__getattr__与__getattribute__属性访问的完整链路这两个方法名字很像行为差异却非常大。__getattribute__是一个无条件拦截器。只要访问实例的任何属性不管这个属性存不存在都会先经过它。__getattr__是一个兜底处理器。只有正常的属性查找流程全部失败之后它才会被调用。看一段代码就清楚了class Demo: def __init__(self): self.name demo def __getattribute__(self, name): print(getattribute called:, name) return super().__getattribute__(name) def __getattr__(self, name): return ffallback: {name} d Demo() print(d.name) # 先打印 getattribute called: name再打印 demo print(d.not_exist) # 先打印 getattribute called: not_exist再打印 fallback: not_exist看到没有即使是访问一个不存在的属性也会先调__getattribute__查找失败后才落到__getattr__。这解释了为什么在__getattr__里“安全地”访问self.xxx有时会出问题如果self.xxx本身不存在并且你在这个方法里又写了self.xxx就会形成一个隐形的递归循环。正确的做法是直接操作self.__dict__或者调用object.__getattribute__(self, name)来绕过这个兜底逻辑。__getattr__最有实用价值的场景是“懒加载”。比如你在读取后端的 JSON 配置时希望把config[database][host]变成config.database.host又不想为每个字段手写属性class AttrDict: def __init__(self, data): self.__dict__[_data] data def __getattr__(self, name): try: value self._data[name] except KeyError: raise AttributeError(name) if isinstance(value, dict): return AttrDict(value) return value这里我把原始字典存在self.__dict__[_data]而不是self._data data就是为了避免self._data的访问触发__getattr__从而造成递归。这个 trick 非常实用建议记下来。3.3__setattr__与__delattr__赋值和删除也不是省油的灯和读属性对应写操作也有两个钩子__setattr__在self.xxx yyy时触发__delattr__在del self.xxx时触发。一个经典的应用是做不可变对象或者字段校验class ValidatedScore: def __setattr__(self, name, value): if name score and not (0 value 100): raise ValueError(score must be between 0 and 100) super().__setattr__(name, value)不过这里最大的坑是在__setattr__内部如果写self.score value会再次触发__setattr__陷入无限递归。正确的姿势是用super().__setattr__(name, value)或者直接写self.__dict__[name] value。3.4 改了__eq__却忘了__hash__字典和集合里的幽灵问题Python 有一个非常硬性的约定两个对象如果相等a b为True那么它们的哈希值必须相等hash(a) hash(b)。这个约定的底层逻辑是set和dict的实现原理它们先通过哈希值定位到“桶”再用比较桶里的对象。如果两个相等对象哈希不同它们会被放进不同的桶导致查找永久失败。麻烦的地方在于当你自定义了__eq__之后Python 会自动把该类的__hash__设为None对象就变成“不可哈希”了。不信你试试class Person: def __init__(self, name): self.name name def __eq__(self, other): return isinstance(other, Person) and self.name other.name p Person(张三) hash(p) # TypeError: unhashable type: Person这是 Python 的自我保护机制它知道你重定义了相等语义但不知道你的相等逻辑是否基于可变字段所以它不敢再默认按对象 id 计算哈希。如果你确定这个对象在哈希表生命周期内不会被修改可以显式补上def __hash__(self): return hash(self.name)这里隐含的另一个教训是如果你用一个可变对象作为dict的 key那么这个对象一旦被修改哈希值就变了之后你再按原来的 key 去查永远找不到。所以被放进set和dict的对象的参与哈希的字段必须是不可变的。3.5 别把__del__当 C 析构函数用最后一个容易引起误解的是__del__。在 CPython 里它确实会在对象的引用计数归零时被调用但你不能依赖它在确定的时间点执行。原因有两点一是引用计数只适用于 CPython换成 PyPy 这类解释器回收时机完全不同二是循环引用会导致对象进入垃圾回收器gc模块的待处理列表__del__的执行会被推迟到 GC 真正清理的时机。所以如果你写def __del__(self): self.file.close()这在很多情况下都没问题但进程退出时如果还有对象没被回收文件可能不会被优雅关闭。正确的资源管理方式是配合with语句使用上下文管理器也就是下一章要讲的__enter__和__exit__。__del__更适合作为“最后的保底清理”而不是主力资源释放手段。4. 让对象从“能跑”到“好用”上下文管理、下标访问与可调用对象4.1__enter__和__exit__把资源管理变成语法级承诺with open(file.txt) as f:是 Python 里最常用的资源管理方式而它的底层就是__enter__和__exit__。任何实现了这两个方法的对象都可以被with语句接管。__enter__的返回值会成为as后面的变量__exit__在代码块结束后必定被调用不管代码块内部有没有抛异常。一个常见的实战场景是“自动计时器”import time class Timer: def __enter__(self): self.start time.perf_counter() return self def __exit__(self, exc_type, exc_val, exc_tb): self.elapsed time.perf_counter() - self.start print(f执行耗时: {self.elapsed:.4f}s) with Timer(): total sum(range(1_000_000))__exit__有四个参数exc_type、exc_val、exc_tb分别对应异常类型、异常实例、异常 traceback。如果代码块里没有异常这三个参数都是None。如果你想让with块吞掉某个异常通常不推荐让__exit__返回True即可异常会被静默处理返回None或False异常继续向上传播。这个机制非常适合做数据库事务。可以在__enter__里开启事务__exit__里根据有没有异常决定commit还是rollback。这也是为什么 ORM 和数据库驱动几乎都提供上下文管理器接口的原因。4.2__getitem__和__setitem__让对象像列表和字典一样可读写我在第 2 章已经实现了__getitem__这一节把它的机制彻底说透。obj[key]会被 Python 翻译成type(obj).__getitem__(obj, key)。注意这里的查找是按类型进行的不是直接在对象上找。这也是为什么给某个实例单独挂载一个__getitem__属性不会生效因为它影响不了类型的查询路径。__getitem__不仅能接收整数下标还能接收slice对象和任意 keyclass MultiIndex: def __getitem__(self, key): if isinstance(key, slice): return fslice from {key.start} to {key.stop} return fsingle key: {key} m MultiIndex() print(m[1]) # single key: 1 print(m[1:3]) # slice from 1 to 3配合__setitem__、__delitem__后对象就能像一个“行为可控的字典”你可以在赋值时做类型检查可以在删除时触发清理回调甚至可以实现类似“带默认值的字典”的效果。4.3__call__让实例拥有函数的能力在 Python 里函数是一等公民但有时候你需要一个“带状态的函数”。这时让你的对象实现__call__它就能像普通函数一样被调用。class Adder: def __init__(self, n): self.n n def __call__(self, x): return x self.n add_5 Adder(5) print(add_5(3)) # 8这里add_5是一个对象但它可以像函数一样使用。这种模式在几种场景下特别好用策略模式把不同的处理逻辑封装成可调用对象互相替换。装饰器工厂实现一个带参数的装饰器。回调函数给框架传入一个带__call__的实例它既能执行逻辑又能保存中间状态。一个更进阶的用途是让类本身“可配置”。你在__init__里存好参数__call__里执行主逻辑然后把这个对象丢给高层框架。框架不关心它到底是不是函数只要能调用就行这就是接口与具体实现的解耦。4.4__contains__、__iter__和__getitem__的优先级关系很多人在写自定义容器时会困惑in操作到底触发哪个方法其实 Python 的查找顺序非常明确先找__contains__找到了直接用它判断。如果没有__contains__就尝试用__iter__逐个遍历比对。如果__iter__也没有就退回__getitem__从下标 0 开始逐个取直到IndexError。这个优先级设计很符合“最精确的方法最优先”的原则。你在设计容器类时如果成员判断能被一个 O(1) 的集合计算完成那一定要实现__contains__否则默认的遍历方式可能要 O(n)。这个优化在数据量大时非常明显。4.5 一张表看清常用魔术方法的使用时机你写的代码解释器实际调用备注obj othertype(obj).__add__(obj, other)左边不行会尝试other.__radd__obj * ntype(obj).__mul__(obj, n)类似的还有__rmul__len(obj)type(obj).__len__(obj)返回必须是非负整数obj[i]type(obj).__getitem__(obj, i)i 可以是 int、slice、str 等obj[i] vtype(obj).__setitem__(obj, i, v)赋值表达式for i in objtype(obj).__iter__(obj)没有__iter__时退回__getitem__x in objtype(obj).__contains__(obj, x)没有__contains__时退回迭代with obj as v:type(obj).__enter__(obj)/type(obj).__exit__(...)__exit__接收异常参数str(obj)/print(obj)type(obj).__str__(obj)没有__str__时退回__repr__repr(obj)type(obj).__repr__(obj)调试器、交互式终端调用bool(obj)type(obj).__bool__(obj)没有__bool__时退回__len__obj()type(obj).__call__(obj)让实例可调用hash(obj)type(obj).__hash__(obj)与__eq__必须同步实现a btype(a).__eq__(a, b)也可触发type(b).__eq__(b, a)5. 实战排查与性能调优中的几个观察视角5.1 用“打印大法”观察魔术方法的真实调用时机魔术方法最大的特点是“隐式调用”。你在代码里写下一个并不能直接看到__add__被调用。排查问题时我经常会在方法内部临时加一行print观察它的触发轨迹。class DebugList: def __init__(self, items): self.items list(items) def __len__(self): print(__len__ called) return len(self.items) def __getitem__(self, idx): print(f__getitem__ called: {idx}) return self.items[idx] dl DebugList([1, 2, 3]) print(len(dl)) # __len__ called for i in dl: # 会连续打印 __getitem__ called: 0 / 1 / 2 / 3 pass注意最后这个for循环会打印__getitem__ called: 0、1、2、3。第 3 次是 Python 在尝试通过越界IndexError判断“迭代结束”。看到这你就能明白Python 的旧版序列迭代协议确实是这么工作的。如果不想给每个方法都加打印还有一个偷懒技巧在__getattribute__里临时加一行print(name)能看到实例上所有属性访问的完整轨迹。但这个方法日志量极其庞大而且容易刷屏一般只在排查递归问题时才用。5.2__slots__不是魔术方法却是性能问题的常见解药严格来说__slots__是在类定义时声明的一个类属性它不是双下划线开头的“协议方法”但它对理解对象属性机制非常有帮助所以放在这里一起说。默认情况下Python 的每个实例都有一个__dict__字典用来存放实例属性。这个字典带来了灵活的属性增删能力但也带来了内存开销。如果一个类你会创建成千上万个实例并且字段是固定的可以用__slots__禁用这个字典class Point: __slots__ (x, y) def __init__(self, x, y): self.x x self.y y加上之后实例不再有__dict__属性访问在底层变成了类似 C 结构体成员的固定偏移量访问内存占用大幅下降属性访问速度也会快一些。代价是不能随意给实例添加新属性。当你发现程序内存占用异常时优先检查有没有大量对象都在维护着自己的__dict__。5.3 魔术方法查找走的不是实例字典一个非常隐蔽的坑是魔术方法的查找发生在类型类上而不是实例上。这意味着如果你这样写class A: pass a A() a.__len__ lambda: 42 len(a) # TypeError: object of type A has no len()这个len(a)依然会抛异常因为len()在type(a)即A类上找__len__而实例上的__len__属性不影响类的协议查询。理解这一点对调试非常有帮助。有些库允许用户为单个实例动态挂载方法看起来“像”实现了协议但实际上只要不是定义在类上的内置函数一律不认。5.4 常见报错的快速定位我整理几个最常见的异常和它们的真实原因排查时可以先对照一下报错信息真实原因TypeError: object of type X has no len()类没有实现__len__或者手误写成了__length__之类的TypeError: X object is not iterable类没有实现__iter__和__getitem__for无路可走TypeError: X object is not subscriptable类没有实现__getitem__却用了obj[i]TypeError: X object does not support item assignment类没有实现__setitem__却用了obj[i] vTypeError: X object is not callable类没有实现__call__却写了obj()AttributeError: __enter__类没有实现__enter__/__exit__却用了with obj:TypeError: unhashable type: X类定义了__eq__但没定义__hash__或者哈希字段是可变的RecursionError__getattr__/__getattribute__/__setattr__内部又访问了同名字段出现无限递归5.5 一个务实的开发习惯写完类先补“协议三件套”在我自己写类库和接手别人代码时很容易通过一个类有没有补全基础协议方法来判断作者的工程经验。所谓“协议三件套”就是__repr__、__eq__、__hash__。__repr__让调试信息和日志输出不再是一堆__main__.X object at 0x...直接能看到关键字段。__eq__让对象之间的比较符合业务直觉而不是默认比较内存地址。__hash__让对象能安全地放进set、dict、frozenset配合__eq__一起用才不会被临时报unhashable的错卡住。这三个方法补完之后你的类才真正具备“值对象”的资格。尤其在写数据模型、领域对象、配置项这类场景时这套组合带来的提升是立竿见影的。我个人在实际调试中还有一个体会如果某天你发现一个自定义对象在set里出现了“重复元素”或者dict的 key 明明看起来一样却查不到先别怀疑是哈希算法的问题第一反应应该是去看这个类的__eq__和__hash__是否共同实现了、它们依赖的字段是不是可变对象。这类问题往往藏得很深因为表面报错并不直接指向魔术方法。掌握双下划线这套协议之后你会发现自己写的类能和内置类型一样顺手排错速度也会快一个档次。