← 返回题目列表

Python 泛型怎么写?TypeVar、Generic 和协变逆变是什么?

困难 第 27 / 27 题 更新于 2026/07/31
泛型TypeVarGeneric协变逆变typing

简化版

**泛型解决的是「类型之间的关联」——把 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)只读容器(SequenceIterable
逆变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 只能恰好是 strbytes 之一,不会推断成两者的共同父类,也不接受第三种类型。② 协变/逆变只影响静态检查器判断「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=BaseModelbound=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(标准库的 SequenceIterable 已经声明好了),只有自己写协议或只读容器时才用得上;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.argsP.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 把『输入类型』和『输出类型』绑在一起,避免类型信息在函数边界丢成 AnyAny 会传染、等于关掉这一片的检查)。四件套: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=strMyStr 就推断成 MyStrTypeVar("T", A, B)值约束(枚举):只允许恰好是 AB,传子类会向上取到列出的那个类型——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 的语义是「关闭这里的类型检查」:任何类型都能赋给 AnyAny 也能赋给任何类型,而且对 Any 值做任何操作(调用任意方法、任意运算)检查器都不报错。于是一个返回 Any 的函数,它的返回值赋给的变量是 Any,再传给下一个函数、参与运算,Any 就沿着数据流一路扩散,最终一大片代码失去检查——这就是「传染」。object 则相反:它是所有类型的共同父类,任何值都能赋给 object 变量,但你几乎不能对它做任何操作(连 x + 1 都会报错),必须先用 isinstance 收窄类型才能使用。所以「我不知道这是什么类型、但确实需要接受一切」应该写 object(检查器会强制你收窄,安全);写 Any 意味着「我放弃这块的类型安全」,只应在与无标注的第三方库交互等边界场景短暂使用。
  • 追问:Python 3.12 的 PEP 695 新语法带来了什么变化? 三方面。① 不用再显式声明 TypeVardef 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=BaseModelbound=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