想象你拿到了一套毛坯房:墙刷好了,窗户也装了,但没接水管、没通电、也没挂门牌。城市水电工上门,只能摇头——没有接口,公共服务根本送不进来。Python 的类也一样。你写了一个 class Dog,它确实是一座“房子”,但如果没有 __init__、__repr__、__len__ 这些双下划线方法,len(dog)、print(dog)、for x in dog 这些“城市公共服务”就会对你爱答不理。
带双下划线的方法在英文里叫 double underscore,念快了就成了「dunder」,中文圈习惯叫「魔术方法」。它们不神秘,本质是 Python 留给你的一排接口端子:插上哪个,你新建的“房子”就能接入城市的哪条管线。接好水电煤网,房子才从毛坯房变成正式居民;接好协议,你的类才从“自定义玩具”变成真正 Pythonic 的对象。
这篇文章以「给新房接水电」为主线,带你逐条认识 dunder:基础认知、创建显示、比较运算、容器迭代、上下文管理,以及最后的设计心法。读完你就能让任何类体面地融入 Python 生态。
对 AI 从业者来说,掌握魔术方法意味着写出的数据管道、模型封装和训练框架能直接享受 len()、for、with 等语言级能力——代码更少、协作更顺,被同事夸“很 Pythonic”只是顺带的事。
可视化演示
展示自定义类(cls)通过实现init/repr/eq/len/getitem/iter等魔术方法,接入Python内置协议(Init/Str/Cmp/Size/Idx/Loop)。
本节配有交互动效(进入视口后自动加载,约 3KB)。
魔术方法(Dunder Methods) init→初始化协议、repr→字符串表示、eq→相等比较、len→长度协议 getitem→索引访问、iter→迭代协议,让自定义类拥有Python内置行为 class MyClass: def len(self) → len(obj)
dunder基础认知
城市施工不看你的墙有多漂亮,只看接口是否按国家标准预留。Python 的数据模型文档就是那本“国家标准”,里面规定了一排带双下划线的方法:__init__、__repr__、__len__…… 方法名里藏着两个下划线(double underscore),念快了就成了 “dunder”。
为什么名字这么怪?为了不跟普通方法撞车。Python 约定:凡是双下划线包围的名字,都是“语言预留接口”。当你给类写上 def __len__(self):,类自己并不懂你的意图,但 len(obj) 会顺着这个名字找到你的实现——就像水电工看到国家标准插座,立刻知道往哪儿插。
内置函数与 dunder 是“接口合同”:len(x) 实际调用 x.__len__(),x + y 调用 x.__add__(y),x[i] 调用 x.__getitem__(i)。这些约定写在官方“数据模型”文档里,属于协议。实现协议不用继承、不用注册,照着方法名写就行——Python 社区管这叫“鸭子类型”。
认识了国家标准插座,下面就开始一间屋子一间屋子地布线。
\displaystyle \text{len}(x) = x.\__len__() \qquad x + y = x.\__add__(y) \qquad x[i] = x.\__getitem__(i)- 内置函数 len(x)Python 提供给全体对象的公共“测量”服务,任何对象都能传进去。
- 协议转译等号表示“底层自动委托”,而不是数值相等。
\displaystyle \text{len}(x) = x.\__len__() - 对象方法 len()类里定义的实例方法,返回一个非负整数作为长度。
\displaystyle x.\__len__() - 其余映射同理加号和下标也会被翻译成各自对应的 dunder 方法。
\displaystyle x + y = x.\__add__(y) \qquad x[i] = x.\__getitem__(i)
创建与显示协议
装修公司进场了。__init__ 是设计师:房子的主体结构由系统自动分配,__init__ 负责水电布局、家具摆放——也就是初始化对象的属性。你写的 def __init__(self, name) 会在对象落成的那一刻自动执行,相当于“钥匙一交,装修队开工”。
装修完还要挂门牌。__repr__ 是开发者门牌,用近乎代码风格的字符串表达对象内容;__str__ 是用户门牌,是 print() 递给客户看的介绍。两个方法都要求返回字符串,但受众不同:__repr__ 给程序员调试,__str__ 给使用者阅读。
细节上,print(x) 优先找 __str__;找不到就退回 __repr__。而在交互式命令行(REPL)里直接敲对象名,永远显示 __repr__。所以最省事的策略是只写 __repr__,让 print 自动蹭它的输出。
门牌挂好,访客老远就能认出来。不过两座房子长得像不像、谁大谁小,还得靠评估师出场。
\displaystyle \text{repr}(x) = x.\__repr__() \qquad \text{str}(x) = x.\__str__()repr() 和 str() 是两个内置函数,它们分别委托给对象的两个 dunder 方法。- repr(x)交互式环境里输入对象名回车后看到的文本。
- x.repr()应返回“字面量风格”的字符串,最好能直接粘回代码重建对象。
\displaystyle x.\__repr__() - str(x)
print(x)时展示给用户的文本。 - x.str()没有定义时,Python 默认继承
object的实现,只显示内存地址。\displaystyle x.\__str__()
比较运算符协议
两套房子怎么比?不能靠目测。__eq__ 是评估师,__lt__ 是排序员,__add__ 是户型合并师。它们让 ==、<、+ 这些运算符也认识你的对象,这就是“运算符重载”。
默认情况下,object 的 __eq__ 比较的是内存地址——两个内容完全相同的对象,若不重写,== 也会返回 False。这就像两套户型图一模一样的房子,评估师不看内容只报“门牌号不同,不算同一套”。要按内容比较,就得重写 __eq__。
细节上,__lt__ 返回布尔值,sorted() 靠它排顺序;__add__ 应该返回一个新对象而不是修改 self。另外好消息是:实现 __eq__ 后,Python 3 会自动从它补出 __ne__(即 !=),但不会自动补 __lt__——排序还得自己动手。
现在你的房子有了身份和比价规则。接下来让它变成可数、可逛的多房间住宅。
\displaystyle x == y \;\equiv\; x.\__eq__(y)== 号不是魔法,它只是 __eq__ 方法的语法糖衣。- x == y这是你写的代码,可读且自然。
- 等价符号表示“底层语义完全一致”,Python 会自动做这个翻译。
- x.eq(y)把右边的对象 y 作为参数传进去,self 就是左边的 x。
\displaystyle x.\__eq__(y) - 返回 True/False这个布尔值直接决定整个
==表达式的结果。
容器迭代协议
房产证要写面积,门牌号要能索引到房间,还要规划参观动线。对应到 Python:__len__ 返回元素个数,让 len(x) 工作;__getitem__ 支持 x[i]、切片甚至 in 判断;__iter__ 返回迭代器,让 for 循环和列表推导式都能用。实现这三个,对象就变成像 list、dict 一样的正经容器。
__len__ 是最容易的:返回一个整数即可。__getitem__ 稍微讲究:参数 i 是下标,你要返回对应元素;越界时应该抛 IndexError,这样切片和负索引才能正常工作。__iter__ 则给 for 循环提供“参观动线”,每走一步从迭代器里取一个元素。
有趣的是,即使只实现 __getitem__ 而不写 __iter__,Python 也会用“旧式迭代协议”兜底:从下标 0 开始,循环调用 __getitem__,直到它抛出 IndexError 才停。这叫“索引回退”,老代码里非常常见。
房子变成可以数、可以逛的住宅区之后,还差一项贴心服务——物业托管。
\displaystyle \text{len}(x) = x.\__len__() \qquad x[i] = x.\__getitem__(i) \qquad \text{for } v \text{ in } x \rightarrow x.\__iter__()- 长度协议
len(x)委托给__len__,返回元素个数。\displaystyle \text{len}(x) = x.\__len__() - 索引协议
x[i]委托给__getitem__,下标作为参数传入。\displaystyle x[i] = x.\__getitem__(i) - 迭代协议
for循环委托给__iter__,拿到一个迭代器。\displaystyle \text{for } v \text{ in } x \rightarrow x.\__iter__() - 回退机制没有
__iter__时,Python 退回 0、1、2…… 连续调__getitem__直到异常。
上下文管理协议
物业托管合同,就是 with 语句。__enter__ 是进场服务:开门、通电、开空调;__exit__ 是退场善后:关窗、断电、交钥匙。只要对象实现这两个方法,就能用 with 管理它的生命周期,而且善后一定执行——哪怕屋里中途着了火(抛异常),退场流程也不会少。
文件对象是经典案例:with open(...) as f: 无论中间是否报错,__exit__ 都会关闭文件。在 AI 训练中,模型保存、显存清理、日志开关也常用这个模式,防止训练中途崩溃导致资源泄漏。
细节上,__enter__ 的返回值会成为 as 后面的变量;__exit__ 接收三个参数(异常类型、异常值、回溯栈),正常结束时三个都是 None。如果 __exit__ 返回 True,还能把异常“吞掉”,让程序继续往下走——这招要慎用,像消防员把小火苗踩灭,但大火必须上报。
物业托管的合同签好,房子就从毛坯房升级成高级住宅了。最后一步,是理解城市的整体规划。
\displaystyle \text{with } m \text{ as } v: B \;\Longleftrightarrow\; v = m.\__enter__();\; \text{try: } B \;\text{finally: } m.\__exit__(e)with 是语法糖,本质是“先进场、再干活、最后一定退场”的三段式合同。- with m as v进入上下文管理器的入口。
- 入场赋值
__enter__的返回值交给 as 后的变量 v。\displaystyle v = m.\__enter__() - 执行主体缩进块里的代码 B 在 try 中运行,随时可能抛异常。
- 善后退场无论成败,
__exit__都会在 finally 中被调用。\displaystyle \text{finally: } m.\__exit__(e)
Pythonic设计实践
城市规划讲究整体协调。单个协议只是插座,组合起来才是完整的基础设施。实现 __len__ 的同时,bool(x) 也会自动按“长度是否为 0”来判定;实现 __getitem__/__iter__ 后,解包、成员判断顺势可用——这就是协议一致性:一个协议会带动一串内置行为。
协议组合使用,威力更大。容器协议 + 比较协议 + 显示协议齐活,你的类就能像内置类型一样被队友无脑使用:能 len()、能下标、能 for、能 sorted()、能一眼看懂 repr。模仿内置类型的直觉、不发明新名词,是 Pythonic 的核心。
设计原则有三条:第一,只实现使用场景需要的协议,不贪多;第二,用最少的 dunder 换取最大的生态兼容;第三,留意协议之间的联动,例如 __str__ 会回退到 __repr__,__eq__ 会自动推出 __ne__。协议是接口契约,不是装饰品。
当一座房子水电网全通、门牌有字、面积可测、房间可逛、物业有人管,它就不再是违章建筑——你的类也一样:协议装齐,Python 对你一视同仁。
---
\displaystyle \underbrace{\text{len}(x) = x.\__len__()}_{\text{长度协议}} \quad \underbrace{x[i] = x.\__getitem__(i)}_{\text{索引协议}} \quad \underbrace{x < y = x.\__lt__(y)}_{\text{排序协议}}- 长度协议让
len()认识你的对象。\displaystyle \underbrace{\text{len}(x) = x.\__len__()}_{\text{长度协议}} - 索引协议让下标、切片、成员判断都可用。
\displaystyle \underbrace{x[i] = x.\__getitem__(i)}_{\text{索引协议}} - 排序协议让
<和sorted()能处理你自定义的对象。\displaystyle \underbrace{x < y = x.\__lt__(y)}_{\text{排序协议}} - 组合效果三者叠加,对象“像 list 一样好用”。
AI 应用场景
训练数据管道
阶段:数据处理与数据加载
解决的问题:传统代码总爱自定义 get_data()、get_size()、get_item() 等 API,队友每次都要翻文档才能用。AI 数据集类实现 __len__/__getitem__/__iter__ 后,可以像内置 list 一样被 DataLoader 直接消费,代码一看就懂。
具体案例:PyTorch 的 Dataset 接口要求实现 __len__ 和 __getitem__,配合 DataLoader 自动完成批量采样、打乱、多进程加载。写过一次之后,所有训练脚本都可以用同一套“城市接口”取数据。
模型输出排序
阶段:模型推理与后处理
解决的问题:目标检测的候选框按置信度排序,如果每次都写 key=lambda x: x.confidence,既啰嗦又容易查错字段。给结果类实现 __lt__ 后,直接 sorted(results) 就能按内置直觉排好。
具体案例:训练时每隔 N 轮打印 Top-K 结果:for box in sorted(boxes)[-3:]——评估师(__lt__)到位,排序问题自然解决。
训练资源自动清理
阶段:训练实验管理与资源管理
解决的问题:训练脚本经常要开 logger、设随机种子、切换设备、释放显存。用上下文管理协议包一层,__enter__ 自动布置环境,__exit__ 自动保存曲线并释放资源,杜绝异常时资源泄漏。
具体案例:MLOps 平台运行端到端训练任务时,用 with experiment("run-01") as exp: 统一管理初始化、中途失败回滚和结束收尾,代码整洁又可靠。
Python 演示
class Sample: def __init__(self, name, score): self.name = name self.score = score def __repr__(self): return "Sample(" + self.name + ", " + str(self.score) + ")" def __eq__(self, other): return isinstance(other, Sample) and self.name == other.name and self.score == other.score def __lt__(self, other): return self.score < other.score class AiDataset: def __init__(self, samples): self.samples = list(samples) self._count = len(self.samples) def __len__(self): return self._count def __getitem__(self, idx): return self.samples[idx] def __iter__(self): return iter(self.samples) def __repr__(self): return "AiDataset(" + str(len(self)) + " samples)" if __name__ == "__main__": ds = AiDataset([ Sample("cat", 0.9), Sample("dog", 0.7), Sample("bird", 0.8), ]) print(len(ds)) print(ds[0]) for s in ds: print(s) print(ds == AiDataset(ds.samples)) print(sorted(ds))
def __len__(self):让len(ds)返回样本数;这是最基础的“面积测量”协议。def __getitem__(self, idx):让ds[0]能取到第一个样本,像 list 一样按下标访问。def __iter__(self): return iter(self.samples)让for s in ds能逐个访问全部样本。def __lt__(self, other):让sorted(ds)能对样本按分数排序,评估师登场。def __repr__(self):让调试时一眼看清对象内容,而不是一堆内存地址。print(ds == AiDataset(ds.samples))触发AiDataset.__eq__,内部再做元素级比较。
常见误区
误区学会了 `__init__` 就觉得类已经定制好了,反正别的也用不上。
正解__init__ 只是装修队。没挂门牌(__repr__),调试时两眼一黑;没装水表(__len__),对象在容器面前就是黑洞。真正让类“接入城市生态”的是整套协议。
为何常见:入门教程大量铺陈 __init__,很容易让人误以为它代表了全部。
误区默认 `print(对象)` 能输出 `<__main__.Dog object at 0x7f...>`,能用就行。
正解默认输出只包含内存地址,定位 bug 时毫无信息。实现 __repr__ 后,日志、断言、交互式排查一目了然,这是最划算的调试投资。
为何常见:内存地址看起来“很底层很专业”,初学者以为这就是正确行为。
误区写了 `__eq__` 就万事大吉,比较相关的都自动覆盖。
正解Python 3 会自动从 __eq__ 补出 __ne__,但 sorted() 还认 __lt__,+ 还认 __add__。只做 == 的房子,在排序时依然排不上队。
为何常见:平时只测 ==,没有把对象放进排序或更复杂的容器场景。
误区把所有见过的 dunder 方法都写进类里,显得能力强、考虑周全。
正解多余协议让行为难以预测。Pythonic 是按使用场景实现所需协议,用最少的接口换最大的生态兼容。
为何常见:把“多”当“好”,没意识到协议是接口契约而非装饰品。
面试题
Q1. 「dunder」这个名称是怎么来的?
- A.双下划线单词的连读缩写← 正确答案
- B.一种 Python 官方标准缩写
- C.一位著名 Python 开发者的姓氏
- D.动态更新运行时的英文简称
答案:A。dunder 是 double underscore(双下划线)的连读,像“donut”一样自然。它提醒我们:这类方法不是普通方法,而是语言预留的接口端子。
Q2. 要让自定义类支持 len(obj),应当实现哪个方法?
- A.
__size__ - B.
__len__← 正确答案 - C.
__length__ - D.
__count__
答案:B。len() 在底层委托给 __len__,这是数据模型文档钦定的长度协议。方法是接口端子,名字不能按自己的喜好发明,否则就“插不进国家标准插座”。
Q3. 关于 __str__ 与 __repr__,说法正确的有哪些?
- A.
__repr__面向开发者调试← 正确答案 - B.
__str__面向最终用户← 正确答案 - C.
print()优先调用__str__← 正确答案 - D.缺少
__repr__时会自动用__str__补齐
答案:ABC。D 说反了,回退方向是 __str__ 缺席时退回 __repr__;REPL 永远优先显示 __repr__。这两个方法像门牌的两面:一面给访客看,一面给维修工看。
Q4. 实现了 __eq__ 之后,Python 会自动补充哪个方法?
- A.
__lt__ - B.
__ne__← 正确答案 - C.
__gt__ - D.
__cmp__
答案:B。Python 3 会自动从 __eq__ 推导出 __ne__(即 !=),但排序用的 __lt__、__gt__ 不会自动生成。评估师只解决了“是否相同”,没解决“谁大谁小”。
Q5. 要让自定义类支持 x[i] 下标访问,应当实现哪个方法?
- A.实现
__index__()方法 - B.实现
__getitem__()方法← 正确答案 - C.实现
__len__()方法 - D.实现
__iter__()方法
答案:B。下标语法 x[i] 被翻译成 x.__getitem__(i)。__index__ 是把对象转成整数下标用的,__len__ 管长度,__iter__ 管遍历,各有各的管线。
Q6. 只实现 __getitem__ 而不实现 __iter__,for 循环还能工作吗?
- A.不能,必须实现
__iter__ - B.能,Python 会退回按 0、1、2… 索引逐个取← 正确答案
- C.能,但只能迭代一次
- D.不能,会立刻抛异常
答案:B。这是旧式迭代协议:Python 从下标 0 开始反复调用 __getitem__,直到它抛出 IndexError 才停止。相当于没有预约参观动线时,导游就按门牌号一间一间硬敲。
Q7. 以下哪些属于容器/迭代协议?
- A.
__len__← 正确答案 - B.
__getitem__← 正确答案 - C.
__iter__← 正确答案 - D.
__call__
答案:ABC。__len__ 量面积、__getitem__ 按门牌找房、__iter__ 规划动线,三者合起来让对象像容器。__call__ 是“呼叫铃”,让对象像函数一样被调用,不属于容器协议。
Q8. 关于 with 与上下文管理协议,说法正确的有哪些?
- A.
__enter__的返回值赋给as后的变量← 正确答案 - B.
__exit__在正常结束时会被调用← 正确答案 - C.
__exit__在出现异常时不调用 - D.
__exit__返回 True 可以抑制异常← 正确答案
答案:ABD。__exit__ 无论正常还是异常都会执行,就像物业退房一定查水电;返回 True 表示“这个异常我已经处理了”。C 把最关键的特性说反了,这正是上下文管理最可靠的环节。
Q9. __repr__ 的主要用途是什么?
- A.支持
+运算符 - B.返回可读的调试字符串← 正确答案
- C.让对象可被 pickle
- D.实现对象排序
答案:B。__repr__ 是给开发者看的“门牌名片”,目标是一眼看清对象内容,最好能直接复制回代码重建对象。运算符、序列化、排序各有各的协议,不归它管。
Q10. 实现 __add__ 时,约定俗成的正确做法是?
- A.原地修改并返回自己
- B.原地修改并返回 None
- C.创建并返回一个新的对象← 正确答案
- D.返回一个布尔比较结果
答案:C。+ 在语义上应该“产生一个新结果”,而不是破坏原来的操作数,就像户型合并后新房和老房都还在。如果原地修改并返回 self,会导致 a + b 悄悄改变 a,这违反最少惊讶原则。