从“类型体操”到工程设计:用 Python 解释协变、逆变与不变

从"类型体操"到工程设计:用 Python 解释协变、逆变与不变

在 Python 里,很多人第一次听到"协变、逆变、不变"时,都会本能地皱眉:这是不是又是一套只存在于类型系统里的抽象概念?平时写业务代码、做 Web 后端、数据处理、自动化脚本,真的需要懂这些吗?

我的答案是:如果你只是写几十行脚本,可以暂时不懂;但如果你在设计事件处理器、回调函数、SDK、框架接口、插件系统、只读集合接口,那么你迟早会碰到它。

协变、逆变、不变,本质上不是"类型体操",而是在回答一个非常朴素的工程问题:

DogAnimal 的子类时,Container[Dog] 能不能被当成 Container[Animal] 使用?

这个问题一旦放进实际工程,就会变得非常重要。因为它关系到 API 是否安全、扩展性是否好、类型检查器是否能帮你提前发现 bug。


一、先建立直觉:子类型关系不一定会自动传递到容器上

假设我们有这样的类层次:

python 复制代码
class Event:
    pass

class MouseEvent(Event):
    def click_position(self) -> tuple[int, int]:
        return (100, 200)

class KeyboardEvent(Event):
    def key(self) -> str:
        return "Enter"

很明显:

python 复制代码
MouseEvent 是 Event 的子类型
KeyboardEvent 是 Event 的子类型

那么问题来了:

python 复制代码
list[MouseEvent] 是 list[Event] 的子类型吗?

很多初学者会觉得:"当然是啊,MouseEvent 都是 Event,MouseEvent 列表不就是 Event 列表吗?"

但答案是:不是。

为什么?

看下面这个例子:

python 复制代码
def append_keyboard_event(events: list[Event]) -> None:
    events.append(KeyboardEvent())

mouse_events: list[MouseEvent] = [MouseEvent()]

append_keyboard_event(mouse_events)  # 假设允许这样做

如果 list[MouseEvent] 可以传给 list[Event],那么 append_keyboard_event() 就能往这个列表里塞入一个 KeyboardEvent

这样一来,mouse_events 这个原本应该只包含 MouseEvent 的列表,里面就混进了 KeyboardEvent

后面如果代码这样写:

python 复制代码
for event in mouse_events:
    print(event.click_position())

遇到 KeyboardEvent 时就会出错,因为 KeyboardEvent 没有 click_position() 方法。

这就是为什么 Python 的 list[T] 在类型系统里通常是不变的


二、三个概念一句话讲清楚

我们先给出最核心的定义。

假设 MouseEventEvent 的子类:

python 复制代码
MouseEvent <: Event

那么对于一个泛型类型 Box[T]

1. 协变:子类型关系保持方向

如果:

python 复制代码
MouseEvent <: Event

并且可以推出:

python 复制代码
Box[MouseEvent] <: Box[Event]

那么 Box[T]T协变的。

直觉:只读、只产出 T 的接口,通常可以协变。

比如:

python 复制代码
Sequence[MouseEvent] 可以当成 Sequence[Event]

因为你只能从里面读出元素,不能随便往里面塞新的 KeyboardEvent


2. 逆变:子类型关系反过来

如果:

python 复制代码
MouseEvent <: Event

但可以推出:

python 复制代码
Handler[Event] <: Handler[MouseEvent]

那么 Handler[T]T逆变的。

直觉:只消费 T 的接口,通常可以逆变。

比如一个能处理所有 Event 的处理器,当然也能处理 MouseEvent

python 复制代码
from collections.abc import Callable

def handle_any_event(event: Event) -> None:
    print("handle event")

def register_mouse_handler(
    handler: Callable[[MouseEvent], None]
) -> None:
    handler(MouseEvent())

register_mouse_handler(handle_any_event)  # 合理

这里 register_mouse_handler() 需要的是"能处理 MouseEvent 的函数"。

handle_any_event() 能处理任何 Event,当然也能处理 MouseEvent,所以它可以传进去。

这就是函数参数位置的逆变。


3. 不变:子类型关系不传递

如果:

python 复制代码
MouseEvent <: Event

但:

python 复制代码
Box[MouseEvent] 不是 Box[Event]
Box[Event] 也不是 Box[MouseEvent]

那么 Box[T]T不变的。

直觉:既读又写的可变容器,通常是不变的。

典型例子就是:

python 复制代码
list[T]
dict[K, V]
set[T]

它们都可以修改内容,因此不能轻易协变。


三、协变:设计"只读集合接口"时最常见

协变最适合出现在"生产者"或"只读视图"里。

比如你在设计一个事件仓库,只允许外部读取事件,不允许修改内部集合:

python 复制代码
from typing import Generic, TypeVar

T_co = TypeVar("T_co", covariant=True)

class ReadOnlyEventStore(Generic[T_co]):
    def __init__(self, events: list[T_co]) -> None:
        self._events = events

    def get_all(self) -> tuple[T_co, ...]:
        return tuple(self._events)

    def first(self) -> T_co:
        return self._events[0]

这里 T_co 是协变的,因为它只出现在返回值位置。

现在我们可以这样使用:

python 复制代码
def print_events(store: ReadOnlyEventStore[Event]) -> None:
    for event in store.get_all():
        print(type(event).__name__)

mouse_store = ReadOnlyEventStore([MouseEvent(), MouseEvent()])

print_events(mouse_store)  # 类型上合理

为什么合理?

因为 print_events() 只需要读取 Event。而 mouse_store 里读出来的都是 MouseEvent,每一个 MouseEvent 都是 Event,所以安全。

关键在于:只读意味着外部不能往里面塞错误类型的对象。

如果我们给 ReadOnlyEventStore 添加一个写方法,就会破坏协变:

python 复制代码
class BadStore(Generic[T_co]):
    def add(self, event: T_co) -> None:
        ...

这在类型检查器那里通常会被认为不安全,因为协变类型变量不能随便出现在参数位置。

工程经验是:

当你想让 Repository[SubType] 可以安全地传给需要 Repository[BaseType] 的地方时,请优先设计只读接口。

例如,比起直接暴露:

python 复制代码
list[MouseEvent]

更好的公共接口通常是:

python 复制代码
Sequence[MouseEvent]
Iterable[MouseEvent]
tuple[MouseEvent, ...]

因为它们表达的是"我给你数据,但你不能改我的内部状态"。


四、逆变:回调函数和事件处理器的关键

逆变最容易让人困惑,但它在回调设计里非常自然。

假设你在写一个 UI 框架,允许用户注册鼠标事件处理器:

python 复制代码
from collections.abc import Callable

MouseHandler = Callable[[MouseEvent], None]

def register_mouse_handler(handler: MouseHandler) -> None:
    event = MouseEvent()
    handler(event)

调用方可以传入这样一个函数:

python 复制代码
def handle_mouse(event: MouseEvent) -> None:
    print(event.click_position())

register_mouse_handler(handle_mouse)

这当然没问题。

但下面这个函数也应该被允许:

python 复制代码
def log_any_event(event: Event) -> None:
    print(f"event: {type(event).__name__}")

register_mouse_handler(log_any_event)

为什么?

因为框架承诺只会传入 MouseEvent。而 log_any_event() 能接受任何 Event,自然也能接受 MouseEvent

但是反过来就不行:

python 复制代码
class DoubleClickEvent(MouseEvent):
    def click_count(self) -> int:
        return 2

def handle_double_click(event: DoubleClickEvent) -> None:
    print(event.click_count())

register_mouse_handler(handle_double_click)  # 不安全

register_mouse_handler() 只保证传入 MouseEvent,不保证一定是 DoubleClickEvent

如果把只能处理 DoubleClickEvent 的函数注册进去,当框架传入普通 MouseEvent 时,函数内部调用 click_count() 就会出错。

所以对于函数参数:

python 复制代码
Callable[[T], None]

T 是逆变的。

更口语化地说:

注册回调时,能处理"更宽泛输入"的函数,可以替代只能处理"更具体输入"的函数。

这对事件系统、消息总线、插件机制特别重要。


五、不变:可变容器为什么最保守

不变通常发生在"既读又写"的地方。

比如:

python 复制代码
def process_events(events: list[Event]) -> None:
    events.append(KeyboardEvent())

如果允许你传入:

python 复制代码
mouse_events: list[MouseEvent] = [MouseEvent()]
process_events(mouse_events)

就会破坏 mouse_events 的类型承诺。

所以 list[MouseEvent] 不能当作 list[Event] 使用。

正确做法是根据意图调整接口。

如果函数只是读取:

python 复制代码
from collections.abc import Sequence

def print_event_names(events: Sequence[Event]) -> None:
    for event in events:
        print(type(event).__name__)

那么你可以传入:

python 复制代码
mouse_events: list[MouseEvent] = [MouseEvent()]
print_event_names(mouse_events)

因为 Sequence 是只读视角,适合协变。

