我最早意识到“Magic Methods”不是冷知识,是某次在代码评审里给别人写了一个自以为很优雅的类,结果同事随口说了一句:你这个对象没法放进set,因为__hash__和__eq__的关系被破坏了。当时我以为他只是抠细节,后来才明白,Python 里这些双下划线方法根本就是语言运行机制的一部分,不是所谓的“语法糖”那么简单。Python 3.12 里,Magic Methods(魔术方法,也叫特殊方法或 dunder 方法)依然是我们这些写 Python 的人绕不开的核心技能。
这一篇是 Python 3.12 MagicMethods 系列的第一篇,先把整体框架和底层逻辑讲透。为什么要叫“简介”?因为魔术方法覆盖面极广,你得先把它们是什么、什么时候被调用、为什么这么设计想清楚,后面再逐个贴代码才有意义。内容会比较硬核,但不至于劝退:我会尽量用日常写代码的场景来解释,让你以后看源码、调试、设计库的时候,能像看自己的代码一样顺畅。
适合哪些人读?如果你准备深入理解 Python 的语法机制,或者已经写过两年代码但遇到__getattr__、__slots__这类东西时心里发虚,又或者你在准备技术面试、想弄清楚“为什么这个类支持len()而那个不行”,这篇都能给你一个比较系统的基底。我会结合 Python 3.12 的一些新变化来讲,也会穿插一些我自己踩过的坑,尽量做到“学过就能用”。
1. 魔术方法到底是什么:从语法糖到底层机制
1.1 一句大白话解释魔术方法
有人把魔术方法叫“魔法方法”,其实就是一组以双下划线开头和结尾的普通方法。普通方法是我们定义在类里、通过实例点调用;魔术方法特殊在:它们通常不是由你的业务代码直接调,而是 Python 解释器在特定语法场景下自动去调用。
举个例子。你写len(obj),解释器其实在底层找obj.__len__();你写obj[3],触发的就是obj.__getitem__(3);你写a + b,真正执行的是a.__add__(b)或者b.__radd__(a)。这些行为从 Python 解释器的角度看,根本没有魔法,就是一套固定的协议:语法形式触发方法查找,查得到就用,查不到就报错或者说“不支持”。
理解了这个模型,很多事情就通透了。为什么字符串可以直接in判断包含关系?因为str实现了__contains__。为什么两个自定义的对象不能直接相加?因为你的类没有给解释器提供__add__这个钩子。不是“Python 不允许”,而是你没有告诉它该怎么做。
1.2 为什么命名要“双下划线”
很多新手会问,为什么非得是__xxx__,不能普通一点?这背后其实是 Python 社区在设计上的一种约定,叫“dunder”(double underscore 的缩写)。核心原因有两个:
第一个是避免误伤。Python 是一种高度动态的语言,类里可以定义大量属性、方法。如果不给这类“被解释器调用”的方法一个醒目的标识,那么你在命名普通方法时极容易误覆盖它们。比如你给对象写了个方法叫len,这没问题,不影响len(obj);但如果你为别的原因写了个__len__,就会改变对象对内置函数len()的响应。这种机制是强大且危险的,给专属命名空间本身就是一种保护。
第二个是统一协议入口。Python 的协议(比如容器协议、迭代协议)分布在不同的魔术方法上,如果名称分得五花八门,解释器查找就会困难,第三方框架想实现统一协议也无从下手。统一用双下划线包起来,一看就知道这不是普通业务方法,而是由 Python 内部调用的协议接口。
1.3 Python 3.12 在魔术方法相关机制上的新变化
Python 3.12 并不是靠新增“大量魔术方法”来刷存在的版本,它更像是在类型系统和类行为上做细致调整。这也提醒我们:魔术方法不是死的,它跟着语言版本一起演化。
几个值得关注的点和魔术方法有直接关联:
为了兼容性和稳定性,__class_getitem__依然是用来支持list[int]这类泛型语法的核心魔术方法。Python 3.9 开始支持内置类型用方括号加类型参数,Python 3.12 对泛型别名的实现细节做了更多处理,但是协议入口不变。
typing.override装饰器在 Python 3.12 正式可用,这虽然不是“魔术方法”,但它影响到了我们在子类中重写__getitem__、__iter__等协议接口时的表达方式。它让“我确实想覆盖父类的方法”这个意图变成一个显式声明,同时帮助排查拼写错误。如果你自定义的类继承自一个协议丰富的基类,这个特性在维护阶段尤其实用。
还有一个底层变化是 Python 3.12 对in操作符的优化。以前x in obj还会依赖一个比较曲折的回退逻辑,3.12 里对__contains__未实现的场景做了更一致的处理,内置容器类型踩到不同代码路径导致行为不一致的情况变少了。这种事只有在你深挖协议细节时候才会注意到,但确实让“协议查找”这件事更规范。
2. 构建对象生命周期:构造、销毁与初始化的协作
2.1__new__不只是“构造函数”的别名
如果你从别的语言转过来,很容易把 Python 的对象创建理解成“构造函数”。严格的讲,__new__才是负责“创建空白对象”的静态方法,而__init__负责“初始化这个对象”的属性。Python 里类对象的创建分两步走,cls.__new__负责创建实例,然后解释器自动调用__init__完成初始化,二者通过同一个参数集打通。
正常开发里,你大部分时间只需要覆盖__init__。但有两个场景你必须碰__new__:第一个是不变对象(比如tuple、str)的子类,因为它们在对象创建之后就不能再被初始化程序修改属性,你必须在__new__里完成属性绑定;第二个是单例模式或者池化对象,类通过__new__返回缓存好的实例,绕开重复创建的开销。
我踩过一次相关的坑:写一个表示测量结果的类,我想让它像tuple一样不可变,于是照抄某种“最佳实践”,把__new__和__init__都定义了,结果参数被重复接收,代码可读性直线下降。实际上,__init__里的操作在__new__已经完成属性设置后又会执行一次,你不得不做很多防御性判断。后来干脆把属性设置全挪到__new__,让__init__只做类型校验,就干净多了。
2.2__init__应该干什么,不该干什么
__init__的逻辑应该是“基于外部传入参数,把对象初始化到可用状态”。这里面有两个容易走火入魔的地方:
不要在__init__里去调用可能触发网络请求、连接数据库等重活的逻辑。对象创建本身应该是廉价且确定性的,如果你在初始化阶段引入副作用,调试时你会痛不欲生,因为任何一次“创建对象”都产生外部影响,你无法在测试里放心构造对象。
另一个是不要用__init__做大量的防御编程来掩盖类型设计问题。如果入参类型不对,就让它明确地报错,别试图“尽力猜测用户想传什么”,否则类接口会模糊到连你自己都记不住。
2.3__del__与资源释放的真相
__del__是“析构”入口,在对象引用计数归零、被垃圾回收时由解释器调用。很多从 Java 转来的同学会以为这是finalize,可以拿来做资源清理,但在 Python 里依赖__del__是一个危险选择:调用时机不可预测,循环引用时不一定及时执行,解释器关闭时甚至可能不执行。
真正的资源管理应该放在上下文管理器(__enter__和__exit__)里,或者用try/finally保证显式释放。对我来说,__del__最多用来做“最后手段”的辅助清理和日志输出,绝不能作为核心保障。
3. 容器协议与运算符协议:让自定义对象“像个内建类型”
3.1 实现容器:__len__、__getitem__与__iter__的配合
你想让一个自定义对象表现得像列表或字典,就要实现一套容器协议。把这几个方法拆开看,它们分工明确:
__len__给内置len()提供支持,返回元素个数,类型必须是整数。__getitem__处理下标访问obj[key],同时也要负责处理切片,因为解释器会把切片对象传进来。__iter__返回一个迭代器,让for x in obj能工作。
最容易被忽视的是:Python 里这几点并不是绝对捆绑的。你只实现了__getitem__但没实现__iter__,for循环还是能工作,因为解释器会走一个“老式迭代协议”:从下标 0 开始不断尝试__getitem__,直到捕获到IndexError。这个回退机制基本能跑,但性能和语义都不理想,尤其和in操作符配合时会莫名其妙地开辟一个临时迭代器。所以,只要你做的是正经容器,就把三件套补齐。
我做过一个内存中的小型时间序列容器,内部数据用deque存储。最开始图省事只写了__len__和__getitem__,没写__iter__。直到有次做数据归档,别人把对象交给一个第三方绘图库,库内部用for x in data遍历,每次下标重算、分段切片也都在重复包一层,性能惨不忍睹。补上__iter__后,整个数据流顺畅多了。这个经验就是:容器接口尽量一次给全,别靠解释器兜底。
还有个细节值得注意:__getitem__收到的键可能不是你以为的类型。比如用户传了1.5这种浮点数,你的实现是直接把它当成索引去list里取,就会遇到 TypeError;更隐蔽的是传一个slice对象,你要考虑是否支持。简单做法是显式做类型判断,不支持的键直接抛出TypeError,这和内建行为的语义一致。
3.2 比较协议与哈希协议:__eq__、__lt__、__hash__
比较逻辑是 Python 操作符重载里最复杂的一块,因为六个比较符(==、!=、<、>, <=, >=)并不要求你实现六个方法。基础的做法是实现__eq__和__lt__,让解释器根据“取反”和“交换操作数”来推导其余比较结果。在更实际的代码里,用functools.total_ordering修饰符挂上__eq__和一个核心比较方法,其它六个比较符会被自动推导出来,少写不少模板。
比这更关键的是哈希协议,往往被人遗忘。规则很简单:如果你覆盖了__eq__,却没有同步覆盖__hash__,Python 会把这个类的__hash__置为None,实例就变得不可哈希,放进set或作为dict的键时会直接报TypeError: unhashable type。为什么?合理哈希的前提是:相等的对象必须有相同的哈希值。你改变了相等性的定义,原先那个基于“内存地址相同才相等”的默认哈希就不安全了。如果你要自己定义__hash__,务必保证它只依赖参与比较的属性,否则会制造出“相等但哈希不同”的怪物对象,让dict出现查找失败这种极度隐秘的 bug。
我在实际项目里就碰到过一起线上事故的苗头:一个数据模型重写了__eq__用来做业务主键比较,但忘了处理哈希,结果有人试图拿它做去重集合,启动时直接崩溃。后来把__hash__基于主键字段重写,问题立刻消失。这属于“十分钟能修好,但定位可能要半天”的问题。
3.3 运算符重载:__add__与反向运算符
a + b的过程比你想的复杂一点:解释器先查a.__add__(b),如果a没实现或返回NotImplemented,就查b.__radd__(a)。很多人在自定义数值类型时只写了__add__,没写__radd__,结果obj + 5可以,5 + obj就报错了,完全不知道为什么。
这个协议设计的本意是让“运算符支持”具备对称性。比如一个向量类型,vec + scalar能处理,scalar + vec也应该被处理。如果你只实现正向,那么就必须在文档里强调“只能放在左边”,这显然不自然。夯实做法是:实现正向运算符时,如果遇到自己无法解释的操作数,返回NotImplemented而不是抛异常,把后续决定权交给右侧操作数。这是 Python 官方推荐的行为,也是我强烈建议的写法。
用NotImplemented最容易踩的坑是把它和NotImplementedError混为一谈。前者是一个单例对象,表示“本类型不处理此操作”,让解释器继续找反向方法;后者是一个异常类,表示“方法没实现”。身边真的有人用混过,还 debug 了好一阵,所以特别提醒一句。写运算重载相关的代码时,接口处返回NotImplemented,具体方法内部要做语义不支持的判断时可以抛出异常,两者用途不同。
4. 显示、调用与上下文管理:把对象嵌入语言行为
4.1__repr__与__str__:调试和显示要分开
这两个方法看起来都负责转成字符串,但语义侧重点不同。__str__面向用户,作用于str(obj)和print(obj),可以写得友好一点;__repr__面向开发者,主要出现在交互式解释器和异常消息里,要求尽量无歧义,最好能表达出“如何重建一个相等对象”的信息。如果你只实现其中一个,Python 会用__repr__兜底__str__。
我的建议是:业务对象里两个都实现,而且__repr__要遵循“精确大于好看”,能包含关键标识字段。以前我会偷懒只写一个__str__,直到在日志里看到一堆很美的字符串,却完全看不出是哪个实例,才追悔莫及。调试日志里出现的是__repr__,没有它你就没有最后的救命稻草。还有个实用的技巧:当对象字段较多时,可以在__repr__里输出类的模块名和限定名,方便跨模块定位。
4.2__call__:让实例变成可调用对象
obj()能执行,前提是你实现了__call__。这种模式最典型的应用就是函数式编程里的“带状态闭包”:类的__init__存配置,__call__执行计算。它比嵌套函数更清晰,因为状态字段一目了然,且天然支持类型标注和继承扩展。
我自己常用在“可配置策略”上,比如一个指数平滑器,构造时传入平滑系数,调用时传入最新观测值,内部维护状态。写成一个实现了__call__的对象,调用前后行为一致,而且配合functools.singledispatch还能按照参数类型做分发,比单纯闭包灵活。
4.3__enter__和__exit__:上下文管理器
with语句之所以能自动进入、退出和清理,依赖的是这两兄弟。写的时候要记住__exit__的三个参数:异常类型、异常实例和 traceback。如果方法返回True,异常会被吞掉,块内代码不会因为该异常而中断,这是一个可以用来实现“优雅降级”的钩子。
我已经数不清多少次在资源代码里手动写try/finally,后来发现只是缺一个上下文管理器。如果你的类里有一个“必须被清理”的资源,不要等外部调用者自觉,直接在__enter__后返回资源对象,然后在__exit__里做清理。这比自己写close()方法安全得多,因为即使中间抛了异常,__exit__也会被调用。
有一个使用上的偏差要纠正:有人以为with open(...) as f里的f就是文件对象本身,进而以为__enter__必须返回self。实际上返回什么由你决定,完全可以返回和资源类不同的“代理对象”。比如数据库连接器进入上下文时返回一个事务对象,退出时根据是否有异常决定 commit 还是 rollback,这是非常常见且优雅的设计。
5. 属性访问拦截与调用行为的深层设计
5.1__getattr__、__setattr__和__getattribute__的区别
很多人把这三个搞混。先理清:__getattribute__是无条件拦截所有属性访问的最底层钩子,只要对象有属性读取,就会先经过它;__getattr__是只有在正常属性查找失败时才调用的“兜底”;__setattr__负责拦截属性赋值。这个细微差别决定了你该选哪个用。
如果用__getattribute__动态拼接属性,稍有不慎就会触发无限递归,因为方法内部只要访问self.xxx就会再次进入__getattribute__,你必须通过object.__getattribute__(self, name)来绕开。这和__getattr__的递归陷阱不一样,但同样让人头疼。
我对这类钩子态度的总结是:能不用就不用,用了就一定要画清楚访问边界。适用场景其实很窄,比如为兼容旧接口做属性映射、为配置对象提供动态字段。如果你在设计业务模型时随时想着用它们“偷懒”,最后大概率会为了一个魔法钩子牺牲掉代码可读性和 IDE 补全能力。
5.2__slots__与魔术方法共存
__slots__不是魔术方法本身,但它会影响魔术方法相关的动态行为。它声明“实例只允许这些属性”,换来的是更小的内存占用和更快的属性访问,但代价是禁止动态添加新属性。如果你的类用了__slots__,同时又定义了__getattr__,要小心“不存在属性”的兜底逻辑是否和插槽设计冲突。
我自己遇到过一个实际问题:定义了__slots__的配置类,因为覆盖了__getattr__做缺省值返回,结果写错属性名时,原本应该立即暴露的拼写错误被“聪明地”掩盖了,线上跑了好久才发现逻辑分支异常。后来决定,凡是模型类,一律不用高级属性拦截,把缺省逻辑放进公有属性里,让错误快速暴露。
5.3__class__相关协议与元类进阶
__class__并不是一个需要你定义的方法,它让实例可以访问自己的类。但有一个相关魔术方法值得提:__class_getitem__,它控制系统被写成MyClass[int]时返回的内容,在泛型设计里是绕不开的。Python 3.12 对typing模块的改进更多体现在类型注解的计算上,但协议入口依然依赖这个魔术方法。
元类(__metaclass__在 Python 3 里通过metaclass参数指定)是另一个层次的话题:你可以通过控制类创建过程来自动给每个类注入魔术方法。但我的个人态度一直是:元类是一把极锋利的刀,一般业务代码不要主动掏出来。它能帮你减少重复,也能让整个团队的代码变得极其抽象、难以跟踪。
6. 实际项目里的常见陷阱与排查思路
6.1 “为什么这个对象不能放进字典”
最常见的就是重写__eq__后没有重写__hash__。表现形式为构建set或dict键时抛出unhashable type。排查思路很简单:查看该类定义里是否存在__hash__ = None,是的话就要重写。注意,dict本身也要求键的哈希在生命周期内不变,所以你的__hash__要依赖不可变字段,不能用可变字段参与哈希计算。
我补充一个容易翻车的地方:一个对象同时重写了__eq__和__hash__,但__hash__依赖了__eq__未涉及的字段,这将导致两个对象相等但哈希不同,dict里就会出现查找不到先前键的诡异现象。这种事靠翻阅代码很难发现,通常要用极小化最小复现集来定位。
6.2__getattr__无限递归
写__getattr__时,最经典的错误是方法内部访问了不存在的变量。因为访问不存在变量本身就触发__getattr__,然后又进入同一个方法,无限递归到RecursionError。比如你想返回一个默认值,写道:
def __getattr__(self, name): return self.default_value这里只要default_value不存在,就递归了。正确做法是先用self.__dict__.get(name)这类基础机制去检索,不是盲目的self.xxx。另一个安全写法是把所有次级属性读走object.__getattribute__,但这就要格外小心。我个人建议:把__getattr__内部逻辑尽量限定在访问一次性计算结果的缓存字典里,不要依赖其他动态属性。
6.3__getitem__越界行为不一致
内建列表在越界访问时抛IndexError,自定义容器如果拿assert或直接抛出别的异常,就会破坏依赖这个协议的代码逻辑。因为很多标准库和第三方库会特意捕获IndexError来终止迭代,如果你是抛ValueError,外层根本不会把它当作“遍历到末尾”的信号,从而引发循环或死循环。实现时务必严格遵循:键类型不对抛TypeError,越界或不存在抛IndexError(字典则抛KeyError)。
6.4 重载__eq__但没有返回NotImplemented
比较操作的返回应当是布尔值或者NotImplemented。如果你在__eq__里直接抛TypeError,当左操作数类型和右操作数类型不匹配时,就会中断整个比较链路,导致x == y和y == x行为不对称。正确的协议是:不认识对方的类型时返回NotImplemented,解释器会去找对方的镜像方法。这一点在实现跨类型比较时特别重要,比如让自定义类和内置数值类型做比较。
表格做一个常见问题速查:
| 症状 | 大概率原因 | 排查方向 |
|---|---|---|
| 对象不可哈希 | 重写__eq__后__hash__被置空 | 同步重写__hash__ |
for遍历性能差 | 只实现了__getitem__没有__iter__ | 补全迭代协议 |
obj + 5可以但5 + obj报错 | 缺少__radd__ | 实现反向运算符 |
with块抛异常后资源未释放 | __exit__里没做清理 | 确保异常时也执行清理 |
写__getattr__导致递归爆炸 | 方法内部访问了不存在的属性 | 用self.__dict__等基础方式取值 |
set里出现“看起来相等”的重复元素 | 哈希基于可变字段 | 用不可变字段作为哈希依据 |
__repr__输出出现...递归 | 对象自引用结构未做深度控制 | 对嵌套对象单独处理展示逻辑 |
7. 用一个综合例子串联主要魔术方法
理论知识讲再多,不如实际看一个类。下面我写一个很小的数值结果类,它综合运用了本系列第一篇里提到的核心方法,你可以直接照着敲一遍感受协议之间的协作。
from functools import total_ordering @total_ordering class Measure: def __init__(self, value: float, unit: str = ""): self.value = value self.unit = unit def __repr__(self): return f"Measure({self.value!r}, {self.unit!r})" def __str__(self): return f"{self.value} {self.unit}".strip() def __eq__(self, other): if not isinstance(other, Measure): return NotImplemented return self.value == other.value and self.unit == other.unit def __lt__(self, other): if not isinstance(other, Measure): return NotImplemented if self.unit != other.unit: raise ValueError("Different units cannot be compared directly") return self.value < other.value def __hash__(self): return hash((self.value, self.unit)) def __add__(self, other): if isinstance(other, Measure): if self.unit != other.unit: raise ValueError("Different units cannot be added directly") return Measure(self.value + other.value, self.unit) if isinstance(other, (int, float)): return Measure(self.value + other, self.unit) return NotImplemented def __radd__(self, other): return self.__add__(other) def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): pass def __call__(self, scale: float) -> "Measure": return Measure(self.value * scale, self.unit)这个类内部逻辑不复杂,但每一个方法都对应一个协议:
__eq__和__lt__配合total_ordering生成全套比较运算符。关键在于isinstance判断和NotImplemented的使用,这样Measure(3, "m") == 5不会抛错,而是返回False。哈希基于参与相等判断的两个字段,Measure(1, "m")和Measure(1.0, "m")哈希一致,也符合相等性语义。加号运算同时处理同类对象和标量,__radd__保证10 + Measure(3, "m")也能跑。__repr__提供了无歧义表示,__str__则负责给人看的输出。上下文管理器虽然这里没有真实资源,但如果你以后把这类对象塞进独立事务,直接在__exit__里补上提交或回滚逻辑就可以。__call__让它具备“按比例缩放”的可调用语义,融合数值对象和行为策略于一体。
你可以在这段代码基础上做实验:把它放进列表里排序,放进字典里做键,又或者在with块里使用。只要协议链条完整,所有内建机制都能无缝配合,这就是魔术方法设计的意义所在。
8. 初学时的三个心态建议
第一,魔术方法的效率点在于“协议完整,而不是方法齐全”。你不用把几十个魔术方法全实现一遍,只要根据对象的核心身份决定要承担什么协议。容器就做全容器协议,数值类型就做全运算协议,资源对象就实现上下文管理协议。贪多求全反而容易让代码变得负担沉重。
第二,观察解释器的实际行为比死记方法列表更重要。遇到obj不支持的语法时,别急着咒骂,用dir(obj)和hasattr去查一查它到底实现了哪些协议入口,这是最训练思维方式的做法。
第三,逐渐培养“自己解释语言行为”的能力。比如写3 * obj失败,你要立刻想到查找__mul__和__rmul__的顺序;写len(obj)失败,就要想到__len__不存在,不存在的原因要么类型不支持,要么协议未实现。这种能力一旦建立,无论 Python 升级到什么版本,你对新特性的适应速度都会快于大多数人。
作为 Python 3.12 MagicMethods 系列的开篇,这篇更重在地基梳理。后续的篇幅里,我会逐个潜入具体协议:从数值运算的完整设计,到迭代器与生成器边界,再到描述符协议在框架设计中的真实应用。你可以在动手实现自己的类时随时对照这篇里的框架,看还需要补充什么协议方法,只需要想清楚“这个对象希望被 Python 语言当作什么样的公民”即可。