Python 基础体系 · 第 32/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 泛型类型:TypeVar、ParamSpec、TypeVarTuple 与 Protocol

Python 的类型标注并不会改变对象的运行时行为。它们主要为静态类型检查器提供额外信息,使检查器能够回答三个问题:

  1. 一个函数的输入和输出之间是否存在类型依赖?
  2. 一个泛型类在不同类型参数下是否仍然保持同一套逻辑?
  3. 一个对象是否满足某种接口,而不必继承指定的基类?

TypeVarParamSpecTypeVarTupleProtocol 分别解决这些问题的不同部分:

  • TypeVar:抽象一个或多个普通类型;
  • ParamSpec:抽象一个可调用对象的完整参数列表;
  • TypeVarTuple:抽象长度和元素类型都可变的类型序列;
  • Protocol:描述“具有哪些成员”的结构化接口。

Python 3.12 引入了新的类型参数语法,Python 3.14 继续支持这种写法;旧的 TypeVar(...)ParamSpec(...)TypeVarTuple(...) 构造方式仍然有效。两种写法在静态类型语义上通常等价,但新语法的作用域更清晰。(typing.python.org)


一、先区分三个层次:运行时对象、静态类型和类型参数

考虑下面的代码:

def first[T](items: list[T]) -> T:
    return items[0]

调用:

number = first([1, 2, 3])
text = first(["a", "b"])

静态类型检查器可以推导出:

number: int
text: str

这里的 T 不是运行时变量,也不会在函数执行时保存“当前是 int 还是 str”。它只是一个类型占位符:

调用 first([1, 2, 3]):
list[T] 与 list[int] 匹配
因此 T = int
返回类型 T = int

调用 first(["a", "b"]):
list[T] 与 list[str] 匹配
因此 T = str
返回类型 T = str

运行时只有一个普通函数对象:

print(first([1, 2, 3]))
print(first(["a", "b"]))

类型参数主要存在于静态分析阶段。泛型实例的类型参数通常不会记录在实例的运行时类型中,这种现象称为类型擦除。例如,Box[int]Box[str] 可以是不同的静态类型,但它们创建出的对象运行时仍然可能都是 Box 实例。(typing.python.org)

因此,泛型标注不能代替运行时校验:

def parse_user(data: dict[str, object]) -> "User":
    ...

即使返回值被标注为 User,Python 也不会自动检查函数是否真的返回了 User。静态类型检查器检查的是源代码中的类型关系,而不是运行时值的实际类型。


二、TypeVar:让普通类型之间建立依赖关系

2.1 TypeVar 的基本含义

TypeVar 表示一个待推导的类型变量。

旧语法:

from collections.abc import Sequence
from typing import TypeVar

T = TypeVar("T")

def first(items: Sequence[T]) -> T:
    return items[0]

Python 3.12 及更高版本可以写成:

from collections.abc import Sequence

def first[T](items: Sequence[T]) -> T:
    return items[0]

这两个定义表达的是同一个核心关系:

参数类型:Sequence[T]
返回类型:T

因此,返回值的类型必须与序列元素类型一致。官方泛型规范将这种关系描述为:泛型函数的返回值应当与集合中元素的类型保持一致。(typing.python.org)

下面的代码会被类型检查器接受:

numbers = first([10, 20, 30])       # int
names = first(("Alice", "Bob"))     # str

而下面的函数实现不满足声明:

def bad_first[T](items: Sequence[T]) -> T:
    return "always a string"  # 类型错误

如果调用者传入 Sequence[int],函数承诺返回 int,但实现却固定返回 str。这不是“泛型不够智能”,而是函数实现违反了类型关系。


2.2 一个 TypeVar 可以出现在多个位置

TypeVar 的价值在于同一个变量可以出现在多个参数或返回值中,从而表达参数之间的约束。

def same[T](left: T, right: T) -> T:
    return left

它表示:

left 和 right 必须使用同一个 T
返回值也是这个 T

下面的调用通常可以通过:

same(1, 2)          # T = int
same("a", "b")      # T = str

下面的调用会遇到类型不匹配:

same(1, "x")

因为静态检查器需要为同一个 T 找到一个能够同时满足两个参数的类型。不同检查器在复杂情况下可能推导到公共超类型,例如 object,也可能直接报告不一致;不能把 T 理解为“每个位置都可以独立变化”。

对比下面的函数:

def different[T, U](left: T, right: U) -> tuple[T, U]:
    return left, right

这里有两个独立的类型变量:

different(1, "x")  # tuple[int, str]

形式上:

same[T](T, T) -> T
different[T, U](T, U) -> tuple[T, U]

前者表达同一性约束,后者允许两个位置使用不同类型。


2.3 无约束、约束集合和上界

TypeVar 常见的三种形式如下:

T = TypeVar("T")
T_constrained = TypeVar("T_constrained", int, float)
T_bounded = TypeVar("T_bounded", bound=SupportsLen)

新语法对应为:

def f[T](value: T) -> T:
    return value

def g[T: (int, float)](value: T) -> T:
    return value

def h[T: SupportsLen](value: T) -> T:
    return value

这三者的推导规则不同。

无约束类型变量

def identity[T](value: T) -> T:
    return value

调用:

class User:
    pass

user = identity(User())

此时可以保留具体类型:

T = User
返回类型 = User

约束类型变量

from typing import TypeVar

AnyText = TypeVar("AnyText", str, bytes)

def concat(left: AnyText, right: AnyText) -> AnyText:
    return left + right

允许:

concat("a", "b")        # str
concat(b"a", b"b")      # bytes

不允许混合:

concat("a", b"b")       # 类型错误

约束集合的含义是:T 必须从列出的类型中选择一个。子类会被视为对应的约束类型,而不是保留为子类本身:

class MyStr(str):
    pass

value = concat(MyStr("a"), MyStr("b"))

这里 T 通常被推导为 str,而不是 MyStr。(typing.python.org)

上界类型变量

上界表达的是“至少满足某种接口”:

from collections.abc import Sized

def longer[T: Sized](left: T, right: T) -> T:
    if len(left) >= len(right):
        return left
    return right

调用:

longer([1], [1, 2])    # list[int]
longer({1}, {1, 2})    # set[int]

这里 T 不要求等于 Sized,而是要求实际类型满足 Sized。因此,上界和约束集合的差异是:

约束集合:
T ∈ {str, bytes}
实际类型被归一化到列出的类型之一

上界:
T <: Sized
实际类型可以是任何满足 Sized 的具体子类型

约束集合和上界不能同时用于同一个 TypeVar。约束集合要求从固定选项中选择,上界只规定可接受的最大范围。(typing.python.org)


2.4 TypeVar 不能自动保证实现安全

下面的声明看起来合理:

def append_and_return[T](items: list[T], value: T) -> list[T]:
    items.append(value)
    return items

但不能因此把可变容器任意当成协变类型。假设:

class Animal:
    pass

class Cat(Animal):
    pass

如果允许:

cats: list[Cat] = [Cat()]
animals: list[Animal] = cats

那么调用者可以通过 animals 写入一个普通 Animal

animals.append(Animal())

这会使原本声称只包含 Catcats 变得不安全。因此,像 list[T] 这样的可变泛型容器通常是不变的:list[Cat] 既不是 list[Animal] 的子类型,也不是反过来。(typing.python.org)


三、方差:为什么有的泛型可以协变,有的不能

方差描述的是:当 CatAnimal 的子类型时,Container[Cat]Container[Animal] 之间是否也存在子类型关系。

3.1 协变:只生产类型

只读容器可以安全地协变:

from collections.abc import Iterable, Iterator
from typing import TypeVar

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

class ReadOnlyBox[T_co]:
    def __init__(self, value: T_co) -> None:
        self._value = value

    def get(self) -> T_co:
        return self._value

如果:

Cat <: Animal

那么:

ReadOnlyBox[Cat] <: ReadOnlyBox[Animal]

原因是:调用者只会从盒子中读取一个 Animal,而盒子实际提供 Cat 是安全的。

def print_animal(box: ReadOnlyBox[Animal]) -> None:
    animal = box.get()
    print(animal)

cat_box: ReadOnlyBox[Cat] = ReadOnlyBox(Cat())
print_animal(cat_box)  # 安全

3.2 逆变:只消费类型

只接收数据的对象可以逆变:

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

class Consumer[T_contra]:
    def consume(self, value: T_contra) -> None:
        ...

如果一个消费者可以处理任意 Animal,它当然也可以处理 Cat

Consumer[Animal] <: Consumer[Cat]

注意方向与协变相反。

3.3 不变:既读又写

如果一个类型既返回 T 又接收 T,通常必须保持不变:

T = TypeVar("T")

class MutableBox[T]:
    def __init__(self, value: T) -> None:
        self.value = value

    def get(self) -> T:
        return self.value

    def set(self, value: T) -> None:
        self.value = value

假设 MutableBox[Cat] 可以赋值给 MutableBox[Animal],那么调用者可以:

animal_box: MutableBox[Animal] = cat_box
animal_box.set(Animal())

于是 cat_box.get() 可能返回一个并非 Cat 的对象,类型安全被破坏。

旧式 TypeVar 默认是不变的;使用 covariant=Truecontravariant=True 只能表达确实满足对应方差条件的泛型类。对于普通泛型函数或类型别名,单独声明方差没有实际意义。(typing.python.org)


四、泛型类:复用类型关系,而不只是复用代码

