处理多个相互作用的 type 很容易变得混乱。 设想一个 parse 并执行 mathematical expression 的 system。 你希望能表达 Number + Number、Number * Number 等操作, 其中 Number 是一组 numerical object 的 base class。 但当你写 a + b, 而且既不知道 a 的 exact type,也不知道 b 的 exact type 时, 怎样才能让二者正确交互?

答案要从一个平时很少被提及的 language fact 开始。 Python 一次只对一个 type 做 dispatch。 也就是说,如果一次 operation 涉及多个 type 未知的 object, Python 的 dynamic binding mechanism 一次只能作用于其中一个 type。 剩余 type 必须由你手工检查, 相当于把 language 已经为第一个 type 做的 dispatch 自己再写一遍。

解决办法是 Multiple Dispatching。 广义上,polymorphism 指 function 能接受不止一种 type 的 argument (参见 Rethinking Objects)。 它有三种形式。 C++ 的 function overloading 根据 argument type 选择 function。 Generics 用一个 function body 处理多种 type。 本章使用的形式,是 inheritance 提供的 runtime dispatch: 它根据一个 object 的 type resolve,也就是接收 method call 的那个 object。 这正是为什么一次 method call 只能 resolve 一个 unknown type。

如果要对两个 unknown type 做 dispatch,就需要两次 method call。 第一次 resolve 第一个 type,第二次 resolve 第二个 type。 下面的 example 通过名为 compete() 与 eval_*() 的 method 完成两次 dispatch, 而两个 interacting object 都来自同一个 hierarchy。 两个 unknown type 意味着两次 dispatch,这就是 double dispatching。 如果相互作用的是两个不同 type hierarchy, 则需要分别对每个 hierarchy 发起一次 dispatching method call。

下面两个 version 共用一个 result type:名为 Outcome 的 enumeration。 它只有 WIN、LOSE、DRAW 三种结果。 Outcome 是 StrEnum, 所以每个 member 本身就是它的 string value, print 时会显示 win、lose、draw:

# outcome.py
# The result of one Item competing with another.
from enum import StrEnum

class Outcome(StrEnum):
    WIN = "win"
    LOSE = "lose"
    DRAW = "draw"

还需要两个很小的 helper function: 一个生成随机 item pair,另一个让 pair 对战并打印 result:

# arena.py
import random
from collections.abc import Iterator
from typing import Any

def item_pair_gen[T](base: type[T],
                     n: int) -> Iterator[tuple[T, T]]:
    items = base.__subclasses__()
    for _ in range(n):
        yield random.choice(items)(), random.choice(items)()

def duel(item1: Any, item2: Any) -> None:
    print(f"{item1} <-->{item2} :{item1.compete(item2)}")

item_pair_gen() 对传给它的任意 base class 都是 generic 的, 而 __subclasses__() 会列出该 base 的 direct subclass; 这是 Factory 使用过的 registry-free enumeration。 duel() 只能使用 Any, 因为下面两个 version 定义了彼此独立的 Item hierarchy, 而这个 file 必须同时服务两者。

下面是 Multiple Dispatching 真正运行起来的样子:

# paper_scissors_rock.py
import random
from typing import Any
from arena import duel, item_pair_gen
from outcome import Outcome

class Item:
    def __str__(self) -> str:
        return type(self).__name__

class Paper(Item):
    def compete(self, item: Any) -> Outcome:
        # First dispatch: self was Paper
        return item.eval_paper(self)
    def eval_paper(self, item: Any) -> Outcome:
        # Second dispatch: caller was Paper, and it draws
        return Outcome.DRAW
    def eval_scissors(self, item: Any) -> Outcome:
        # Second dispatch: caller was Scissors, and it wins
        return Outcome.WIN
    def eval_rock(self, item: Any) -> Outcome:
        # Second dispatch: caller was Rock, and it loses
        return Outcome.LOSE

class Scissors(Item):
    def compete(self, item: Any) -> Outcome:
        # First dispatch: self was Scissors
        return item.eval_scissors(self)
    def eval_paper(self, item: Any) -> Outcome:
        # Second dispatch: caller was Paper, and it loses
        return Outcome.LOSE
    def eval_scissors(self, item: Any) -> Outcome:
        # Second dispatch: caller was Scissors, and it draws
        return Outcome.DRAW
    def eval_rock(self, item: Any) -> Outcome:
        # Second dispatch: caller was Rock, and it wins
        return Outcome.WIN

class Rock(Item):
    def compete(self, item: Any) -> Outcome:
        # First dispatch: self was Rock
        return item.eval_rock(self)
    def eval_paper(self, item: Any) -> Outcome:
        # Second dispatch: caller was Paper, and it wins
        return Outcome.WIN
    def eval_scissors(self, item: Any) -> Outcome:
        # Second dispatch: caller was Scissors, and it loses
        return Outcome.LOSE
    def eval_rock(self, item: Any) -> Outcome:
        # Second dispatch: caller was Rock, and it draws
        return Outcome.DRAW

if __name__ == "__main__":
    random.seed(47)  # Reproducible pairs
    for item1, item2 in item_pair_gen(Item, 10):
        duel(item1, item2)
