
本文通过 pytorch 的 `dataset[t_co]` 这一真实案例,深入解释协变(covariance)与逆变(contravariance)的必要性——它们不是语法糖,而是保障类型安全的关键机制;若忽略协变,将导致合法子类型无法被接受,引发静默类型漏洞。
在静态类型系统(如 python 的 typing + 类型检查器 mypy)中,泛型类型的子类型关系并非天然继承自其类型参数的子类型关系。默认情况下,Dataset[T] 是不变的(invariant):即使 bool 是 int 的子类型(Python 中 bool 继承自 int),Dataset[bool] 也不是 Dataset[int] 的子类型。这看似无害,却会在关键场景下破坏类型安全与代码复用。
? 问题浮现:没有协变,类型检查会“误报”合法代码
假设我们定义一个仅读取整数标签的数据集:
from typing import Generic, TypeVar, List from torch.utils.data import Dataset # 注意:此处故意省略 T_co —— 即使用不变泛型 class DatasetInvariant(Generic[T]): def __getitem__(self, index: int) -> T: ... # 正确的 PyTorch 定义(带协变标记) class DatasetCovariant(Generic[T_co]): # T_co = TypeVar('T_co', covariant=True) def __getitem__(self, index: int) -> T_co: ...
现在考虑如下函数:它接受任意 Dataset[int],并安全地读取其标签(只读操作):
def process_int_labels(dataset: DatasetCovariant[int]) -> List[int]: return [dataset[i] for i in range(min(3, len(dataset)))]
如果我们传入一个 Dataset[bool](例如只含 True/False 标签的二分类数据集),逻辑上完全合理——因为 bool 可隐式提升为 int(True == 1, False == 0),且 Dataset[bool] 仅返回 bool,而 int 的所有操作对 bool 均成立。
✅ 有协变时(Dataset[T_co]):Dataset[bool] 是 Dataset[int] 的子类型 → 类型检查通过,运行安全。
❌ 无协变时(默认 Dataset[T]):Dataset[bool] 与 Dataset[int] 被视为不兼容类型 → mypy 报错 Argument 1 to “process_int_labels” has incompatible type “Dataset[bool]”,强行阻断了本应合法的多态调用。
这就是协变的核心动机:当泛型类型仅作为“生产者”(producer)——即只读、只输出类型 T ——则应声明为协变,以允许更具体的子类型安全替代。
? 为什么不是所有容器都能协变?对比 list 与 tuple
协变的前提是不可变性或只读契约。反例是 list[T]:它既支持读(lst[i] -> T),也支持写(lst.append(x))。若 list[bool] 是 list[int] 的子类型,则以下代码将类型安全失效:
def fill_with_zeros(lst: list[int]) -> None: lst.append(0) # ✅ 合法:向 int 列表插入 int bools: list[bool] = [True, False] fill_with_zeros(bools) # ❌ 危险!实际向 bool 列表插入了 int → 破坏列表元素类型一致性
因此 list[T] 必须是不变的(invariant),而 tuple[T] 因不可变,可安全协变:
def expect_tuple_of_int(t: tuple[int, ...]) -> None: pass t_bool: tuple[bool, bool] = (True, False) expect_tuple_of_int(t_bool) # ✅ mypy 通过:tuple[bool] <: tuple[int](协变)
⚡ 逆变(Contravariance):函数类型的“反直觉”安全规则
逆变常见于消费者(consumer)场景,最典型的是函数签名。考虑:
from typing import Callable, TypeVar T = TypeVar('T') U = TypeVar('U') # 函数类型:Callable[[Arg], Ret] # 在 Arg 位置是逆变的,在 Ret 位置是协变的 def apply_twice(f: Callable[[int], str], x: int) -> str: return f(f(x)) # ❌ 类型错误:f(x) 返回 str,但 f 需要 int 输入
正确抽象应允许更“宽泛”的输入、更“严格”的输出:
# 更通用的函数:能处理 int 及其父类(如 object),返回 str 或其子类(如 Literal["ok"]) general_func: Callable[[object], str] = lambda x: "ok" # 更专用的函数:只接受 int,返回 Literal["ok"] specific_func: Callable[[int], Literal["ok"]] = lambda x: "ok" # ✅ 类型安全替换:general_func 可替代 specific_func 作为参数 # 因为 general_func 接受更多输入(逆变),返回更通用结果(协变) def use_specific(f: Callable[[int], Literal["ok"]]) -> None: print(f(42)) use_specific(general_func) # ❌ mypy 拒绝:general_func 不保证返回 Literal["ok"] use_specific(specific_func) # ✅ # ✅ 但反过来:若函数参数需接受 general_func,则应声明为 Callable[[object], str] def use_general(f: Callable[[object], str]) -> None: print(f("hello")) # general_func 支持 str 输入(因 Object 是 str 的父类) use_general(specific_func) # ❌ mypy 拒绝:specific_func 不接受 str(只接受 int) use_general(general_func) # ✅
这印证了函数类型的经典规则:
参数类型逆变(更宽泛的输入类型可替代更严格的输入)返回类型协变(更严格的返回类型可替代更宽泛的返回)
✅ 总结:何时用协变 / 逆变?
| 场景 | 方向 | 关键特征 | 典型例子 |
|---|---|---|---|
| 只读容器 / 生产者 | 协变 ✅ | 类型 T 仅作为输出(__getitem__, iter()) | Dataset[T_co], tuple[T_co], Iterator[T_co] |
| 只写容器 / 消费者 | 逆变 ❗ | 类型 T 仅作为输入(queue.put()) | Queue[T_con](需显式声明) |
| 读写混合 / 可变 | 不变 ⚠️ | 同时读写 T,无法保证双向安全 | list[T], dict[K, V](键/值均不变) |
| 函数参数 | 逆变 ✅ | 参数类型需“向上兼容”(父类更安全) | Callable[[object], ...] 替代 Callable[[int], ...] |
| 函数返回值 | 协变 ✅ | 返回类型需“向下兼容”(子类更精确) | Callable[..., Literal["ok"]] 替代 Callable[..., str] |
? 实践建议:在设计泛型类时,先问自己——这个类型参数 T 在接口中是只被读出(协变)、只被写入(逆变),还是双向流动(不变)?pytorch 将 Dataset[T_co] 设为协变,正是因为它是一个纯粹的“数据生产者”,这一设计让 Dataset[bool]、Dataset[np.int32] 等子类型能无缝融入期望 Dataset[int] 的下游训练流程,杜绝了类型擦除导致的运行时错误,是工业级库类型健壮性的典范。