如果函数确实要修改列表,那么就应该明确接受 list[Event],并且调用者也应该传入真正允许混合事件的列表:

python 复制代码
events: list[Event] = [MouseEvent()]
process_events(events)

这不是类型系统在为难你,而是在帮你把设计意图说清楚。


六、一个实战案例:设计事件处理器系统

现在我们做一个更接近真实项目的例子。

需求如下:

  1. 系统有多种事件;
  2. 可以注册事件处理器;
  3. 有些处理器只处理某类事件;
  4. 有些通用处理器可以处理所有事件;
  5. 事件列表对外只读,避免外部破坏内部状态。

先定义事件:

python 复制代码
class Event:
    def name(self) -> str:
        return self.__class__.__name__

class UserLoginEvent(Event):
    def __init__(self, user_id: int) -> None:
        self.user_id = user_id

class OrderCreatedEvent(Event):
    def __init__(self, order_id: int) -> None:
        self.order_id = order_id

定义只读事件流:

python 复制代码
from typing import Generic, TypeVar
from collections.abc import Iterable

T_co = TypeVar("T_co", bound=Event, covariant=True)

class EventStream(Generic[T_co]):
    def __init__(self, events: Iterable[T_co]) -> None:
        self._events = tuple(events)

    def __iter__(self):
        return iter(self._events)

    def first(self) -> T_co:
        return self._events[0]

这里 EventStream[T] 是协变的。因为它只负责"产出事件",不负责"消费事件"。

然后定义处理器协议:

python 复制代码
from typing import Protocol

T_contra = TypeVar("T_contra", bound=Event, contravariant=True)

class EventHandler(Protocol[T_contra]):
    def handle(self, event: T_contra) -> None:
        ...

这里 EventHandler[T] 是逆变的。因为它负责"消费事件"。

实现两个处理器:

python 复制代码
class LoggingHandler:
    def handle(self, event: Event) -> None:
        print(f"[LOG] {event.name()}")

class LoginHandler:
    def handle(self, event: UserLoginEvent) -> None:
        print(f"user login: {event.user_id}")

现在我们写一个只处理登录事件的分发函数:

python 复制代码
def dispatch_login_event(
    event: UserLoginEvent,
    handlers: Iterable[EventHandler[UserLoginEvent]],
) -> None:
    for handler in handlers:
        handler.handle(event)

使用:

python 复制代码
login_event = UserLoginEvent(user_id=42)

handlers: list[EventHandler[UserLoginEvent]] = [
    LoggingHandler(),
    LoginHandler(),
]

dispatch_login_event(login_event, handlers)

这里 LoggingHandlerhandle() 接受的是 Event,比 UserLoginEvent 更宽泛,所以它可以作为 EventHandler[UserLoginEvent] 使用。

这就是逆变的价值。

如果你在大型系统中设计消息处理、领域事件、插件机制、任务调度器、数据管道,这种设计非常常见。


七、用一张文字图理解三者

可以把泛型接口分成三类:

text 复制代码
                 类型变量 T 的使用位置

只返回 T,不接收 T
Producer[T] / ReadOnlyBox[T]
        |
        v
      协变
      Producer[Child] 可以当 Producer[Parent]


只接收 T,不返回 T
Consumer[T] / Handler[T]
        |
        v
      逆变
      Consumer[Parent] 可以当 Consumer[Child]


既接收 T,又返回 T
MutableBox[T] / list[T]
        |
        v
      不变
      MutableBox[Child] 和 MutableBox[Parent] 互不替代

再简化成一句口诀:

读用协变,写用逆变,读写都有多半不变。

当然这只是帮助理解的口诀,不是机械规则。真实设计中还要看接口语义。


八、常见误区:不要把 list 当成万能参数类型

很多 Python 代码喜欢这样写:

python 复制代码
def summarize(events: list[Event]) -> None:
    ...

如果这个函数只是遍历事件,不修改列表,那么这不是一个好签名。

更好的写法是:

python 复制代码
from collections.abc import Iterable

def summarize(events: Iterable[Event]) -> None:
    for event in events:
        print(event.name())

或者如果你需要支持索引、长度:

python 复制代码
from collections.abc import Sequence

def summarize(events: Sequence[Event]) -> None:
    print(len(events))
    print(events[0].name())

这样做有三个好处:

第一,调用者可以传入 listtuple、生成器、自定义集合。

第二,接口表达更准确:我只是读,不会改。

第三,类型系统更宽容、更安全:Sequence[UserLoginEvent] 可以传给 Sequence[Event]

