想象你拿到了一套毛坯房:墙刷好了,窗户也装了,但没接水管、没通电、也没挂门牌。城市水电工上门,只能摇头——没有接口,公共服务根本送不进来。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”只是顺带的事。

目录
  1. 可视化演示
  2. dunder基础认知
  3. 创建与显示协议
  4. 比较运算符协议
  5. 容器迭代协议
  6. 上下文管理协议
  7. Pythonic设计实践
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示自定义类(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)
内置操作在底层被“翻译”成对象自己的方法调用,这就是协议的全部秘密。
  1. 内置函数 len(x)Python 提供给全体对象的公共“测量”服务,任何对象都能传进去。
    len(x)\displaystyle \text{len}(x)
  2. 协议转译等号表示“底层自动委托”,而不是数值相等。
    \displaystyle \text{len}(x) = x.\__len__()
  3. 对象方法 len()类里定义的实例方法,返回一个非负整数作为长度。
    \displaystyle x.\__len__()
  4. 其余映射同理加号和下标也会被翻译成各自对应的 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 方法。
  1. repr(x)交互式环境里输入对象名回车后看到的文本。
    repr(x)\displaystyle \text{repr}(x)
  2. x.repr()应返回“字面量风格”的字符串,最好能直接粘回代码重建对象。
    \displaystyle x.\__repr__()
  3. str(x)print(x) 时展示给用户的文本。
    str(x)\displaystyle \text{str}(x)
  4. 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__ 方法的语法糖衣。
  1. x == y这是你写的代码,可读且自然。
    x==y\displaystyle x == y
  2. 等价符号表示“底层语义完全一致”,Python 会自动做这个翻译。
    ≡\displaystyle \equiv
  3. x.eq(y)把右边的对象 y 作为参数传进去,self 就是左边的 x。
    \displaystyle x.\__eq__(y)
  4. 返回 True/False这个布尔值直接决定整个 == 表达式的结果。
    True / False\displaystyle \text{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__()
容器协议就是“房子能报面积、能按门牌找房间、能带游客参观”三板斧。
  1. 长度协议len(x) 委托给 __len__,返回元素个数。
    \displaystyle \text{len}(x) = x.\__len__()
  2. 索引协议x[i] 委托给 __getitem__,下标作为参数传入。
    \displaystyle x[i] = x.\__getitem__(i)
  3. 迭代协议for 循环委托给 __iter__,拿到一个迭代器。
    \displaystyle \text{for } v \text{ in } x \rightarrow x.\__iter__()
  4. 回退机制没有 __iter__ 时,Python 退回 0、1、2…… 连续调 __getitem__ 直到异常。
    x[0],x[1],x[2],… 直到 IndexError\displaystyle x[0], x[1], x[2], \ldots \ \text{直到 IndexError}

上下文管理协议

物业托管合同,就是 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 是语法糖,本质是“先进场、再干活、最后一定退场”的三段式合同。
  1. with m as v进入上下文管理器的入口。
    with m as v\displaystyle \text{with } m \text{ as } v
  2. 入场赋值__enter__ 的返回值交给 as 后的变量 v。
    \displaystyle v = m.\__enter__()
  3. 执行主体缩进块里的代码 B 在 try 中运行,随时可能抛异常。
    try: B\displaystyle \text{try: } B
  4. 善后退场无论成败,__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{排序协议}}
协议组合就像水电网一起接通,一座房子真正融入城市。
  1. 长度协议让 len() 认识你的对象。
    \displaystyle \underbrace{\text{len}(x) = x.\__len__()}_{\text{长度协议}}
  2. 索引协议让下标、切片、成员判断都可用。
    \displaystyle \underbrace{x[i] = x.\__getitem__(i)}_{\text{索引协议}}
  3. 排序协议让 < 和 sorted() 能处理你自定义的对象。
    \displaystyle \underbrace{x < y = x.\__lt__(y)}_{\text{排序协议}}
  4. 组合效果三者叠加,对象“像 list 一样好用”。
    协议组合→内置生态兼容\displaystyle \text{协议组合} \rightarrow \text{内置生态兼容}

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 演示

python45 行
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))
目的:用 4 个 dunder 协议让一个迷你数据集类像内置容器一样支持长度、下标、遍历、比较和排序。
  1. def __len__(self):让 len(ds) 返回样本数;这是最基础的“面积测量”协议。
  2. def __getitem__(self, idx):让 ds[0] 能取到第一个样本,像 list 一样按下标访问。
  3. def __iter__(self): return iter(self.samples)让 for s in ds 能逐个访问全部样本。
  4. def __lt__(self, other):让 sorted(ds) 能对样本按分数排序,评估师登场。
  5. def __repr__(self):让调试时一眼看清对象内容,而不是一堆内存地址。
  6. 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」这个名称是怎么来的?

单选题 · easy

  • A.双下划线单词的连读缩写← 正确答案
  • B.一种 Python 官方标准缩写
  • C.一位著名 Python 开发者的姓氏
  • D.动态更新运行时的英文简称

答案:A。dunder 是 double underscore(双下划线)的连读,像“donut”一样自然。它提醒我们:这类方法不是普通方法,而是语言预留的接口端子。

Q2. 要让自定义类支持 len(obj),应当实现哪个方法?

单选题 · easy

  • A.__size__
  • B.__len__← 正确答案
  • C.__length__
  • D.__count__

答案:B。len() 在底层委托给 __len__,这是数据模型文档钦定的长度协议。方法是接口端子,名字不能按自己的喜好发明,否则就“插不进国家标准插座”。

Q3. 关于 __str__ 与 __repr__,说法正确的有哪些?

多选题 · mid

  • A.__repr__ 面向开发者调试← 正确答案
  • B.__str__ 面向最终用户← 正确答案
  • C.print() 优先调用 __str__← 正确答案
  • D.缺少 __repr__ 时会自动用 __str__ 补齐

答案:ABC。D 说反了,回退方向是 __str__ 缺席时退回 __repr__;REPL 永远优先显示 __repr__。这两个方法像门牌的两面:一面给访客看,一面给维修工看。

Q4. 实现了 __eq__ 之后,Python 会自动补充哪个方法?

单选题 · mid

  • A.__lt__
  • B.__ne__← 正确答案
  • C.__gt__
  • D.__cmp__

答案:B。Python 3 会自动从 __eq__ 推导出 __ne__(即 !=),但排序用的 __lt__、__gt__ 不会自动生成。评估师只解决了“是否相同”,没解决“谁大谁小”。

Q5. 要让自定义类支持 x[i] 下标访问,应当实现哪个方法?

单选题 · mid

  • A.实现 __index__() 方法
  • B.实现 __getitem__() 方法← 正确答案
  • C.实现 __len__() 方法
  • D.实现 __iter__() 方法

答案:B。下标语法 x[i] 被翻译成 x.__getitem__(i)。__index__ 是把对象转成整数下标用的,__len__ 管长度,__iter__ 管遍历,各有各的管线。

Q6. 只实现 __getitem__ 而不实现 __iter__,for 循环还能工作吗?

单选题 · extreme

  • A.不能,必须实现 __iter__
  • B.能,Python 会退回按 0、1、2… 索引逐个取← 正确答案
  • C.能,但只能迭代一次
  • D.不能,会立刻抛异常

答案:B。这是旧式迭代协议:Python 从下标 0 开始反复调用 __getitem__,直到它抛出 IndexError 才停止。相当于没有预约参观动线时,导游就按门牌号一间一间硬敲。

Q7. 以下哪些属于容器/迭代协议?

多选题 · mid

  • A.__len__← 正确答案
  • B.__getitem__← 正确答案
  • C.__iter__← 正确答案
  • D.__call__

答案:ABC。__len__ 量面积、__getitem__ 按门牌找房、__iter__ 规划动线,三者合起来让对象像容器。__call__ 是“呼叫铃”,让对象像函数一样被调用,不属于容器协议。

Q8. 关于 with 与上下文管理协议,说法正确的有哪些?

多选题 · mid

  • A.__enter__ 的返回值赋给 as 后的变量← 正确答案
  • B.__exit__ 在正常结束时会被调用← 正确答案
  • C.__exit__ 在出现异常时不调用
  • D.__exit__ 返回 True 可以抑制异常← 正确答案

答案:ABD。__exit__ 无论正常还是异常都会执行,就像物业退房一定查水电;返回 True 表示“这个异常我已经处理了”。C 把最关键的特性说反了,这正是上下文管理最可靠的环节。

Q9. __repr__ 的主要用途是什么?

单选题 · mid

  • A.支持 + 运算符
  • B.返回可读的调试字符串← 正确答案
  • C.让对象可被 pickle
  • D.实现对象排序

答案:B。__repr__ 是给开发者看的“门牌名片”,目标是一眼看清对象内容,最好能直接复制回代码重建对象。运算符、序列化、排序各有各的协议,不归它管。

Q10. 实现 __add__ 时,约定俗成的正确做法是?

单选题 · hard

  • A.原地修改并返回自己
  • B.原地修改并返回 None
  • C.创建并返回一个新的对象← 正确答案
  • D.返回一个布尔比较结果

答案:C。+ 在语义上应该“产生一个新结果”,而不是破坏原来的操作数,就像户型合并后新房和老房都还在。如果原地修改并返回 self,会导致 a + b 悄悄改变 a,这违反最少惊讶原则。