本节目标:把魔术方法(dunder)理解成「语言预留的协议钩子」而非语法糖——能说清隐式调用为什么只在类型上查找、迭代协议的两条路径、
__eq__与__hash__为何必须联动,以及NotImplemented如何驱动反射运算与子类优先。
适用版本:Python 3.12+(实测 3.14.6)
1.3 魔术方法驱动的协议设计
入门篇里我们实现了 __repr__、__eq__、__iter__,让自定义对象能 print、能进 set、能被 for。但那些都是「用法」。本节要回答的是协议层面的机制:为什么 len(obj) 不会调用你塞在实例上的 __len__?为什么 a == b 有时会去问 b?为什么定义了 __eq__ 就自动不可哈希?这些问题的答案,共同构成了 Python 数据模型的核心。
一、协议的本质:隐式调用只在类型上查找
Python 的运算符和内置函数不「调用方法」,而是按协议在类型上查找 dunder。这与普通属性访问有一个决定性差别:隐式 dunder 查找会绕过实例字典。先看一个反例——把 __len__ 塞进实例,len() 依然看不见它:
class Trick:
pass
t = Trick()
t.__len__ = lambda: 999 # 塞进实例字典
print("t.__len__() =", t.__len__()) # 显式调用能工作
try:
print("len(t) =", len(t))
except TypeError as e:
print("len(t) 报错:", e)
class Trick2:
def __len__(self): return 999 # 定义在类型上
print("len(Trick2()) =", len(Trick2()))
输出:
t.__len__() = 999
len(t) 报错: object of type 'Trick' has no len()
len(Trick2()) = 999
len() 找的是 type(t).__len__,实例字典里的那份被完全忽略。这条规则的解释很实际:如果 dunder 能从实例取,攻击者就能通过控制对象内容来劫持 +、==、iter 等语义。协议必须由类型定义,才能保证一致性。这也意味着——想让对象支持某个协议,方法必须写在类上。
二、迭代协议:两条路径
for、list()、解包、in 都依赖迭代协议。它有两条入口:
路径一(首选):__iter__ 返回一个迭代器,迭代器实现 __next__,耗尽时抛 StopIteration:
class Counter:
def __init__(self, n): self.n, self.i = n, 0
def __iter__(self): return self
def __next__(self):
if self.i >= self.n:
raise StopIteration
self.i += 1
return self.i
print("list(Counter(3)):", list(Counter(3)))
路径二(回退):若对象没有 __iter__,解释器会退化为序列协议——从下标 0 开始不断调 __getitem__,直到 IndexError 终止:
class WithGetItem:
def __init__(self): self.data = [10, 20, 30]
def __getitem__(self, i):
print(f" __getitem__({i}) 被调用")
return self.data[i] # 越界自动抛 IndexError
print("list(WithGetItem()):", list(WithGetItem()))
输出:
__getitem__(0) 被调用
__getitem__(1) 被调用
__getitem__(2) 被调用
__getitem__(3) 被调用
list(WithGetItem()): [10, 20, 30]
注意第 4 次调用(下标 3)抛出的 IndexError 被解释器当作「迭代结束」的信号吞掉了。in 运算符同理:没有 __contains__ 时退化为迭代查找。只要实现 __getitem__,对象就白得迭代与 in 能力——这是 range、str 等类型至今只依赖序列协议的遗留设计。
迭代在字节码里由 GET_ITER 与 FOR_ITER 两条指令驱动:
import dis
def loop(it):
for x in it:
pass
dis.dis(loop)
输出(本机 3.14.6,节选):
LOAD_FAST_BORROW 0 (it)
GET_ITER
L1: FOR_ITER 3 (to L2)
STORE_FAST 1 (x)
JUMP_BACKWARD 5 (to L1)
L2: END_FOR
POP_ITER
GET_ITER 调用 __iter__(或回退到序列协议)拿到迭代器,FOR_ITER 每次调 __next__;StopIteration 被 FOR_ITER 捕获后跳转到 END_FOR 正常退出。
三、比较协议与 __eq__ / __hash__ 的联动
比较协议看似简单,但 __eq__ 一旦定义,解释器会顺手把 __hash__ 置为 None:
class User:
def __init__(self, uid): self.uid = uid
def __eq__(self, other):
if not isinstance(other, User):
return NotImplemented
return self.uid == other.uid
print("User.__hash__ is None :", User.__hash__ is None)
try:
hash(User(1))
except TypeError as e:
print("hash() 报错:", e)
输出:
User.__hash__ is None : True
hash() 报错: unhashable type: 'User'
这不是限制,而是保护。set/dict 依赖一条不变量:相等的对象必须有相等的哈希。自定义 __eq__ 后,默认的按 id 哈希不再与新的相等语义一致——两个「相等」的对象会落进不同哈希桶,set 的去重、dict 的查找全部失效。解释器无法替你猜出正确的 __hash__,于是选择让对象不可哈希,逼你显式表态:
class User2:
def __init__(self, uid): self.uid = uid
def __eq__(self, other):
if not isinstance(other, User2):
return NotImplemented
return self.uid == other.uid
def __hash__(self):
return hash(self.uid) # 与 __eq__ 使用同一字段
print("len({User2(1), User2(1)}) =", len({User2(1), User2(1)}))
输出:
len({User2(1), User2(1)}) = 1
关键纪律:参与 __eq__ 的字段必须参与 __hash__,且这些字段要不可变。若子类只继承 __eq__ 而不重写 __hash__,它会正常继承父类的哈希(因为 __hash__ = None 只发生在定义 __eq__ 的那个类上)——这是容易忽略的继承细节。
四、NotImplemented 与反射运算
a == b 并非只问 a。完整流程是:先试 a.__eq__(b);若返回 NotImplemented,再试反射的 b.__eq__(a):
class L:
def __eq__(self, other):
print(" L.__eq__ 被调用"); return NotImplemented
class R:
def __eq__(self, other):
print(" R.__eq__ 被调用"); return True
print("L() == R():", L() == R())
输出:
L.__eq__ 被调用
R.__eq__ 被调用
L() == R(): True
有一条子类优先规则:当右操作数是左操作数类型的子类时,Python 会先调用右操作数的反射方法,给更具体的类型一次优先权:
class Base:
def __eq__(self, other): print(" Base.__eq__"); return "base"
class Sub(Base):
def __eq__(self, other): print(" Sub.__eq__"); return "sub"
print("Base() == Sub() ->", Base() == Sub()) # Sub 是子类,先被问
print("Sub() == Base() ->", Sub() == Base())
输出:
Sub.__eq__
Base() == Sub() -> sub
Sub.__eq__
Sub() == Base() -> sub
返回 NotImplemented 与返回 False 是两回事:NotImplemented 表示「这个类型我不认识,请去问对方」;False 表示「我认识,且结果为假」。写错会让反射机制失效。
一个容易混淆的点:当双方都返回 NotImplemented,不同类型的运算结局不一样。算术运算符会抛 TypeError,而 == 会退化为身份比较(is)并返回 False,不报错:
class N1:
def __eq__(self, other): return NotImplemented
class N2:
def __eq__(self, other): return NotImplemented
print("N1() == N2() ->", N1() == N2()) # False,不抛错
class A1:
def __add__(self, other): return NotImplemented
def __radd__(self, other): return NotImplemented
class A2:
def __add__(self, other): return NotImplemented
def __radd__(self, other): return NotImplemented
try:
A1() + A2()
except TypeError as e:
print("A1() + A2() ->", type(e).__name__, ":", e)
输出:
N1() == N2() -> False
A1() + A2() -> TypeError : unsupported operand type(s) for +: 'A1' and 'A2'
五、算术协议与 BINARY_OP
算术运算符背后也是同一套反射机制。a + b 先试 a.__add__(b),返回 NotImplemented 再试 b.__radd__(a)。内置 int 对陌生类型一律返回 NotImplemented,于是自定义类型的 __radd__ 得以接管:
class Money:
def __init__(self, v): self.v = v
def __radd__(self, other):
print(" Money.__radd__ 被调用")
return Money(self.v + other)
def __repr__(self): return f"Money({self.v})"
print("1 + Money(5) ->", 1 + Money(5))
输出:
Money.__radd__ 被调用
1 + Money(5) -> Money(6)
这正是 sum() 能作用于自定义对象的原因——sum 从整数 0 起步,第一次加法就是 0 + obj,靠的正是 __radd__。在字节码层面,a + b 是单条 BINARY_OP,a == b 是 COMPARE_OP:
LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (a, b)
BINARY_OP 0 (+)
__iadd__(+=)则不同:它返回的值会回绑到左操作数名,若对象可变就返回 self 实现原地修改,否则回退到 __add__ 产生新对象。
六、上下文管理协议
with 语句由 __enter__ / __exit__ 驱动,机制上有两个要点值得从协议角度强调:
class Timer:
def __init__(self, name): self.name = name
def __enter__(self):
print(f"[enter] {self.name}"); return self
def __exit__(self, exc_type, exc, tb):
print(f"[exit] {self.name} exc={exc_type}")
return False # 返回 False -> 异常继续传播
with Timer("job") as t:
print("working:", t.name)
输出:
[enter] job
working: job
[exit] job exc=None
第一,__exit__ 无论 with 块正常结束还是抛异常都一定执行,这是资源清理的保证。第二,__exit__ 的返回值决定异常去留:返回真值吞掉异常,返回 False/None 则继续向上传播。把 __exit__ 写成 return True 却不处理异常,是最常见的静默 bug 来源。更完整的上下文管理器与 contextlib 用法见入门篇的 魔术方法与运算符重载
;本节只强调它在协议层面的位置——它和迭代、比较、算术一样,都是「实现类上的 dunder,语言就把对应语法交给你」。
若想继续看 dunder 如何被元编程批量生成与改写,可以延伸阅读站内专题 Python 元编程与动态特性 与 Python 设计模式 。
小结
- 隐式 dunder 查找只在类型上进行,绕过实例字典;协议方法必须定义在类上。
- 迭代协议有两条路径:
__iter__/__next__(首选)与__getitem__序列回退(IndexError作为终止信号);字节码由GET_ITER/FOR_ITER驱动。 - 定义
__eq__会把该类的__hash__置为None,这是为了维护「相等对象哈希相等」的不变量;要放回set必须显式实现__hash__,且与__eq__使用同一组不可变字段。 NotImplemented表示「不认识这个类型」,触发反射运算;右操作数是左操作数子类时反射方法优先。- 双方都返回
NotImplemented时,==退化为身份比较返回False,而算术运算符抛TypeError。 - 算术由
BINARY_OP、比较由COMPARE_OP承载;__exit__的返回值决定异常是否被吞掉。
到这里,对象模型这一章的三根支柱就立住了:对象是什么(1.1 的三要素与内存布局)、属性怎么找(1.2 的描述符流水线)、对象如何融入语言(1.3 的协议)。下一章 元类与 __init_subclass__
会向上再走一层——既然类型也是对象,那么我们能不能像操作普通对象一样,去定制「类的创建」这件事本身?
阅读导航:上一节:1.2 属性查找、描述符协议与 getattr · 下一节:2.1 元类与 init_subclass 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。