从"类型体操"到工程设计:用 Python 解释协变、逆变与不变
在 Python 里,很多人第一次听到"协变、逆变、不变"时,都会本能地皱眉:这是不是又是一套只存在于类型系统里的抽象概念?平时写业务代码、做 Web 后端、数据处理、自动化脚本,真的需要懂这些吗?
我的答案是:如果你只是写几十行脚本,可以暂时不懂;但如果你在设计事件处理器、回调函数、SDK、框架接口、插件系统、只读集合接口,那么你迟早会碰到它。
协变、逆变、不变,本质上不是"类型体操",而是在回答一个非常朴素的工程问题:
当
Dog是Animal的子类时,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] 在类型系统里通常是不变的。
二、三个概念一句话讲清楚
我们先给出最核心的定义。
假设 MouseEvent 是 Event 的子类:
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)
这不是类型系统在为难你,而是在帮你把设计意图说清楚。
六、一个实战案例:设计事件处理器系统
现在我们做一个更接近真实项目的例子。
需求如下:
- 系统有多种事件;
- 可以注册事件处理器;
- 有些处理器只处理某类事件;
- 有些通用处理器可以处理所有事件;
- 事件列表对外只读,避免外部破坏内部状态。
先定义事件:
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)
这里 LoggingHandler 的 handle() 接受的是 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())
这样做有三个好处:
第一,调用者可以传入 list、tuple、生成器、自定义集合。
第二,接口表达更准确:我只是读,不会改。
第三,类型系统更宽容、更安全: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] 的困惑?
你设计过事件处理器、回调函数或插件系统吗?如果重新设计一次,你会如何使用协变、逆变和不变来表达接口边界?