4.1 定义泛型类

Python 3.14 推荐使用类型参数语法:

class Box[T]:
    def __init__(self, value: T) -> None:
        self.value = value

    def get(self) -> T:
        return self.value

    def set(self, value: T) -> None:
        self.value = value

使用:

int_box = Box(42)          # Box[int]
text_box = Box("hello")    # Box[str]

int_value = int_box.get()  # int
text_value = text_box.get()  # str

旧语法仍然有效:

from typing import Generic, TypeVar

T = TypeVar("T")

class OldBox(Generic[T]):
    def __init__(self, value: T) -> None:
        self.value = value

    def get(self) -> T:
        return self.value

新语法隐式表达了 Generic[T] 的作用。泛型类实例化时,如果构造函数参数中出现了类型变量,检查器可以根据实参推导类型参数;如果没有足够信息,未指定的类型参数通常会退化为 Any。(typing.python.org)

例如:

class Node[T]:
    def __init__(self, value: T | None = None) -> None:
        self.value = value

a = Node(1)       # Node[int]
b = Node("text")  # Node[str]
c = Node()        # Node[Any]

如果空构造函数不应产生 Any,可以显式标注:

c: Node[int] = Node()

这里的标注不会改变运行时对象,只是告诉静态检查器该对象预期承担 Node[int] 的类型契约。


五、ParamSpec:保留函数的完整参数列表

5.1 为什么 TypeVar 不够

假设要编写一个装饰器,仅增加日志,不改变原函数的参数和返回值:

from collections.abc import Callable
from typing import Any, TypeVar

T = TypeVar("T")

def add_logging(func: Callable[..., T]) -> Callable[..., T]:
    def wrapper(*args: Any, **kwargs: Any) -> T:
        print("calling")
        return func(*args, **kwargs)

    return wrapper

Callable[..., T] 只说明:

参数:任意
返回值:T

它丢失了参数的数量、顺序、名称和类型。于是下面的错误可能无法在装饰器边界被发现:

@add_logging
def greet(name: str, age: int) -> str:
    return f"{name}: {age}"

greet(123, "not an age")

ParamSpec 用于捕获一个可调用对象的整个参数列表:

from collections.abc import Callable

def add_logging[T, **P](func: Callable[P, T]) -> Callable[P, T]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
        print("calling")
        return func(*args, **kwargs)

    return wrapper

这里:

  • T 表示返回值类型;
  • P 表示参数列表;
  • P.args 表示位置参数;
  • P.kwargs 表示关键字参数。

ParamSpec 的核心用途正是把一个 callable 的参数类型转发给另一个 callable,尤其适用于装饰器和高阶函数。(docs.python.org)

完整示例:

from collections.abc import Callable

def add_logging[T, **P](func: Callable[P, T]) -> Callable[P, T]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
        print(f"calling {func.__name__}")
        return func(*args, **kwargs)

    return wrapper


@add_logging
def greet(name: str, age: int) -> str:
    return f"{name} is {age}"


print(greet("Alice", 30))
print(greet(name="Bob", age=20))

静态上,装饰后的 greet 仍然近似为:

(name: str, age: int) -> str

因此以下调用应被检查器拒绝:

greet(123, "wrong")
greet("Alice")
greet("Alice", age="wrong")

运行时,P 不会变成一个可以直接操作的参数对象。它是静态类型参数;P.argsP.kwargs 主要用于在包装函数签名中标注 *args**kwargs。(docs.python.org)


5.2 ParamSpec 的合法位置

P 不能像普通类型变量那样任意嵌套:

def invalid[**P](value: P) -> P:
    ...

这不是合法的 ParamSpec 用法。

ParamSpec 主要出现在以下位置:

from collections.abc import Callable

type IntFunction[**P] = Callable[P, int]

def f[**P](func: Callable[P, int]) -> Callable[P, str]:
    ...

也可以作为泛型类参数:

from collections.abc import Callable

class CallbackHolder[T, **P]:
    callback: Callable[P, T]

    def __init__(self, callback: Callable[P, T]) -> None:
        self.callback = callback

ParamSpec 表示的是“参数表达式”,不是普通的单一类型,因此不能写成 list[P]P 或普通返回值类型。(typing.python.org)


5.3 Concatenate:在参数列表前增加参数

有些装饰器会在调用者看不见的地方注入一个参数。例如,依赖注入装饰器可能自动提供一个 Request

from collections.abc import Callable
from typing import Concatenate

class Request:
    pass

def inject_request[T, **P](
    func: Callable[Concatenate[Request, P], T],
) -> Callable[P, T]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
        request = Request()
        return func(request, *args, **kwargs)

    return wrapper

使用:

@inject_request
def handle(request: Request, user_id: int) -> str:
    return f"user={user_id}"

result = handle(42)

装饰前:

(Request, user_id: int) -> str

装饰后:

(user_id: int) -> str

Concatenate[Request, P] 表示:

参数列表 = 一个位置参数 Request + P

因此 P 必须放在 Callable 的参数部分,不能将 Concatenate 当成普通容器类型。被拼接到前面的参数是位置参数;规范不允许通过关键字方式引用这些被前置的参数,这是为了避免包装函数内部出现重复传参等不安全调用。(typing.python.org)


5.4 ParamSpec 的真实边界

ParamSpec 能保留静态参数签名,但不能解决所有装饰器问题。

例如装饰器修改了参数名称:

def rename_argument[T, **P](func: Callable[P, T]) -> Callable[P, T]:
    ...

仅靠 ParamSpec 无法表达“把参数 user_id 改名为 id”这种任意重写。它可以转发原参数列表,或用 Concatenate 添加固定的前置位置参数,但不能任意变换参数名称、关键字专属性和参数顺序。

此外,包装器实现本身仍可能不安全:

def unsafe[T, **P](func: Callable[P, T]) -> Callable[P, T]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
        return func(*args, **kwargs)

    return wrapper

如果函数内部对 argskwargs 做了错误的拆包、重排或重复传值,类型检查器无法证明运行时逻辑一定正确。ParamSpec 保护的是“签名关系”,不是函数体中的任意参数操作。


六、TypeVarTuple:表达可变长度的类型序列

6.1 普通 TypeVarTypeVarTuple 的区别

普通 TypeVar 代表一个类型:

T = int

TypeVarTuple 代表一组类型:

Ts = (int, str, bool)

Python 3.12 及更高版本使用 * 声明:

def pack[*Ts](*values: *Ts) -> tuple[*Ts]:
    return values

调用:

result = pack(1, "a", True)

静态类型为:

tuple[int, str, bool]

这里不是:

tuple[object, ...]

因为每一个位置的类型都被保留了:

第 1 个实参:int
第 2 个实参:str
第 3 个实参:bool

Ts = (int, str, bool)
返回类型 = tuple[*Ts]
       = tuple[int, str, bool]

TypeVarTuple 是变长泛型的基础,特别适合表示异构元组、数组维度、张量形状和可变参数序列。(typing.python.org)

旧语法为:

from typing import TypeVarTuple

Ts = TypeVarTuple("Ts")

def pack(*values: *Ts) -> tuple[*Ts]:
    return values

6.2 *Ts 必须解包

TypeVarTuple 必须使用 * 解包:

class Array[*Shape]:
    def __init__(self, shape: tuple[*Shape]) -> None:
        self.shape = shape

正确:

shape: tuple[*Shape]

不正确:

shape: Shape

原因是 Shape 本身不是一个普通单一类型,而是一组待展开的类型参数。*Shape 才表示把这组类型插入当前位置。规范明确要求 TypeVarTuple 必须以解包形式使用,以区分它和普通 TypeVar。(typing.python.org)

从语义上看:

tuple[*Shape]

Shape = (Height, Width) 时等价于:

tuple[Height, Width]

Python 3.14 中也可以使用 Unpack 表达同一含义:

from typing import TypeVarTuple, Unpack

Shape = TypeVarTuple("Shape")

value: tuple[Unpack[Shape]]

在支持星号类型参数语法的版本中,*Shape 是更直接的写法。(docs.python.org)


6.3 在类型序列前后添加固定类型

TypeVarTuple 可以与普通类型或 TypeVar 混合:

def prefix[T, *Ts](first: T, rest: tuple[*Ts]) -> tuple[T, *Ts]:
    return (first, *rest)

调用:

value = prefix(0, (True, "text"))

推导过程:

T = int
Ts = (bool, str)

返回类型:
tuple[T, *Ts]
= tuple[int, bool, str]

也可以在类型序列后追加类型:

def suffix[*Ts, T](items: tuple[*Ts], last: T) -> tuple[*Ts, T]:
    return (*items, last)

对于数组或张量形状,常见写法是增加或删除某个轴:

from typing import NewType

Batch = NewType("Batch", int)
Height = NewType("Height", int)
Width = NewType("Width", int)

class Array[*Shape]:
    pass

def add_batch_axis[*Shape](
    value: Array[*Shape],
) -> Array[Batch, *Shape]:
    ...

def remove_batch_axis[*Shape](
    value: Array[Batch, *Shape],
) -> Array[*Shape]:
    ...

如果:

image: Array[Height, Width]

那么:

batched = add_batch_axis(image)

推导为:

batched: Array[Batch, Height, Width]

再调用:

image_again = remove_batch_axis(batched)

推导回:

image_again: Array[Height, Width]

这类类型变换的重点不在于运行时真的创建了某个数组对象,而在于静态类型检查器可以追踪维度序列的变化。(typing.python.org)


6.4 TypeVarTuple 的长度约束

同一个 TypeVarTuple 出现在多个位置时,长度必须能够匹配。

def join_same_shape[*Ts](
    left: tuple[*Ts],
    right: tuple[*Ts],
) -> tuple[*Ts]:
    return left

允许:

join_same_shape((1, "a"), (2, "b"))

此时:

Ts = (int, str)

但下面的调用长度不一致:

join_same_shape((1,), (2, "b"))

它要求同一个 Ts 同时代表长度为 1 和长度为 2 的类型序列,因此应被拒绝。规范要求同一个 TypeVarTuple 的使用在长度上保持匹配;至于多个参数类型发生冲突时具体推导到公共类型还是直接报错,部分推导细节由类型检查器决定。(typing.python.org)


6.5 不允许两个 TypeVarTuple

下面的声明是不允许的:

class Invalid[*Ts1, *Ts2]:
    pass

当使用:

Invalid[int, str, bool]

时,无法确定哪些类型属于 Ts1,哪些属于 Ts2

Ts1 = ?
Ts2 = ?

这会造成无法消解的边界歧义,因此一个类型参数列表中最多只能出现一个 TypeVarTuple。普通 TypeVar 可以出现在它的前面或后面。(typing.python.org)


6.6 TypeVarTuple 与普通 *args 的区别

下面的写法:

def all_ints(*args: int) -> None:
    ...

表示每一个位置参数都必须是 int

all_ints(1, 2, 3)  # 允许
all_ints(1, "x")   # 拒绝

而下面的写法:

def heterogeneous[*Ts](*args: *Ts) -> tuple[*Ts]:
    return args

表示每个位置参数都有自己的类型:

heterogeneous(1, "x", True)
# tuple[int, str, bool]

二者的区别是:

*args: int
    每个元素都使用同一个 int

*args: *Ts
    每个位置的类型进入 Ts,Ts 可以是 (int, str, bool)

如果写成:

def invalid[*Ts](*args: Ts) -> None:
    ...

则不合法,因为 Ts 必须被解包。*args 是少数可以直接写成 *Ts 的位置;在其他位置通常需要写成 tuple[*Ts] 或类似形式。(typing.python.org)


七、Protocol:用结构描述接口

7.1 名义子类型与结构化子类型

Python 传统上主要使用名义子类型:

class Animal:
    def speak(self) -> str:
        ...

class Dog(Animal):
    ...

DogAnimal 的子类,因为它明确出现在 Animal 的继承关系中。

Protocol 使用结构化子类型。它关心的不是“是否继承了这个类”,而是“是否提供了兼容的成员”。

from typing import Protocol

class Closable(Protocol):
    def close(self) -> None:
        ...

以下类没有继承 Closable

class DatabaseConnection:
    def close(self) -> None:
        print("closed")

但它可以被赋给 Closable 类型:

def close_resource(resource: Closable) -> None:
    resource.close()

connection = DatabaseConnection()
close_resource(connection)

静态类型检查器会检查 DatabaseConnection.close 的签名是否与协议成员兼容。只要具备所有必需成员,并且成员类型可赋值给协议声明的类型,就可以隐式实现协议。(typing.python.org)

可以把两种关系形式化为:

名义子类型:
X <: P
当且仅当 X 的继承关系中明确包含 P

结构化子类型:
X <: P
当且仅当 X 提供 P 的全部成员,且每个成员签名兼容

因此,协议特别适合描述第三方对象、测试替身和跨模块边界,而不要求被适配的类修改继承关系。


7.2 方法签名必须满足可赋值关系

协议不是简单的“方法名字存在检查”。

from typing import Protocol

class Reader(Protocol):
    def read(self, size: int) -> bytes:
        ...

下面的实现符合协议:

class FileReader:
    def read(self, size: int) -> bytes:
        return b""

下面的实现不一定符合:

class WrongReader:
    def read(self, size: str) -> str:
        return ""

问题有两个:

  1. 参数 size 要求 str,不能接受协议允许的 int
  2. 返回 str,不是协议要求的 bytes

方法参数和返回值必须按照函数类型的可赋值规则进行检查。尤其是参数位置通常体现逆变关系:实现不能把协议允许的输入范围缩小。

协议成员不仅包括方法,也包括属性:

class UserLike(Protocol):
    name: str
    def is_active(self) -> bool:
        ...

因此,下面的对象只有在同时提供兼容的 nameis_active 时,才能实现该协议。


7.3 数据协议与只读属性

可变属性会影响协议的方差和可赋值性。

from typing import Protocol, TypeVar

T = TypeVar("T")

class MutableValue(Protocol[T]):
    value: T

由于调用者可能读写 valueT 必须同时满足输入和输出要求,因此该协议通常是不变的。

如果协议只需要读取属性,可以使用只读属性:

from typing import Protocol, TypeVar

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

class ReadableValue(Protocol[T_co]):
    @property
    def value(self) -> T_co:
        ...

此时协议只生产 T_co,可以安全地协变:

class Cat:
    pass

class CatValue:
    @property
    def value(self) -> Cat:
        return Cat()

def show_animal(value: ReadableValue[object]) -> None:
    print(value.value)

show_animal(CatValue())  # 可接受

协议规范明确区分了只含方法的非数据协议和包含属性等非方法成员的数据协议;可变属性通常会导致不变性。(typing.python.org)


八、泛型 Protocol:结构化接口与类型参数结合

协议本身也可以是泛型的:

from collections.abc import Iterator
from typing import Protocol

class IterableLike[T](Protocol):
    def __iter__(self) -> Iterator[T]:
        ...

实现它不需要显式继承:

class NumberCollection:
    def __iter__(self) -> Iterator[int]:
        return iter([1, 2, 3])

使用:

def total(values: IterableLike[int]) -> int:
    return sum(values)

result = total(NumberCollection())

这里同时存在两层关系:

NumberCollection 提供 __iter__ 方法
__iter__ 返回 Iterator[int]
因此 NumberCollection 实现 IterableLike[int]

如果协议定义为只读生产者,可以使用协变类型变量:

from collections.abc import Iterator
from typing import Protocol, TypeVar

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

class Producer(Protocol[T_co]):
    def produce(self) -> T_co:
        ...

如果协议定义为消费者,可以使用逆变类型变量:

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

class Consumer(Protocol[T_contra]):
    def consume(self, value: T_contra) -> None:
        ...

Python 3.12 的类型参数语法支持由类型检查器推导协议中的方差;旧式 TypeVar 写法通常需要显式声明方差。协议若声明的方差与成员实际使用方式不一致,类型检查器应报告错误。(typing.python.org)


九、把 TypeVarProtocol 组合起来表达边界

TypeVar 的上界可以是协议:

from typing import Protocol

class SupportsClose(Protocol):
    def close(self) -> None:
        ...

def close_and_return[T: SupportsClose](resource: T) -> T:
    resource.close()
    return resource

这里的 T 表示:

T 必须实现 SupportsClose
返回值保留调用者传入的具体类型

例如:

class Socket:
    def close(self) -> None:
        print("socket closed")

socket = close_and_return(Socket())

静态类型可以保留为:

Socket

如果直接把参数写成协议类型:

def close_only(resource: SupportsClose) -> None:
    resource.close()

那么函数只承诺使用 close(),也不需要保留具体实现类型。

两种写法的区别:

def close_only(resource: SupportsClose) -> None:
    只需要接口能力,不关心具体类型

def close_and_return[T: SupportsClose](resource: T) -> T:
    需要接口能力,并保留输入与输出之间的具体类型关系

这正是上界 TypeVarProtocol 配合的常见场景。


十、协议的组合:交集、联合与递归

10.1 组合多个协议形成更窄接口

Python 没有独立的交集类型运算符,但可以通过多继承协议表达“同时满足多个接口”:

from collections.abc import Hashable, Iterable
from typing import Protocol

class HashableIterable(Iterable[int], Hashable, Protocol):
    pass

它要求对象同时满足:

Iterable[int]
Hashable

元组通常同时满足这两个条件:

def cache_key(value: HashableIterable) -> int:
    return hash(value)

cache_key((1, 2, 3))

协议多继承表达的是结构约束,不是要求实现类按照相同的继承顺序组织 MRO。协议之间的可赋值关系仍然由成员集合决定。(typing.python.org)

10.2 联合类型

协议也可以放入联合类型:

class Exitable(Protocol):
    def exit(self) -> int:
        ...

class Quittable(Protocol):
    def quit(self) -> int | None:
        ...

def finish(task: Exitable | Quittable) -> int:
    if hasattr(task, "exit"):
        return task.exit()
    result = task.quit()
    return 0 if result is None else result

联合表示“满足其中一个协议”,而不是必须同时满足二者。(typing.python.org)

10.3 递归协议

协议可以引用自身:

from collections.abc import Iterable
from typing import Protocol

class TreeNode(Protocol):
    def children(self) -> Iterable["TreeNode"]:
        ...

实现:

class SimpleNode:
    def __init__(self, children: list["SimpleNode"]) -> None:
        self._children = children

    def children(self) -> list["SimpleNode"]:
        return self._children

因为 list[SimpleNode] 可以作为 Iterable[TreeNode] 使用,SimpleNode 可以实现 TreeNode。递归协议适合描述树、图节点、递归容器等自引用结构。(typing.python.org)


十一、@runtime_checkable:让协议参与有限的运行时检查

默认情况下,协议主要服务于静态类型检查:

from typing import Protocol

class Closable(Protocol):
    def close(self) -> None:
        ...

直接运行:

isinstance(open("data.txt"), Closable)

默认并不是可靠的运行时用法。若确实需要使用 isinstance(),可以显式添加 @runtime_checkable

from typing import Protocol, runtime_checkable

@runtime_checkable
class Closable(Protocol):
    def close(self) -> None:
        ...

然后:

with open("data.txt") as file:
    print(isinstance(file, Closable))

不过,运行时检查只适合检查成员是否存在,不等价于静态类型检查器对完整签名的检查:

@runtime_checkable
class Reader(Protocol):
    def read(self, size: int) -> bytes:
        ...

一个对象只要存在名为 read 的成员,就可能通过某些运行时协议检查,但运行时不会完整验证:

参数数量是否正确
参数类型是否为 int
返回值是否为 bytes
关键字参数名称是否兼容

因此:

静态检查:
验证接口成员及其类型关系

runtime_checkable:
提供有限的 isinstance / issubclass 能力
主要检查运行时可观察到的成员存在性

运行时检查是显式选择的能力;协议默认不参与 isinstance()issubclass(),并且 @runtime_checkable 对泛型协议和数据属性存在限制。(typing.python.org)

生产代码中,如果要判断对象能否安全调用,通常仍应结合显式异常处理:

def close_if_possible(value: object) -> None:
    if isinstance(value, Closable):
        try:
            value.close()
        except OSError as exc:
            print(f"close failed: {exc}")

isinstance() 只能减少明显的属性缺失错误,不能保证 close() 的行为一定符合业务约定。


十二、四种工具的选择关系

可以用下面的决策方式区分它们:

12.1 需要抽象一个普通值类型

使用 TypeVar

def identity[T](value: T) -> T:
    return value

12.2 需要保留函数的完整参数签名

使用 ParamSpec

def decorate[T, **P](
    func: Callable[P, T],
) -> Callable[P, T]:
    ...

12.3 需要抽象任意长度的异构类型序列

使用 TypeVarTuple

def pack[*Ts](*values: *Ts) -> tuple[*Ts]:
    return values

12.4 需要描述对象必须具备的接口

使用 Protocol

class SupportsClose(Protocol):
    def close(self) -> None:
        ...

12.5 需要“满足接口,同时保留具体类型”

组合 TypeVarProtocol

def close_and_return[T: SupportsClose](value: T) -> T:
    value.close()
    return value

12.6 需要“满足接口,同时保留参数签名”

组合 ProtocolParamSpec

from collections.abc import Callable
from typing import Protocol

class CallableObject[**P, T](Protocol):
    def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T:
        ...

更常见的实际写法是直接使用:

Callable[P, T]

只有当 callable 还需要额外成员时,才值得定义协议:

class Task[**P, T](Protocol):
    name: str

    def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T:
        ...

十三、一个综合示例:类型安全的任务包装器

下面把 TypeVarParamSpecProtocol 放在同一个例子中。

from collections.abc import Callable
from typing import Protocol, Concatenate


class Logger(Protocol):
    def info(self, message: str) -> None:
        ...


class ConsoleLogger:
    def info(self, message: str) -> None:
        print(f"[INFO] {message}")


def with_logger[T, **P](
    logger: Logger,
    func: Callable[Concatenate[Logger, P], T],
) -> Callable[P, T]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
        return func(logger, *args, **kwargs)

    return wrapper


def load_user(logger: Logger, user_id: int, verbose: bool = False) -> str:
    if verbose:
        logger.info(f"loading user {user_id}")
    return f"user-{user_id}"


load = with_logger(ConsoleLogger(), load_user)

print(load(42))
print(load(42, verbose=True))

静态推导过程如下。

原函数:

load_user: (Logger, user_id: int, verbose: bool = False) -> str

匹配:

Callable[Concatenate[Logger, P], T]

因此:

T = str
P = (user_id: int, verbose: bool = False)

返回的包装器类型为:

Callable[P, T]

也就是:

(user_id: int, verbose: bool = False) -> str

所以这些调用应被接受:

load(42)
load(42, verbose=True)
load(user_id=42)

这些调用应被拒绝:

load("42")
load(42, verbose="yes")
load()

Logger 使用协议,因此 ConsoleLogger 不需要继承 LoggerT 保留任务返回值类型,P 保留除注入参数外的原始调用签名,Concatenate 描述了注入参数的位置。

这个例子体现了三个独立约束:

ConsoleLogger 必须提供 info(str) -> None
func 必须接受 Logger 作为第一个位置参数
wrapper 必须保留 func 的其余参数 P 和返回值 T

十四、常见错误与诊断方法

14.1 用 Any 代替 TypeVar

下面的写法会丢失输入和输出之间的关系:

from typing import Any

def identity(value: Any) -> Any:
    return value

它允许调用者得到 Any,之后大量错误会被静默放过。

正确的泛型表达是:

def identity[T](value: T) -> T:
    return value

区别不是“是否能运行”,而是 Any 放弃了检查,而 T 建立了类型依赖。

14.2 用 Callable[..., Any] 标注所有装饰器

def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
    ...

这种写法适合确实无法保留签名的动态场景,但对于普通装饰器会丢失参数检查。若装饰器只是转发参数,应优先使用:

def decorator[T, **P](func: Callable[P, T]) -> Callable[P, T]:
    ...

14.3 把 TypeVarTuple 当作普通类型

错误:

def bad[*Ts](value: Ts) -> Ts:
    ...

正确:

def good[*Ts](value: tuple[*Ts]) -> tuple[*Ts]:
    ...

Ts 表示一组类型,必须在类型表达式中通过 *Ts 展开。

14.4 以为继承了协议才算实现

class Resource:
    def close(self) -> None:
        ...

它不需要写:

class Resource(Closable):
    ...

只要结构满足协议即可。显式继承协议有时可以增强文档性,但不是结构化实现的必要条件。

相反,普通类继承某个协议后,并不会自动使其子类都继续作为协议使用;要定义子协议,需要显式把 Protocol 放入基类列表。(typing.python.org)

14.5 把运行时 isinstance() 当成完整类型检查

if isinstance(value, Closable):
    value.close()

这只能提供有限的运行时判断。它不能验证协议方法的完整签名,也不能验证方法的业务语义。静态检查仍应在开发和 CI 阶段执行。


十五、在类型检查器中验证示例

可以将示例保存为 generics_demo.py,然后使用项目选定的类型检查器验证:

python generics_demo.py

这一步只检查运行时行为,不会执行静态类型分析。

使用 mypy 时:

mypy generics_demo.py

使用 Pyright 时:

pyright generics_demo.py

为了观察推导结果,可以在代码中加入检查器支持的类型揭示语句,例如:

def pack[*Ts](*values: *Ts) -> tuple[*Ts]:
    return values

result = pack(1, "x", True)

然后根据检查器输出确认 result 是否被推导为:

tuple[int, str, bool]

不同检查器在复杂泛型推导、协议成员兼容性和公共超类型选择上可能产生不同诊断。应把类型检查器版本、配置文件和 Python 目标版本固定在项目中,避免本地环境与 CI 对同一代码产生不一致判断。


十六、版本与兼容性边界

Python 3.12 引入的类型参数语法包括:

def f[T](value: T) -> T:
    ...

class Box[T]:
    ...

def call[**P](func: Callable[P, int]) -> None:
    ...

def pack[*Ts](*values: *Ts) -> tuple[*Ts]:
    ...

如果项目还需要支持 Python 3.11 或更早版本,应使用构造器形式:

from typing import ParamSpec, TypeVar, TypeVarTuple

T = TypeVar("T")
P = ParamSpec("P")
Ts = TypeVarTuple("Ts")

Python 3.13 为 TypeVarParamSpecTypeVarTuple 增加了默认类型参数支持;但默认参数并不是所有旧版本类型检查器都能同等处理,项目应同时确认解释器版本和检查器版本。ParamSpecbound、方差等部分语义仍存在规范化边界,不能仅根据运行时构造函数能够接受某个参数,就推断所有检查器已经以完全相同的方式支持它。(docs.python.org)


结语

这四种工具对应四种不同的抽象维度:

TypeVar
    一个普通类型,以及多个位置之间的类型依赖

ParamSpec
    一个 callable 的完整参数列表

TypeVarTuple
    任意长度的类型参数序列

Protocol
    由成员结构定义的接口

它们可以组合,但不能互相替代:

TypeVar       不能保留完整函数参数签名
ParamSpec     不能表示普通返回值类型本身
TypeVarTuple  不能代替单一类型变量
Protocol      不能自动完成运行时行为验证

理解它们的关键,不是记忆语法,而是先确定要表达哪一种关系:

输入值和输出值相同类型?
    TypeVar

装饰器前后参数不变?
    ParamSpec

元组或维度数量可变?
    TypeVarTuple

对象只需满足某个接口?
    Protocol

既满足接口又保留具体类型?
    TypeVar + Protocol

当类型标注准确表达了这些关系时,静态检查器才能在函数调用、泛型类实例化、装饰器包装、协议实现和跨模块接口处发现真正的错误;否则,代码虽然仍然能够运行,但类型系统只能退化为大量 Any 和运行时试错。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。