Python 泛型怎么写?TypeVar、Generic 和协变逆变是什么?
简化版
**泛型解决的是「类型之间的关联」——把 def first(xs: list) -> Any 这种「进去什么类型、出来就丢失类型」的签名,变成 def first(xs: list[T]) -> T:传 list[int] 就返回 int,传 list[str] 就返回 str。**核心工具三个:① TypeVar("T")——类型变量,代表「某个还没确定、但同一次调用里必须一致的类型」;② Generic[T]——让自定义类变成泛型容器(class Stack(Generic[T]),用的时候写 Stack[int]);③ 约束 TypeVar——TypeVar("T", bound=Number)(上界:必须是 Number 的子类)和 TypeVar("T", int, str)(枚举约束:只能是这两个之一)。协变/逆变回答的是「list[Dog] 算不算 list[Animal]」:只读容器协变(Sequence[Dog] 可以当 Sequence[Animal] 用)、可写容器不变(list[Dog] 不是 list[Animal],因为你可能往里塞只 Cat)、函数参数逆变(能处理 Animal 的函数可以当作处理 Dog 的函数用)。Python 3.12+ 有了新语法:def first[T](xs: list[T]) -> T: 和 class Stack[T]:,不用再手动 TypeVar。关键前提:这一切运行时完全不检查,只有 mypy/pyright 这类静态检查器才会验证。核心记忆:泛型 = 用 TypeVar 把「输入类型」和「输出类型」绑在一起;只读协变、可写不变、参数逆变。
详细版
四类工具速查:
| 工具 | 写法 | 用途 |
|---|---|---|
| 类型变量 | T = TypeVar("T") | 关联输入输出类型 |
| 上界 | TypeVar("N", bound=float) | T 必须是 float 的子类型 |
| 值约束 | TypeVar("S", str, bytes) | T 只能是这两者之一(不能是共同父类) |
| 泛型类 | class Box(Generic[T]) | 自定义泛型容器 |
| 协变 | TypeVar("T_co", covariant=True) | 只读容器(Sequence、Iterable) |
| 逆变 | TypeVar("T_contra", contravariant=True) | 只吃不吐(Callable 的参数) |
| 参数规格 | ParamSpec("P") | 装饰器保留原函数签名 |
| 3.12 新语法 | def f[T](x: T) -> T: | 免声明 TypeVar |
from typing import TypeVar, Generic, Sequence, Callable, ParamSpec
from collections.abc import Iterable
T = TypeVar("T")
# ① 函数泛型:把输入类型和输出类型绑定
def first(xs: Sequence[T]) -> T:
return xs[0]
a = first([1, 2, 3]) # 检查器推断 a: int
b = first(["x", "y"]) # 检查器推断 b: str
# 若写成 -> Any,上面两行都会退化成 Any,后续所有检查失效
# ② 泛型类
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
s: Stack[int] = Stack()
s.push(1)
# s.push("x") # ✗ mypy 报错:Argument 1 has incompatible type "str"
# ③ bound:上界约束
N = TypeVar("N", bound=float) # int 也满足(int 是 float 的"鸭子子类型")
def total(xs: Iterable[N]) -> N: ...
# ④ 值约束:只能是列举的那几个之一
S = TypeVar("S", str, bytes) # ★注意和 bound 的区别★
def join(sep: S, parts: list[S]) -> S: ...
join("-", ["a", "b"]) # ✓ 全是 str
# join("-", [b"a"]) # ✗ 混用 str 和 bytes 报错
# ⑤ 协变 vs 不变(最常考)
class Animal: ...
class Dog(Animal): ...
def feed_all(animals: Sequence[Animal]) -> None: ... # 只读 → 协变
def add_cat(animals: list[Animal]) -> None: # 可写 → 不变
animals.append(Animal())
dogs: list[Dog] = [Dog()]
feed_all(dogs) # ✓ Sequence[Dog] 兼容 Sequence[Animal](协变)
# add_cat(dogs) # ✗ list[Dog] 不兼容 list[Animal](不变)——否则函数会往里塞非 Dog
# ⑥ ParamSpec:写装饰器时保留原签名
P = ParamSpec("P")
R = TypeVar("R")
def logged(fn: Callable[P, R]) -> Callable[P, R]: # 参数类型原样透传
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return fn(*args, **kwargs)
return wrapper
# ⑦ Python 3.12+ 新语法(不用声明 TypeVar)
# def first[T](xs: Sequence[T]) -> T: return xs[0]
# class Stack[T]: ...
# def total[N: float](xs: Iterable[N]) -> N: ... # 冒号即 bound
⚠️ 三个最容易混的点:①
bound=和「值约束」不是一回事。TypeVar("N", bound=float)表示「T 是 float 的某个子类型」,此时T可以被推断成更精确的子类(传bool就是bool);而TypeVar("S", str, bytes)是枚举约束,T只能恰好是str或bytes之一,不会推断成两者的共同父类,也不接受第三种类型。② 协变/逆变只影响静态检查器判断「A 能不能当 B 用」,和运行时毫无关系——list[Dog]在运行时就是个普通 list,塞什么都行;变型规则的存在是为了在编译期挡住「往list[Dog]里塞Cat」这类真实 bug。③ 参数标注要「尽量宽」、返回标注要「尽量准」:参数写Sequence[T]/Iterable[T](协变、调用方传 list、tuple 都行),返回写具体的list[T];反过来(参数写list[T]、返回写Iterable[T])会让函数既难被调用、返回值又难被使用。这条准则比背诵变型定义更实用。
完整版教学
一、为什么需要 TypeVar:类型信息的「丢失」
问题:不用泛型时,类型信息在函数边界处丢了
def first(xs: list) -> Any: # 或者根本不写标注
return xs[0]
n = first([1, 2, 3])
n.upper() # ← 检查器不报错!因为 n 是 Any,什么都能调
→ 运行时才炸:AttributeError: 'int' object has no attribute 'upper'
用 TypeVar 把"进"和"出"绑在一起:
T = TypeVar("T")
def first(xs: Sequence[T]) -> T:
return xs[0]
n = first([1, 2, 3]) # 检查器推断 T = int → n: int
n.upper() # ✗ 检查器立刻报错:int 没有 upper
s = first(["a", "b"]) # 检查器推断 T = str → s: str
s.upper() # ✓
TypeVar 的语义 = "同一次调用里,出现 T 的地方必须是同一个类型"
def pair(a: T, b: T) -> tuple[T, T]: ...
pair(1, 2) ✓ T = int
pair(1, "x") ✗ 报错(一个 int 一个 str,T 无法统一)
★(实际上检查器会尝试推断共同父类 object,
多数配置下会给出 incompatible types 警告)
和"随便写个 Any"的区别:
Any :放弃检查(病毒式扩散——碰到 Any 的地方全变成不检查)
TypeVar :延迟到调用点再确定,检查一点不少
★ 关键认知:写 Any 不是"不知道类型",而是"关掉这块的类型检查"
真的不知道就写 object(object 什么都能接,但用之前必须先收窄)
TypeVar 存在的唯一理由是保住类型信息的流动。没有它,「取列表第一个元素」这种函数只能返回 Any,而 Any 的杀伤力在于传染:返回值是 Any,赋给的变量是 Any,再传给别的函数又变成 Any——一个偷懒的标注能让下游几十行代码全部失去检查。TypeVar 的语义很朴素:它是个占位符,表示「这里是某个类型,具体是什么等调用时再定,但同一次调用中所有 T 必须是同一个类型」。于是 first([1,2,3]) 的返回值被推断为 int,紧接着写 .upper() 会当场报错,而不是等到线上。顺带纠正一个常见混淆:Any 不是「不知道类型」的意思,而是「关掉这里的类型检查」;真的不知道类型应该写 object(能接受一切,但使用前必须先 isinstance 收窄)。
二、bound 与值约束:两种收紧方式
不加约束的 T:可以是任何类型
T = TypeVar("T")
def double(x: T) -> T:
return x + x # ✗ 报错!因为 T 可能是任何类型,不保证支持 +
方式一:bound(上界)—— "T 必须是 X 的子类型"
N = TypeVar("N", bound=float)
def double(x: N) -> N:
return x + x # ✓ float 支持 +,所以任何子类型也支持
推断行为:保留调用时的★具体类型★
double(3) → N = int (int 兼容 float:数字塔特例)
double(3.5) → N = float
double(True) → N = bool
常见 bound 用法:
TypeVar("T", bound="Node") # 自引用(返回自身类型的方法)
TypeVar("T", bound=Comparable) # 必须可比较(Protocol)
TypeVar("T", bound=BaseModel) # 必须是某框架基类的子类
方式二:值约束 —— "T 只能是列出来的那几个之一"
S = TypeVar("S", str, bytes)
def join(sep: S, parts: list[S]) -> S: ...
推断行为:★只会是列出的类型之一,绝不合并★
join("-", ["a"]) → S = str ✓
join(b"-", [b"a"]) → S = bytes ✓
join("-", [b"a"]) → ✗ 报错(不会退化成共同父类)
两者的核心差别(面试常问):
┌──────────┬──────────────────────┬───────────────────────┐
│ │ bound=X │ 值约束 (A, B) │
├──────────┼──────────────────────┼───────────────────────┤
│ 允许的类型│ X 的所有子类型(无限)│ 只有 A 和 B(有限枚举)│
│ 推断结果 │ 调用时的具体子类 │ 必然是 A 或 B 之一 │
│ 子类传入 │ 推断为该子类 │ ★推断为 A 或 B★(向上取)│
│ 典型场景 │ "至少得是个 X" │ "只支持这两种" │
└──────────┴──────────────────────┴───────────────────────┘
值约束的一个坑:传 str 的子类
class MyStr(str): ...
join(MyStr("-"), [MyStr("a")]) → S 被推断为 ★str★(不是 MyStr)
→ 返回值类型是 str,丢失了子类信息;bound 则会保留 MyStr
3.12 新语法里的写法:
def double[N: float](x: N) -> N: ... # 冒号 = bound
def join[S: (str, bytes)](sep: S, ...) -> S: ... # 元组 = 值约束
裸 TypeVar 太宽——def double(x: T) -> T: return x + x 会被检查器拒绝,因为 T 可能是任何类型、不保证支持 +。收紧有两条路,面试极爱考它们的区别:bound=X 是「上界」,允许 X 的所有子类型(无限多种),并且推断时保留调用者的具体子类,最适合表达「至少得是个 X」(如 bound=BaseModel、bound=Comparable);值约束 TypeVar("S", str, bytes) 是「枚举」,只允许列出的这几种,且传入子类时会向上取到列出的那个类型(MyStr 会被推断成 str,丢失子类信息),适合表达「只支持这两种,且不许混用」——标准库 re 模块的签名就是这么写的(同一次调用里 pattern 和 string 必须同为 str 或同为 bytes)。另外要记住 bound 支持字符串前向引用(bound="Node"),这是写「返回自身类型」的方法时的常用技巧。
三、泛型类:Generic[T] 与 Self
定义泛型类:
T = TypeVar("T")
class Box(Generic[T]): # ← 继承 Generic[T] 才是泛型类
def __init__(self, item: T) -> None:
self.item = item
def get(self) -> T:
return self.item
b1 = Box(3) # 检查器推断 Box[int]
b2: Box[str] = Box("x") # 显式标注
b1.get().upper() # ✗ 报错:int 没有 upper
多个类型参数:
K = TypeVar("K"); V = TypeVar("V")
class Cache(Generic[K, V]):
def get(self, k: K) -> V | None: ...
c: Cache[str, bytes] = Cache()
继承泛型类的三种姿势(区别很关键):
class IntBox(Box[int]): ... # ① 固定住:IntBox 不再是泛型
class MyBox(Box[T]): ... # ② 继续泛型:MyBox[str] 仍可指定
class Weird(Box): ... # ③ 相当于 Box[Any]:★放弃检查★(别这么写)
★ 返回"自身类型"的正确写法:用 Self(3.11+),不要用 TypeVar 硬凑
from typing import Self
class QueryBuilder:
def where(self, cond: str) -> Self: # 子类调用时返回子类类型
...
return self
class MyBuilder(QueryBuilder): ...
MyBuilder().where("x") # 推断为 MyBuilder(不是 QueryBuilder)✓
→ 3.11 之前的老写法:TSelf = TypeVar("TSelf", bound="QueryBuilder"),
def where(self: TSelf, ...) -> TSelf (能用但啰嗦)
运行时行为(很多人误解):
Box[int] # 运行时返回一个 _GenericAlias 对象,★不会创建新类★
Box[int] is Box[int] # False(每次都新建别名对象)
isinstance(b, Box[int]) # ✗ TypeError: 不能对带参泛型做 isinstance
isinstance(b, Box) # ✓ 只能检查"擦除参数后"的类
→ 参数在运行时是被"擦除"的:Box[int](3) 和 Box(3) 造出的对象一模一样
3.12 新语法:
class Box[T]: # 不用继承 Generic,也不用声明 TypeVar
def __init__(self, item: T) -> None: ...
class Cache[K, V]: ...
自定义泛型容器(缓存、结果包装、Repository、Builder)时用 Generic[T]。三个必须掌握的点:① 继承泛型类的三种姿势语义完全不同——Box[int] 是固定参数(子类不再泛型)、Box[T] 是继续保持泛型、而光写 Box 等于 Box[Any],直接放弃了这块的类型检查(最常见的无声失效)。② 返回自身类型请用 Self(3.11+):链式 API(QueryBuilder.where().order_by())如果把返回标成父类名,子类调用后的类型就退化成父类,后续调用子类特有方法会误报;Self 让检查器把返回类型绑定到实际调用的那个类。③ 泛型参数在运行时是「擦除」的——Box[int] 只是生成一个 _GenericAlias 对象,并不创建新类,Box[int](3) 和 Box(3) 造出的对象完全一样,isinstance(b, Box[int]) 直接抛 TypeError(只能 isinstance(b, Box))。这也再次说明:泛型是给静态检查器看的,运行时什么都不做。
四、协变、逆变、不变:list[Dog] 到底是不是 list[Animal]
先看反例,理解为什么"可写容器必须不变":
class Animal: ...
class Dog(Animal): ...
class Cat(Animal): ...
def add_cat(xs: list[Animal]) -> None:
xs.append(Cat()) # 完全合法:Cat 是 Animal
dogs: list[Dog] = [Dog(), Dog()]
add_cat(dogs) # ★假如允许★
dogs[2].bark() # 💥 运行时炸:Cat 没有 bark()
→ 所以 list[Dog] ★不能★当 list[Animal] 用 → list 是"不变(invariant)"的
只读容器就安全了:
def feed_all(xs: Sequence[Animal]) -> None:
for a in xs: a.eat() # Sequence 没有 append,塞不进去
feed_all(dogs) # ✓ 允许:Sequence[Dog] 兼容 Sequence[Animal]
→ Sequence/Iterable/Mapping 的值类型等只读位置是"协变(covariant)"
函数参数为什么反过来(逆变):
handler: Callable[[Dog], None] # 需要一个"能处理 Dog"的函数
def handle_animal(a: Animal) -> None: ... # 它能处理任何 Animal
handler = handle_animal # ✓ 允许!
→ 因为"能处理所有 Animal"的函数,当然也能处理 Dog(能力更强,可以顶替)
反之:
def handle_dog(d: Dog) -> None: d.bark()
h2: Callable[[Animal], None] = handle_dog # ✗ 不允许
→ 调用方可能传只 Cat 进来,而 handle_dog 处理不了
三条规则汇总(背这张表):
┌──────────────┬──────────┬────────────────────────────────┐
│ 位置 │ 变型 │ 记忆方法 │
├──────────────┼──────────┼────────────────────────────────┤
│ 只读/输出位置 │ 协变 │ 只吐不吃 → 跟着子类型走(顺) │
│ 可读写 │ 不变 │ 又吃又吐 → 只能一模一样 │
│ 输入/参数位置 │ 逆变 │ 只吃不吐 → 反着来(父类型可顶替) │
└──────────────┴──────────┴────────────────────────────────┘
Callable[[入参], 返回值] 一句话验证:★参数逆变、返回值协变★
Callable[[Animal], Dog] 可以当 Callable[[Dog], Animal] 用
(要求宽、给得准 → 永远安全)
自己声明变型(写自定义协议/容器时才需要):
T_co = TypeVar("T_co", covariant=True) # 名字约定后缀 _co
T_contra= TypeVar("T_contra", contravariant=True) # 后缀 _contra
class ReadOnlyBox(Generic[T_co]):
def get(self) -> T_co: ... # ✓ 只出现在返回位置
# def put(self, x: T_co): ... # ✗ 协变量不能出现在参数位置(检查器报错)
3.12+ 可以让检查器★自动推断变型★:class ReadOnlyBox[T]: ... (PEP 695)
变型是泛型里唯一需要动脑的部分,但只要记住那个 add_cat 反例就够了:如果允许 list[Dog] 当 list[Animal] 用,函数就能往里 append 一只 Cat,调用方后续拿 dogs[2].bark() 直接崩——所以可写容器必须「不变」。反过来,Sequence 没有 append,塞不进东西,于是「只读容器协变」是安全的。函数参数的「逆变」看着反直觉,其实也很朴素:要求越宽的函数越能顶替要求窄的函数——一个能处理任何 Animal 的回调,当然可以放在「需要处理 Dog 的回调」的位置上。汇总成一句可操作的准则:Callable 的参数逆变、返回值协变,即「参数要得宽、返回给得准」的函数永远可以顶替「参数要得窄、返回给得宽」的函数。日常写业务代码通常不需要手动声明 covariant=True(标准库的 Sequence、Iterable 已经声明好了),只有自己写协议或只读容器时才用得上;3.12 的 PEP 695 更进一步,让检查器自动推断变型。
五、装饰器与泛型:ParamSpec、Concatenate、TypeVarTuple
问题:给函数写装饰器时,签名会被"吃掉"
✗ 老写法(类型全丢):
def logged(fn: Callable[..., Any]) -> Callable[..., Any]:
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@logged
def add(a: int, b: int) -> int: ...
add("x") # ← 检查器不报错!参数和返回类型全变成了 Any
✓ ParamSpec(3.10+,3.9 及以下用 typing_extensions):
P = ParamSpec("P") # 代表"整个参数列表"(位置 + 关键字)
R = TypeVar("R")
def logged(fn: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return fn(*args, **kwargs)
return wrapper
add("x") # ✗ 现在能正确报错了:参数类型不匹配
★ P.args 和 P.kwargs 必须成对出现在同一个函数签名里,不能拆开用
Concatenate:装饰器"多塞一个参数"或"吃掉一个参数"
from typing import Concatenate
# 装饰后自动注入 conn,调用方不必再传
def with_conn(fn: Callable[Concatenate[Conn, P], R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return fn(get_conn(), *args, **kwargs)
return wrapper
TypeVarTuple(3.11+,PEP 646):可变数量的类型参数
Ts = TypeVarTuple("Ts")
def unpack(t: tuple[*Ts]) -> tuple[*Ts]: ...
→ 主要为 numpy/张量库的"多维形状"标注而生,业务代码很少用
3.12 新语法一并简化:
def logged[**P, R](fn: Callable[P, R]) -> Callable[P, R]: ... # ** 表示 ParamSpec
def unpack[*Ts](t: tuple[*Ts]) -> tuple[*Ts]: ... # * 表示 TypeVarTuple
★ 别忘了配合 functools.wraps(那是运行时的元信息,和类型标注是两回事)
写装饰器是泛型标注最能体现价值、也最容易做错的场景。老写法 Callable[..., Any] 会把被装饰函数的参数类型和返回类型全部抹成 Any——装饰器加得越多,项目的类型检查越形同虚设,add("x") 这种明显错误也不会报。ParamSpec(3.10+)代表「整个参数列表」,配合 *args: P.args, **kwargs: P.kwargs 就能把签名原样透传,装饰后的函数依然享有完整检查(注意 P.args 和 P.kwargs 必须成对出现在同一签名里)。Concatenate 处理更进阶的情况:装饰器自动注入一个参数(数据库连接、当前用户)从而让调用方少传一个参数——它精确表达了「原函数要 (Conn, P),包装后只要 (P)」。TypeVarTuple(3.11+)则是为张量/多维数组的形状标注设计的,业务代码基本用不到。最后提醒一句:这些是静态层面的签名保持,运行时的 __name__、__doc__ 保持仍然要靠 functools.wraps,两者互不替代。
六、实践准则与常见反模式
准则 1:参数要宽、返回要准
✓ def process(items: Iterable[T]) -> list[T]:
调用方传 list/tuple/生成器都行;拿到的返回值有确切的 list 方法
✗ def process(items: list[T]) -> Iterable[T]:
调用方传 tuple 就报错;返回值想 len() 还得先转换
→ 这条准则的本质就是变型规则的日常化版本
准则 2:一个 TypeVar 只在"有关联"时才用
✗ def f(a: T, b: T) -> None: # 只是想说"两个参数",却强制它们同类型
✓ def f(a: T, b: U) -> None: # 无关就用不同的 TypeVar
✓ def f(a: object, b: object) # 完全不关心类型就用 object
准则 3:泛型别嵌套太深
✗ dict[str, list[tuple[int, Callable[[str], dict[str, Any]]]]]
✓ 用 TypeAlias 起名字:
Handler: TypeAlias = Callable[[str], dict[str, Any]]
Registry: TypeAlias = dict[str, list[tuple[int, Handler]]]
(3.12+:type Handler = Callable[[str], dict[str, Any]])
反模式 1:用 Any "消灭"报错
检查器报错 → 加个 Any 或 # type: ignore → 报错消失、bug 留下
✓ 正确做法:想清楚这里到底允许哪些类型,用 TypeVar / 联合类型 / Protocol 表达
反模式 2:以为泛型有运行时效果
def f(x: T) -> T: ...
f("字符串") # 传什么都能跑,运行时★不做任何检查★
→ 想要运行时校验:pydantic、beartype、或手写 isinstance
→ typing 只在 mypy/pyright 跑起来的时候才有意义(CI 里必须真的跑)
反模式 3:泛型类忘了写参数
class Repo(Generic[T]): ...
def get_repo() -> Repo: # ✗ 等价于 Repo[Any],下游全部失去检查
def get_repo() -> Repo[User]: # ✓
→ mypy 的 --disallow-any-generics 可以把这类问题全部揪出来
工程建议:
① 新项目直接上 3.12 新语法(def f[T],无需 TypeVar 声明),可读性高一截
② CI 里跑 mypy --strict 或 pyright,否则所有标注都只是注释
③ 库代码(被别人调用)值得认真写泛型;纯内部脚本按需即可
最后收敛成可执行的准则。「参数要宽、返回要准」是变型规则的日常化版本,也是收益最高的一条:参数标 Iterable[T]/Sequence[T] 让调用方传什么容器都行,返回标具体的 list[T] 让调用方能直接用 len() 和索引。别滥用同一个 TypeVar——def f(a: T, b: T) 是在强制两个参数必须同类型,若本意只是「两个参数」,该用不同的类型变量。嵌套超过两层就用 TypeAlias 起名字(3.12 起是 type X = ...),否则签名会长到没人愿意读。三个反模式尤其要警惕:用 Any 或 # type: ignore 消灭报错(报错没了、bug 还在);以为泛型有运行时效果(运行时完全不检查,要校验得上 pydantic 或手写 isinstance);以及泛型类忘了写参数(-> Repo 等价于 Repo[Any],下游整条链失去检查,用 mypy 的 --disallow-any-generics 可以全部揪出来)。归根结底一句话:标注只有在 CI 里真的跑了检查器才有价值。
记忆钩子:「泛型 = 用
TypeVar把『输入类型』和『输出类型』绑在一起,避免类型信息在函数边界丢成Any(Any会传染、等于关掉这一片的检查)。四件套:T = TypeVar('T')关联类型、Generic[T]定义泛型类、bound=X是上界(无限子类型、推断保留具体子类)、TypeVar('S', str, bytes)是枚举约束(只能是列出的那几个、传子类会向上取到 str)。变型只需记住那个反例:如果list[Dog]能当list[Animal]用,函数就能往里 append 一只 Cat,调用方dogs[2].bark()当场崩——所以★可写容器不变、只读容器(Sequence/Iterable)协变、函数参数逆变★(能处理 Animal 的回调可以顶替需要处理 Dog 的回调),一句话就是Callable的『参数逆变、返回协变』,日常化版本叫『参数要宽、返回要准』。装饰器用ParamSpec保签名(老写法Callable[..., Any]会把参数和返回全抹成 Any),返回自身类型用Self(3.11+)。★最大前提:这一切运行时完全不检查★——泛型参数是擦除的,Box[int](3)和Box(3)造出的对象一样,isinstance(b, Box[int])直接 TypeError;只有 mypy/pyright 在 CI 里真跑起来,标注才有意义。3.12 起有新语法def f[T](...)、class Box[T]、type X = ...,不用再声明 TypeVar。」
七、常见误区与追问
- 误区:加了泛型标注,运行时传错类型会报错。 完全不会——Python 的类型标注在运行时只是存进
__annotations__的元数据,解释器不做任何校验:def first(xs: Sequence[T]) -> T传个整数进去照样执行到真正出错的那一行才崩。更进一步,泛型参数在运行时是被擦除的:Box[int]只是生成一个_GenericAlias对象、不创建新类,Box[int](3)和Box(3)造出的实例一模一样,而isinstance(b, Box[int])会直接抛TypeError(只能isinstance(b, Box))。要在运行时真正校验,得用 pydantic、beartype 这类库或手写isinstance。所以标注的价值完全依赖 CI 里真的跑 mypy/pyright,否则它们只是好看的注释。 - 误区:
TypeVar("T", bound=X)和TypeVar("T", A, B)只是写法不同。 语义差别很大。bound=X是上界:允许X的所有子类型(无限多种),推断时保留调用者的具体子类——bound=str传MyStr就推断成MyStr。TypeVar("T", A, B)是值约束(枚举):只允许恰好是A或B,传子类会向上取到列出的那个类型——TypeVar("S", str, bytes)传MyStr会推断成str,子类信息丢失;而且它天然禁止「一次调用里混用 A 和 B」(这正是re模块用它保证 pattern 和 string 同为 str 或同为 bytes 的原因)。选择标准:想表达「至少得是个 X」用bound,想表达「只支持这两种且不许混」用值约束。 - 误区:
list[Dog]是list[Animal]的子类型,毕竟 Dog 是 Animal。 不是——list是不变(invariant) 的。反例很有说服力:def add_cat(xs: list[Animal]): xs.append(Cat())完全合法,如果允许把list[Dog]传进去,回来再执行dogs[2].bark()就会在运行时崩溃。可写容器必须不变,这是类型安全的硬性要求。想让调用方能传list[Dog],就把参数标成只读的Sequence[Animal]或Iterable[Animal](它们是协变的,因为没有append,塞不进东西)——这也正是「参数要尽量宽」这条实践准则的由来。 - 误区:函数参数的变型规则和容器一样,
Callable[[Animal], None]可以当Callable[[Dog], None]用……反过来才对。 函数参数是逆变的:能接受Animal的函数可以放在「需要一个接受Dog的函数」的位置(它能力更强、什么动物都处理得了);而只接受Dog的函数不能放在「需要接受Animal的函数」的位置(调用方可能传只Cat进来,它处理不了)。返回值则是协变的(返回Dog的函数可以顶替声明返回Animal的函数)。合起来记:Callable参数逆变、返回值协变,也就是「要求得宽、给得准」的函数永远能顶替「要求得窄、给得宽」的函数。 - 误区:写装饰器时把签名标成
Callable[..., Any]就够了。 这会把被装饰函数的参数类型和返回类型全部抹成Any:@logged之后add("x", "y")这种明显的类型错误检查器一句都不报,而且装饰器用得越多、项目里被抹掉检查的函数越多,最后类型检查形同虚设。正确做法是用ParamSpec(3.10+):def logged(fn: Callable[P, R]) -> Callable[P, R],内部写*args: P.args, **kwargs: P.kwargs(这两者必须成对出现在同一个签名里),签名就被原样透传了。如果装饰器要注入或吃掉参数(比如自动注入数据库连接),用Concatenate[Conn, P]精确表达。另外别忘了functools.wraps——那是运行时的__name__/__doc__保持,和静态签名保持是两回事,两个都要写。 - 追问:为什么说
Any会「传染」,它和object有什么区别?Any的语义是「关闭这里的类型检查」:任何类型都能赋给Any,Any也能赋给任何类型,而且对Any值做任何操作(调用任意方法、任意运算)检查器都不报错。于是一个返回Any的函数,它的返回值赋给的变量是Any,再传给下一个函数、参与运算,Any就沿着数据流一路扩散,最终一大片代码失去检查——这就是「传染」。object则相反:它是所有类型的共同父类,任何值都能赋给object变量,但你几乎不能对它做任何操作(连x + 1都会报错),必须先用isinstance收窄类型才能使用。所以「我不知道这是什么类型、但确实需要接受一切」应该写object(检查器会强制你收窄,安全);写Any意味着「我放弃这块的类型安全」,只应在与无标注的第三方库交互等边界场景短暂使用。 - 追问:Python 3.12 的 PEP 695 新语法带来了什么变化? 三方面。① 不用再显式声明
TypeVar:def first[T](xs: Sequence[T]) -> T:和class Stack[T]:直接在定义处声明类型参数,作用域也严格限定在这个函数/类内部——旧写法里T = TypeVar("T")是模块级变量,多个不相关的函数共用一个T容易造成理解混乱。② 约束语法更紧凑:def f[N: float](...)表示 bound,def f[S: (str, bytes)](...)表示值约束,def f[**P, R](...)表示 ParamSpec,def f[*Ts](...)表示 TypeVarTuple。③ 变型自动推断:泛型类不再需要手动声明covariant=True/contravariant=True,检查器会根据类型参数出现在什么位置(只在返回位置 → 协变,只在参数位置 → 逆变,两者都有 → 不变)自动推断。此外还引入了type X = ...的类型别名语句(替代X: TypeAlias = ...)。代价是这些都是语法特性,3.11 及以下的解释器连文件都无法import。 - 追问:什么时候该用
Protocol而不是TypeVar(bound=...)? 两者都在表达「T 需要具备某种能力」,区别在于要求「是什么」还是「能做什么」。bound=SomeClass是名义子类型:要求传入的类型必须在继承链上是SomeClass的子类——适合你自己控制整个继承体系的情况(bound=BaseModel、bound=Enum)。Protocol是结构子类型(鸭子类型的静态版):只要一个类实现了协议里声明的方法/属性,就自动兼容,不需要显式继承——适合「我只关心它有.read()方法」「只要它可比较」这类需求,尤其是当对象来自第三方库、你无法让它去继承你的基类时。两者还能配合:T = TypeVar("T", bound=Comparable),其中Comparable是一个声明了__lt__的Protocol——既表达了「必须可比较」,又不要求任何人显式继承。
八、加强记忆
泛型的目的只有一个:让类型信息穿过函数边界不丢失。def first(xs: list) -> Any 会把返回值抹成 Any,而 Any 是会传染的(等于关掉这一片检查,和「不知道类型」的 object 完全不同);def first(xs: Sequence[T]) -> T 则把「传进去什么」和「返回什么」绑定起来。四件套:TypeVar("T") 声明类型变量(同一次调用里所有 T 必须一致)、Generic[T] 定义泛型类(继承时 Box[int] 固定参数、Box[T] 保持泛型、光写 Box 等于 Box[Any] 无声失效)、bound=X 是上界(无限子类型、推断保留具体子类)、TypeVar("S", str, bytes) 是枚举约束(只能是列出的那几个、传子类会向上取、禁止混用)。变型只需记住一个反例:若 list[Dog] 能当 list[Animal] 用,函数就能往里 append 一只 Cat,调用方 dogs[2].bark() 当场崩溃——所以可写容器不变、只读容器(Sequence/Iterable)协变、函数参数逆变(能处理 Animal 的回调可以顶替需要处理 Dog 的回调),合起来就是 Callable 的**「参数逆变、返回值协变」,日常化版本叫「参数要宽、返回要准」。进阶工具:装饰器用 ParamSpec 保住签名(老写法 Callable[..., Any] 会把参数和返回全抹成 Any)、链式 API 返回自身类型用 Self(3.11+)、需要「能做什么」而非「是什么」时用 Protocol 代替 bound。最大的前提别忘:这一切运行时完全不检查**,泛型参数会被擦除(Box[int](3) 与 Box(3) 造出的对象相同、isinstance(b, Box[int]) 直接 TypeError),所以标注只有在 CI 里真的跑 mypy/pyright 时才有价值。Python 3.12(PEP 695) 起可以写 def f[T](...)、class Box[T]、def f[**P, R](...)、type X = ...,并自动推断变型,代价是 3.11 及以下无法 import。