这就是高级工程师写 API 时经常强调的:

接收参数时尽量依赖抽象接口,而不是具体可变实现。


九、回调函数里的"反直觉"其实很合理

再看一个回调例子:

python 复制代码
from collections.abc import Callable

def run_login_pipeline(
    callback: Callable[[UserLoginEvent], None]
) -> None:
    callback(UserLoginEvent(user_id=1001))

下面两个函数:

python 复制代码
def callback_for_event(event: Event) -> None:
    print("event:", event.name())

def callback_for_login(event: UserLoginEvent) -> None:
    print("login:", event.user_id)

都可以传进去:

python 复制代码
run_login_pipeline(callback_for_event)
run_login_pipeline(callback_for_login)

但这个不应该传进去:

python 复制代码
class AdminLoginEvent(UserLoginEvent):
    def admin_level(self) -> int:
        return 10

def callback_for_admin_login(event: AdminLoginEvent) -> None:
    print(event.admin_level())

run_login_pipeline(callback_for_admin_login)  # 不安全

因为 run_login_pipeline() 并不承诺传入 AdminLoginEvent,它只承诺传入 UserLoginEvent

这也是为什么很多人刚学逆变时觉得绕:我们习惯从"对象继承"角度思考,但回调函数更应该从"调用方承诺"角度思考。

谁调用函数,谁就决定传入什么类型。

一个函数能否作为回调,取决于它能不能安全接住调用方传来的参数。


十、最佳实践:如何在 Python 项目中真正用起来

1. 公共 API 尽量使用只读抽象

如果函数不修改集合,不要写:

python 复制代码
def render(items: list[Item]) -> None:
    ...

优先写:

python 复制代码
from collections.abc import Sequence

def render(items: Sequence[Item]) -> None:
    ...

或者:

python 复制代码
from collections.abc import Iterable

def render(items: Iterable[Item]) -> None:
    ...

这能让你的接口更灵活,也更容易被类型系统接受。


2. 回调参数要理解逆变

设计事件注册函数时:

python 复制代码
def register_handler(
    handler: Callable[[UserLoginEvent], None]
) -> None:
    ...

允许用户传入:

python 复制代码
def handle_any_event(event: Event) -> None:
    ...

这是合理的,不要因为"参数类型不完全一样"就误判它不安全。

真正不安全的是只能处理更窄类型的函数。


3. 可变容器不要强行协变

如果你真的需要修改集合,就诚实地写:

python 复制代码
def add_event(events: list[Event]) -> None:
    events.append(Event())

然后调用者应该传入:

python 复制代码
events: list[Event] = []

不要试图让 list[MouseEvent] 兼容 list[Event]。这不是类型检查器保守,而是避免真实 bug。


4. 自定义泛型时,先问自己"它是生产者还是消费者"

当你写:

python 复制代码
class Repository(Generic[T]):
    ...

请立刻问自己:

这个 Repository[T] 是只返回 T

python 复制代码
def get(self, id: int) -> T:
    ...

那它可能适合协变。

它是只接收 T

python 复制代码
def save(self, item: T) -> None:
    ...

那它可能适合逆变。

它既接收又返回?

python 复制代码
def get(self, id: int) -> T:
    ...

def save(self, item: T) -> None:
    ...

那它大概率应该保持不变。

很多仓储接口之所以难以设计,就是因为它同时承担了读取和写入两种职责。此时可以考虑拆分接口:

python 复制代码
T_co = TypeVar("T_co", covariant=True)
T_contra = TypeVar("T_contra", contravariant=True)

class Reader(Protocol[T_co]):
    def get(self, id: int) -> T_co:
        ...

class Writer(Protocol[T_contra]):
    def save(self, item: T_contra) -> None:
        ...

这样比一个巨大而模糊的 Repository[T] 更清晰,也更符合接口隔离原则。


十一、高级工程师为什么必须理解这个?

因为协变、逆变、不变真正影响的不是"类型写得漂不漂亮",而是系统设计质量。

第一,它帮助你设计更稳定的 API

一个好的 API 不只是"能跑",还应该清楚表达边界。

python 复制代码
Sequence[Event]

表达的是:我只读。

python 复制代码
list[Event]

表达的是:我可能会改。

python 复制代码
Callable[[UserLoginEvent], None]

表达的是:我会传给你一个登录事件,你要能处理它。

类型标注不是装饰品,它是接口契约。


第二,它让类型检查器帮你挡住真实 bug

