《Python高级编程》1.3 魔术方法驱动的协议设计

本节从协议角度讲清 dunder:用实测证明隐式调用只在类型上查找、绕过实例字典,拆开迭代协议的 __iter__ 与 __getitem__ 回退、比较协议的 __eq__/__hash__ 联动、NotImplemented 触发的反射运算与子类优先规则,并用 dis 的 BINARY_OP/COMPARE_OP 把运算符还原成方法调用。

本节目标:把魔术方法(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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时