1. 双下划线方法的调用机制:为什么 Python 要把这些“钩子”藏起来
1.1 什么是魔术方法,为什么叫 dunder
升级到 Python 3.12 之后,我给团队做了一次内部代码审查。有个同事写了一个自定义集合类,他实现了__getitem__,但方法名漏了一个下划线,写成了_getitem__,结果for i in obj直接抛 TypeError。这种问题太常见了。Python 里这一类名字前后各带两个下划线的方法,官方叫 special method,社区一般叫 magic method 或者 dunder method。dunder 就是 double underscore 的缩写,你下次看到__len__、__getitem__、__enter__这类名字,知道它们对解释器有特殊含义就行了。
魔术方法本质上是一组“协议钩子”。Python 不会主动调用它们,而是在特定语法出现时由解释器触发。比如你写len(x),解释器内部会去找type(x).__len__;你写x + y,解释器会尝试type(x).__add__,失败再找type(y).__radd__。理解了这一点,你就能明白为什么打印一个自定义对象会得到<__main__.Demo object at 0x...>,而实现了__repr__之后,打印结果立刻变得人类可读。
这里有个很容易被忽略的关键点:特殊方法是在类型上查找的,不是在实例上查找的。解释器查的是type(obj)对应的方法,而不是先看obj.__dict__。这意味着你把一个函数赋值给某个实例的__len__属性,len(obj)依然不会调用它。这是 CPython 为了性能刻意做的设计,也是很多从动态语言转过来的朋友最容易踩的坑。
class Demo: def __init__(self, items): self.items = items def __len__(self): return len(self.items) d = Demo([1, 2, 3]) d.__len__ = lambda: 99 # 实例属性,干扰不了 type 上的查找 print(len(d)) # 输出 31.2 一个类有了哪些魔术方法,就“长成”什么样
你可以把魔术方法理解成一组开关:实现了就获得某种能力,不实现就退回默认行为。比如实现了__getitem__和__len__,你的对象就可以切片、可以for遍历(走旧的序列协议);实现了__eq__,对象就能用==比较;实现了__enter__和__exit__,对象就能配合with语句。一个类是否“自然”,往往就看它有没有把这组协议补齐。
我常用一个判断标准:一个类应该让用户不翻源码也能凭直觉使用。列表支持len()、下标、切片、遍历、in判断、拼接,这是 Python 容器给用户的“心理契约”。如果你的自定义类表示的是一个集合,却不实现__contains__,那用户只能用if value in obj.items,这不仅啰嗦,还暴露了内部数据结构,以后你想把items从 list 换成 set 都得掂量一下。
1.3 3.11 与 3.12 中迭代和解包的路径变化
顺带提一个 3.12 下值得注意的性能细节。CPython 3.11 开始优化了序列解包路径:当你写a, b, c = obj时,解释器会先看对象有没有同时实现__iter__和__len__,如果有,就走一条更快的路径,不再逐个调用obj[0]、obj[1]并依赖 IndexError 停止。3.12 继续沿用这个优化。
这带来一个行为上的微妙差别:同时实现__iter__和__len__,解包会使用__iter__给出的元素个数;只实现了__getitem__,则走老的序号取值协议,下标从 0 开始递增。如果你打算实现一个容器类,我建议优先用__iter__表达遍历逻辑,用__len__表达长度,而不是完全依赖__getitem__去覆盖这两个语义。后面实战部分我会用代码演示这个区别。
2. Python 3.12 新增的buffer协议:让纯 Python 类也能成为“缓冲区”
2.1 缓冲区协议的历史问题
如果说__iter__、__getitem__这些是开发者天天见的,那__buffer__就是 Python 3.12 真正的新面孔。它的背景要从 CPython 的 buffer protocol 说起。
长期以来,内存缓冲区协议只有 C 层 API(也就是Py_buffer)。numpy、PIL、OpenCV 这些库之间交换原始内存,靠的是 C 扩展互相调用,Python 层用户能感知到的就是memoryview(obj)可以用在很多地方。问题在于:你想在纯 Python 代码里实现一个“可以导出缓冲区”的对象,在 3.12 之前没有标准途径。你只能写 C 扩展,或者借助ctypes、array之类已有的缓冲提供者绕一圈。这让很多希望把自己的数据结构暴露给 NumPy 等库的开发者非常难受。
PEP 688 就是为了解决这个问题,它把缓冲区协议下沉到了 Python 层,规定类只要实现__buffer__和可选的__release_buffer__,就能成为 buffer 协议的提供者,可以直接交给memoryview()、bytes()、struct等消费方。
2.2 在纯 Python 中实现buffer和release_buffer
来看一个简化但完整的例子。我写了一个AudioBuffer类,它内部用array("h")保存 16 位采样数据,现在希望它支持memoryview()。
from array import array class AudioBuffer: def __init__(self, samples): self._samples = array("h", samples) self._exported = 0 def __buffer__(self, flags): if self._exported > 0: raise BufferError("缓冲区正在导出中,不能再次导出") self._exported += 1 return memoryview(self._samples) def __release_buffer__(self, buffer): self._exported -= 1 buf = AudioBuffer([100, 200, 300, 400]) mv = memoryview(buf) print(mv.tolist()) # [100, 200, 300, 400] print(mv.format) # 'h' del mv # 触发 __release_buffer__这里有三个要点。第一,__buffer__必须返回一个memoryview对象,或者实现了 buffer 接口的对象,不能随便返回一个普通 list。第二,flags参数是消费方传入的选择项,比如只读或可写,你的实现可以选择忽略,但必须接受这个参数。第三,__release_buffer__是用来做资源释放的,当最后一个 memoryview 被释放时会调用它。如果你不用它做任何清理,也必须定义?其实可以不定义,但一旦定义了__buffer__而没定义__release_buffer__,某些严格的消费者可能行为异常。
2.3 这个协议对普通项目有什么价值
有人可能觉得,我又不写 numpy 扩展,这个协议跟我没关系。但实际上,它意味着你在 Python 层就能实现“数据导出”的能力。比如你写了一个自定义的压缩帧格式,内部数据是bytearray,你不希望外部直接改内部状态,又想提供读取接口,就可以手动实现__buffer__并配合只读标志。或者你想做一个内存池,用__release_buffer__精确追踪引用计数何时归零,这在 3.12 之前只能在 C 层做。
我在一个数据采集的小项目里,就用这个协议让自定义的环形缓冲类直接支持memoryview切片,省掉了中间复制。数据从硬件驱动出来,先写进环形缓冲,接着被下游算法直接以 memoryview 方式读取,性能比依次拷贝到 list 好了不少。当然,这个协议也有使用边界:它导出的必须是连续内存块,如果你的数据结构是链表式的,就不适合。
3. 3.12 里和魔术方法联动的语法变化:泛型、类型参数与重写标注
3.1 PEP 695 引入了type_params运行时属性
Python 3.12 在语法层面引入了class Box[T]这种简洁的泛型定义方式。对魔术方法而言,最容易感知到的新东西是运行时的__type_params__属性。
class Box[T]: def __init__(self, value: T): self.value = value def get(self) -> T: return self.value print(Box.__type_params__) # (~T,)Box[int](42)这种写法在 3.12 里是合法的,解释器会为泛型类生成隐式的__class_getitem__,让Box[int]在运行时也能返回一个可用的类对象。在 PEP 695 之前,你需要自己定义__class_getitem__或者依赖typing.Generic[T],现在语法和运行时行为统一了。
3.2class_getitem的职责边界
__class_getitem__不是 3.12 才有的,它从 3.7 就开始支持,但很多混乱也由此而来。它是在MyClass[int]时被调用的类方法,返回值通常是一个泛型别名。很多人把它和实例层面的__getitem__混为一谈:一个是给类型加下标,一个是给实例加下标。3.12 中 PEP 695 让普通类也能通过class Box[T]自动获得这一能力,但如果你在做框架类开发,需要定制返回自己定义的泛型别名时,还是可以手动实现__class_getitem__。
class Registry: _items = {} def __class_getitem__(cls, key): return cls._items.setdefault(key, []) Registry["users"].append("tom") Registry["users"].append("jerry") print(Registry["users"]) # ['tom', 'jerry']这个例子看起来像实例访问,但其实用的是类下标,框架开发里常用于“按类型注册组件”。
3.3 override 与 f-string 语法:虽然不是 dunder,但直接影响重写体验
PEP 698 给typing模块新增了override装饰器。它不是魔术方法,但它是给魔术方法配套的警告机制。子类重写父类方法时加上@override,静态类型检查器就能在你删掉或重命名父类方法后提醒你这里断了关系。
from typing import override class Base: def process(self): return "base" class Child(Base): @override def process(self): # 正常,父类有这个方法 return "child"PEP 701 则重写了 f-string 语法。3.12 之前,f-string 内部不能复用外部同类型引号,所以f"{d["name"]}"会语法报错,需要绕路。3.12 之后表达式部分变成了完整的多行表达式,支持反斜杠、支持嵌套同类型引号。这对__format__场景很有意义:你现在可以直接在格式字符串里写出更自然的数据访问表达式,不需要再用变量中转。下面这种写法在 3.12 下是合法的:
d = {"name": "Python", "version": "3.12"} print(f"版本是 {d["version"] if "version" in d else "unknown"}")4. 常用魔术方法全景:分门别类看清每组协议
4.1 生命周期:new、init、del
__new__是真正的“构造”入口,它负责创建实例;__init__只是初始化。对于不可变对象或者元类编程,你需要重写__new__。注意,__new__是一个 staticmethod,解释器在对象尚未创建时就调用了它。__del__则容易给人错觉,它不是 C++ 析构函数,不保证在 del 语句执行时立刻调用,它只会在对象引用计数归零时被触发。在 3.12 的循环引用回收机制下,依赖__del__做资源清理必须格外小心,我后面单独说这个坑。
4.2 容器协议:getitem、setitem、iter、len、contains
容器是自定义类最常实现的一组协议。__getitem__支持obj[key],__setitem__支持obj[key] = value,__contains__支持in判断,__iter__返回迭代器,__len__返回长度。它们的组合决定了一个对象“像不像”内置容器。
一个常见的需求是让自定义集合支持切片。如果你直接让self._items[start:stop]返回内置 list,就会丢失自定义类型信息,用户拿到的不是你的集合类。正确做法是让切片返回同样的自定义类型。
from collections.abc import Sequence class LogList(Sequence): def __init__(self, items): self._items = list(items) def __getitem__(self, index): if isinstance(index, slice): return LogList(self._items[index]) return self._items[index] def __len__(self): return len(self._items)继承Sequence的好处是,__iter__、__contains__、index、count都有了基于__getitem__和__len__的默认实现,你不用把所有方法都手写一遍。
4.3 比较、哈希与排序:eq、lt、hash
实现了__eq__的类,==才能有意义;实现了__lt__的类,排序算法才能比较大小。这里有一个 Python 独有的机制:functools.total_ordering装饰器可以让你只写__eq__和__lt__,其他比较运算符由它补全。但我的建议是,生产代码里尽量手写需要的比较方法,不要迷信装饰器,因为total_ordering生成的比较逻辑会增加一次额外函数调用,而且语义不一定是你想要的。
__hash__更值得注意:一个类如果定义了__eq__而没有定义__hash__,解释器会把__hash__设为 None,对象就不可哈希了。因为哈希表和相等性必须保持一致:相等的对象必须有相等的哈希值。如果你想让对象作为字典键、放进 set,就必须同时实现两者。一个容易忽略的细节是,__hash__返回值必须是 int,且正常实现里要用不可变内容计算,否则放进字典之后内容变了,就再也取不出来了。
4.4 算术运算与反射运算:add、radd、iadd
算术魔术方法构成了一套完整的“运算符协议”。a + b会先调type(a).__add__,如果返回 NotImplemented,再调type(b).__radd__。这里的 NotImplemented 是一个单例,不是异常,它表示“我处理不了这个操作”,让 Python 去尝试对方的反射方法。
__iadd__对应+=的就地更新。列表的+=是原地扩展,元组的+=会生成新对象,就是因为它们对__iadd__的处理不同。自定义类要不要实现__iadd__,取决于你是否希望obj += x修改原对象。如果你不实现,解释器会退化成obj = obj + x,语义上会新建对象。
class Point: def __init__(self, x, y): self.x = x self.y = y def __add__(self, other): if isinstance(other, Point): return Point(self.x + other.x, self.y + other.y) return NotImplemented def __radd__(self, other): return self.__add__(other) def __iadd__(self, other): if isinstance(other, Point): self.x += other.x self.y += other.y return self return NotImplemented4.5 上下文管理:enter、exit
实现这两个方法,你的对象就能进入with块。__enter__的返回值会成为as后面的变量,__exit__接收三个异常信息参数。注意__exit__返回 True 会吞掉异常,返回 False 或 None 则继续抛。我习惯让__exit__尽量只做清理和返回 False,这样可以保持异常的原始传递。
class Transaction: def __init__(self, conn): self.conn = conn def __enter__(self): print("开启事务") return self def __exit__(self, exc_type, exc_val, exc_tb): if exc_type is None: print("提交") else: print("回滚") return False5. 实战:用魔术方法搭建一个行为自然的 Interval 类
5.1 需求与协议选择
我准备实现一个整数区间类Interval,它要支持以下操作:两个区间可以用&求交集,可以用==比较相等,可以for遍历,可以in判断某个数是否在区间内,可以len()得到区间长度,还可以作为字典键。这些需求分别对应__and__、__eq__/__hash__、__iter__、__contains__、__len__。
在设计上有一个选择:让__iter__遍历区间内所有整数,意味着len(Interval(1, 100_000_000))会返回一个巨大的数字,但遍历它就不太明智了。不过作为教学案例,我们保留这个直观语义,你可以根据实际场景改成不产生展开的迭代器。
5.2 实现代码
class Interval: def __init__(self, start, end): if end < start: raise ValueError("end 不能小于 start") self.start = start self.end = end def __iter__(self): return iter(range(self.start, self.end + 1)) def __contains__(self, value): return self.start <= value <= self.end def __len__(self): return self.end - self.start + 1 def __and__(self, other): if not isinstance(other, Interval): return NotImplemented new_start = max(self.start, other.start) new_end = min(self.end, other.end) if new_end < new_start: return None return Interval(new_start, new_end) def __eq__(self, other): if not isinstance(other, Interval): return NotImplemented return (self.start, self.end) == (other.start, other.end) def __hash__(self): return hash((self.start, self.end)) def __repr__(self): return f"Interval({self.start}, {self.end})"注意几个细节。__and__在对方不是 Interval 时返回 NotImplemented,而不是直接 return None,这样可以让 Python 尝试对方的反向操作。__eq__同理,返回 NotImplemented 而不是 False,是为了让Interval(1, 3) == "abc"这样的比较能有机会交给字符串那边处理,语义上更严谨。__hash__用了元组,因为元组是不可变且可哈希的,这能保证哈希值与相等性的实现保持一致。
5.3 验证与联动效果
a = Interval(1, 5) b = Interval(3, 8) c = Interval(1, 5) print(len(a)) # 5 print(4 in a) # True print(6 in a) # False print(list(a)) # [1, 2, 3, 4, 5] print(a & b) # Interval(3, 5) print(a == c) # True print({a: "left", b: "right"}) # 可以作为字典键当这些方法组合起来,你会发现自定义类型和内置类型的使用体验几乎一致。4 in a走的是__contains__,不会展开整个迭代器;list(a)走的是__iter__;len(a)不会真的把元素全部数一遍,而是直接走__len__。也就是说,不同方法服务不同的语法,各司其职,不要指望一个__iter__解决所有问题。
6. 我在实际项目里踩过的几个魔术方法相关的坑
6.1 只定义eq导致对象不可哈希
这个坑出现的频率非常高。我最早写一个配置项类,只实现了__eq__用来做断言比较,结果把它塞进 set 去重时,解释器直接抛TypeError: unhashable type。当时的反应是“我没动哈希,怎么就不行了”,后来查文档才知道规则是:类定义了__eq__且没有定义__hash__,__hash__会被置为 None。解决方案是手动补上__hash__,或者用dataclass(frozen=True)让数据类同时生成两者并保证一致性。在 3.12 的项目里,我推荐后者,因为声明式代码比手写样板好维护,但要注意 dataclass 生成的__eq__会把所有字段按元组顺序比较,字段顺序变了,相等语义也会变。
6.2 NotImplemented 和 NotImplementedError 语义完全不同
这个问题是我审代码时经常要纠正的:NotImplemented是单例对象,用来告诉解释器“这个操作我搞不定,你去试另一侧”;NotImplementedError是异常,用来告诉调用者“这个方法在当前场景没有实现”。把两者搞混的后果很具体:如果你在__add__里写了一行raise NotImplementedError,那a + b直接抛异常,而不是尝试b.__radd__。这在运算符重载里是致命的,因为反向操作完全没有机会执行。正确写法永远是return NotImplemented。
6.3 迭代器与可迭代对象混淆
__iter__方法在 3.12 里既可以被可迭代对象实现,也可以被迭代器实现。如果是可迭代对象,__iter__应该返回一个新的迭代器;如果是迭代器,__iter__通常返回 self。一个典型错误是,在自定义集合的__iter__里直接return self,但类的__next__没实现,结果for循环一执行就报 “object is not an iterator”。我给新手同学的建议是:把“定义可迭代容器”和“定义迭代器本身”分开,容器的__iter__里return iter(self._items),永远不要把容器类本身伪装成迭代器,除非你有明确的状态推进逻辑。
6.4del不适合做关键性清理
早期我写过__del__里关闭数据库连接的代码,结果程序退出时偶发连接泄漏。原因是循环引用或者异常路径会导致__del__的调用时机不稳定,甚至不被调用。Python 3.12 的循环垃圾回收器仍然不会对带有__del__的对象进行循环回收(会移到 gc.garbage),依赖这个方法来管理外设资源风险很高。正确的做法是显式提供close()方法,同时支持上下文管理器协议,把真正的清理逻辑放在__exit__里。__del__可以留作最后兜底,但千万别把它当成唯一的释放通道。
6.5slots与继承的配合
如果定义了__slots__,类的实例没有__dict__,内存占用确实更小。但如果你在子类里忘记定义__slots__,子类实例又会重新获得__dict__,前面的内存优化瞬间失效。3.12 下这个行为没有本质变化。另外一个隐藏点:用__slots__的类如果也实现了__weakref__,需要显式把它加入 slots,否则实例不支持弱引用。很多框架会偷偷用弱引用做缓存,你的对象突然报错 “cannot create weak reference to” 就是这个原因。
这个系列既然叫01 - 简介,我本期的目标就是把魔术方法在 Python 3.12 里的整体面貌梳理清楚。从查找机制到新增的__buffer__协议,再到实战里的协议组合,希望你能形成一套自己的判断逻辑:遇到一个语法行为不符合预期,先想想它背后对应哪个 dunder 方法,再想想那个方法的返回值应该是什么。下一期我打算从__getattr__、__setattr__、__getattribute__这套属性访问协议深入展开,因为动态框架、ORM、调试工具大量依赖这套机制,那里才是水最深的地方。