在动态语言里,很多错误会在运行时才暴露。

比如把只支持 AdminLoginEvent 的函数注册到普通登录事件处理器里,代码可能跑到某个分支才炸。

类型检查器能提前告诉你:

text 复制代码
这个回调不能安全处理 UserLoginEvent

这不是"类型洁癖",而是把线上事故提前挪到开发阶段。


第三,它让团队协作成本更低

大型项目里,代码不是写给机器看的,也是写给同事看的。

当你写下:

python 复制代码
def consume(events: Iterable[Event]) -> None:
    ...

别人会知道:你只是消费这个事件流,不会修改它。

当你写下:

python 复制代码
def mutate(events: list[Event]) -> None:
    ...

别人会警觉:这个函数可能会改变传入列表。

清晰的类型签名,就是团队之间的低成本沟通。


第四,它让框架和 SDK 更容易扩展

框架作者经常要处理这些问题:

用户能不能传入更通用的处理器?

插件能不能返回更具体的结果?

只读数据源能不能支持子类型数据?

这些问题背后都是协变、逆变、不变。

如果你不理解它,很容易写出过度严格或过度宽松的接口。

过度严格会让用户很难用。

过度宽松会让系统不安全。

高级工程师的价值,就在于能在灵活性和安全性之间找到平衡。


十二、一个实用判断清单

以后看到泛型类型 X[T],可以按下面方式判断:

text 复制代码
1. X[T] 只返回 T,不接收 T?
   是:考虑协变。

2. X[T] 只接收 T,不返回 T?
   是:考虑逆变。

3. X[T] 既接收 T,又返回 T?
   是:优先不变。

4. X[T] 是可变集合?
   是:大概率不变。

5. X[T] 是只读集合?
   是:大概率协变。

6. X[T] 是回调、处理器、消费者?
   是:重点关注逆变。

对应到 Python 常见场景:

text 复制代码
Sequence[T]      协变
Iterable[T]      协变
tuple[T, ...]    协变
Callable[[T], R] 参数 T 逆变,返回 R 协变
list[T]          不变
dict[K, V]       通常不变
set[T]           通常不变

十三、结语:类型不是束缚,而是设计语言

Python 的魅力在于它简单、灵活、富有表达力。你可以用它写一个十行脚本,也可以用它构建复杂的 Web 系统、数据平台、机器学习管道和自动化基础设施。

但随着项目变大,真正考验工程能力的,不再只是"会不会写语法",而是:

你能不能设计出清晰的边界?

你能不能让代码在变化中保持稳定?

你能不能让团队成员一眼看懂你的意图?

协变、逆变、不变,表面上是类型系统概念,背后却是 API 设计、数据流方向、职责边界和工程安全。

所以,不要把它们当成晦涩的"类型体操"。

把它们看成三种设计信号:

text 复制代码
协变:我只生产,你放心读取。
逆变:我只消费,你放心交给我。
不变:我既读又写,请不要随便替换。

当你真正理解这一点,就会发现类型标注不再是负担,而是一种温柔的约束。它不会限制 Python 的自由,反而会让自由更可靠。

最后留给你两个问题:

你在项目中有没有遇到过 list[Child] 不能传给 list[Parent] 的困惑?

你设计过事件处理器、回调函数或插件系统吗?如果重新设计一次,你会如何使用协变、逆变和不变来表达接口边界?

相关推荐
hrhcode3 小时前
【LangGraph】四.持久化:保存和恢复执行状态
python·ai·langchain·agent·langgraph
Uopiasd1234oo3 小时前
位置感知注意力与跨阶段部分网络改进YOLOv26特征提取与全局建模能力双重提升
网络·yolo·目标跟踪
xxyy8883 小时前
关于labelimg安装后在标注过程中闪退和死机的问题处理
开发语言·python
IT大白鼠3 小时前
IPv8协议技术解析:设计原理、与IPv6对比及发展前景
网络·ipv8
TechWayfarer3 小时前
2026年IP归属地查询平台选型指南:金融风控、异地登录、离线库全场景实测
网络·网络协议·tcp/ip
卷Java3 小时前
上下文压缩
开发语言·windows·python
AI技术增长3 小时前
Pytorch图像去噪实战(十二):DDPM图像去噪完整训练流程,构建可复现扩散模型工程
pytorch·python·深度学习
日取其半万世不竭3 小时前
Minecraft Java版社区服搭建教程(Windows版)
java·开发语言·windows
信徒_3 小时前
技术选型 RPC 框架
网络·网络协议·rpc