#: Scissors <--> Paper : win
#: Scissors <--> Rock : lose
#: Scissors <--> Rock : lose
#: Scissors <--> Scissors : draw
#: Rock <--> Scissors : win
#: Scissors <--> Rock : lose
#: Paper <--> Scissors : lose
#: Rock <--> Paper : lose
#: Paper <--> Paper : draw
#: Scissors <--> Scissors : draw

Scissors.compete(paper) 会调用 item.eval_scissors(self);第二次 call 中 self 与 item 交换位置,于是 execution 进入 Paper.eval_scissors(),而不是停留在 Scissors 自己的代码里

Scissors.compete(paper) 会调用 item.eval_scissors(self);第二次 call 中 self 与 item 交换位置,于是 execution 进入 Paper.eval_scissors(),而不是停留在 Scissors 自己的代码里

Scissors.compete(paper) 会调用 item.eval_scissors(self);第二次 call 中 self 与 item 交换位置,于是 execution 进入 Paper.eval_scissors(),而不是停留在 Scissors 自己的代码里

跟着一次 duel 走一遍,才能把 perspective 理清。 scissors.compete(paper) 先把 self resolve 成 Scissors,这是第一次 dispatch, 然后调用 paper.eval_scissors(...)。 这个 call 是第二次 dispatch:它 resolve paper, 最终到达 Paper.eval_scissors(), 也就是唯一同时知道两种 type 的那个 method。 它返回的是谁的 result? Paper.eval_scissors() 返回 WIN, 这个 outcome 是对最初发起 duel 的 scissors 来说的, 不是对当前正在执行代码的 Paper 来说:scissors 会剪 paper。 每个 eval_*() method 都是站在 original caller 的视角回答, 也就是 method name 中出现的那个 object type。 如果误读这个 convention,整个 class 里的 result 看起来都会刚好反过来。 每个 eval_*() method 还会接收一个 item argument,也就是 original caller: 正是 compete() 调用前作为 self 持有的同一个 object。 这个 game 不使用它,因为 outcome 只取决于两种 type。 更丰富的 game 可以通过它读取 caller 自己的 state。

这些 Any annotation 放弃了 static checking。 Item 既没有声明 compete(),也没有任何 eval_*() method, 所以除非写一个明确列出全部四个 method 的 Protocol,否则只能用 Any。 使用 Any 后,type checker 无法告诉你某个 class 是否漏掉九种答案中的一种。 这个 gap 只有在某场 duel 第一次真正需要 missing method 时, 以 AttributeError 暴露出来。 如果写一个列出四个 method 的 Protocol,可以恢复 checking, 代价是 declaration 会重复每个 class 的 method name。 table version 不需要这些。 它把答案表示成 data,而不是 method, 因此没有哪个 class 会“忘记实现某个 method”。 Item 只声明自己的一个 method compete(), 所以 opponent parameter 可以用 Item,不必用 Any。

每种 Item type 都编码了自己参与的组合答案。 这些答案合在一起,就是一张被分散到多个 class 中的 table。 如果预计会修改 behavior 或增加新 Item class, 这种 table 很难维护。 更合理的办法,是直接把 table 显式写出来:

# paper_scissors_rock_table.py
import random
from typing import Final
from arena import duel, item_pair_gen
from outcome import Outcome

class Item:
    def compete(self, item: Item) -> Outcome:
        # Use a tuple of types to index into the table:
        return OUTCOME[type(self), type(item)]
    def __str__(self) -> str:
        return type(self).__name__

class Paper(Item):
    pass
class Scissors(Item):
    pass
class Rock(Item):
    pass

OUTCOME: Final[
    dict[tuple[type[Item], type[Item]], Outcome]] = {
    (Paper, Rock): Outcome.WIN,
    (Paper, Scissors): Outcome.LOSE,
    (Paper, Paper): Outcome.DRAW,
    (Scissors, Paper): Outcome.WIN,
    (Scissors, Rock): Outcome.LOSE,
    (Scissors, Scissors): Outcome.DRAW,
    (Rock, Scissors): Outcome.WIN,
    (Rock, Paper): Outcome.LOSE,
    (Rock, Rock): Outcome.DRAW,
}

if __name__ == "__main__":
    random.seed(47)  # Reproducible pairs
    for item1, item2 in item_pair_gen(Item, 10):
        duel(item1, item2)
#: Scissors <--> Paper : win
#: Scissors <--> Rock : lose
#: Scissors <--> Rock : lose
#: Scissors <--> Scissors : draw
#: Rock <--> Scissors : win
#: Scissors <--> Rock : lose
#: Paper <--> Scissors : lose
#: Rock <--> Paper : lose
#: Paper <--> Paper : draw
#: Scissors <--> Scissors : draw

Dictionary key 非常灵活。 tuple 与单个 object 一样,都可以作为 key。 这种 lookup 继承了 table-driven state machine 的两个性质。 首先,lookup 精确匹配 class, 所以 Paper subclass 不会命中 Paper 的 row。 其次,missing pair 会在第一次发生对应 duel 时抛 KeyError, 这种 fail-fast policy 很适合仍在构建的 table。 练习 1 添加 Lizard 时就会遇到这个情况。