
本文介绍在使用带不变泛型的 protocol 时,当返回 `union[myexporter[t1], myexporter[t2]]` 后无法安全调用 `process_sample(get_sample())` 的典型 mypy 类型推导失败问题,并提供无需重构架构的实用解决方案,包括 `@overload`、泛型协变调整与类型守卫等。
在 Python 类型系统中,Protocol 的泛型默认是不变(invariant)的——这意味着 MyExporter[SampleA] 和 MyExporter[SampleB] 互不兼容,即使 SampleA 和 SampleB 同属 BaseSample 子类。当你用 Union[MyExporter[SampleA], MyExporter[SampleB]] 作为函数返回类型时,类型检查器(如 mypy)会将 get_sample() 的结果保守推断为 SampleA | SampleB,但 process_sample() 的参数要求却是精确匹配其所属 exporter 的具体泛型类型(如 SampleA),从而导致“argument has incompatible type”错误。
最推荐、零侵入的解决方案是使用 @overload 显式声明不同输入字面量对应的精确返回类型:
from typing import Protocol, overload, TypeVar, cast, Union
from typing_extensions import Literal
class BaseSample: ...
class SampleA(BaseSample): ...
class SampleB(BaseSample): ...
T = TypeVar("T", bound=BaseSample)
class MyExporter(Protocol[T]):
def get_sample(self) -> T: ...
def process_sample(self, sample: T) -> str: ...
# 模拟运行时实例(仅用于类型检查)
my_exporter_a = cast(MyExporter[SampleA], object())
my_exporter_b = cast(MyExporter[SampleB], object())
@overload
def get_exporter(name: Literal["a"]) -> MyExporter[SampleA]: ...
@overload
def get_exporter(name: Literal["b"]) -> MyExporter[SampleB]: ...
def get_exporter(name: str) -> Union[MyExporter[SampleA], MyExporter[SampleB]]:
if name == "a":
return my_exporter_a
elif name == "b":
return my_exporter_b
else:
raise ValueError(f"Unknown exporter: {name}")
# ✅ 类型检查通过
exporter = get_exporter("a") # 类型:MyExporter[SampleA]
sample = exporter.get_sample() # 类型:SampleA
output = exporter.process_sample(sample) # ✅ 完全匹配该方案优势明显:
- 无运行时开销:@overload 仅影响静态类型检查;
- 精准推导:mypy 能根据字面量 "a"/"b" 精确绑定泛型 T,使 get_sample() 与 process_sample() 类型链完全一致;
- 可扩展性强:新增类型只需增加一个 @overload 声明即可。
⚠️ 注意事项:
- 必须为每个受支持的字符串字面量(如 "a"、"b")单独编写 @overload;若输入来自动态变量(如用户输入),需配合 isinstance 或 TypeGuard 进行运行时类型缩小;
- 避免在实现体中使用泛型 T——实际函数体应保持类型擦除,仅 overload 签名承担类型契约;
- 若 name 是 str 变量而非字面量,mypy 将回退到联合类型,此时可考虑补充 assert isinstance(exporter, MyExporter[SampleA]) 或使用 typing.TypeGuard 自定义类型守卫。
另一种轻量替代方案(适用于 exporter 行为对 SampleA | SampleB 统一处理的场景)是将协议泛型改为 Union 类型本身:
def get_exporter(name: str) -> MyExporter[Union[SampleA, SampleB]]:
# 返回一个能同时处理两种样本的 exporter 实例
...
exporter = get_exporter("unknown")
sample = exporter.get_sample() # 类型:SampleA | SampleB
exporter.process_sample(sample) # ✅ 因为 process_sample 接受 Union[SampleA, SampleB]此方式牺牲了类型特异性,但简化了分支逻辑,适合 SampleA 与 SampleB 在 exporter 中行为高度一致的场景。
综上,@overload + Literal 是兼顾类型安全、可维护性与最小改动的最佳实践。它让类型系统真正理解「同一个 exporter 实例的输入输出类型必须自洽」这一语义,而非被迫退化为宽泛的联合类型推理。










