尧图精选

Python 3.12魔术方法全解析:从dunder机制到__buffer__协议实战

🕒 发布时间:2026/10/2 4:46:54 📁 来源:尧图网络
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 childPEP 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 fInterval({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(frozenTrue)让数据类同时生成两者并保证一致性。在 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、调试工具大量依赖这套机制那里才是水最深的地方。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →