《Python编程入门》9.2 泛型、Protocol、TypedDict 与 PEP 695

当同一段逻辑要处理多种类型时就需要泛型。本节从 TypeVar 与 bound 讲起,给出泛型函数与泛型类的写法,对照 3.12 的 PEP 695 新语法如何简化 type X = ...、class C[T] 与 def f[T];随后深入 Protocol 子类型、TypedDict、NamedTuple、@overload、Self 与 3.13 的 TypeIs,并讲清变型的直觉。

本节目标:掌握泛型函数/泛型类、Protocol 结构化子类型、TypedDict、overload、Self、TypeIs 的写法,并会用 3.12 的 PEP 695 新语法替代旧写法。
适用版本:Python 3.12+(实测 3.14.6)

9.2 泛型、Protocol、TypedDict 与 PEP 695

9.1 节我们学会了给单个函数写注解。但真实项目里,很多函数和容器是「类型无关」的:first([1, 2]) 返回 int,first(["a", "b"]) 返回 str,逻辑完全一样。这类需求靠**泛型(generics)**表达。本节还会补齐几个进阶工具:Protocol、TypedDict、overload、Self、TypeIs。

9.2.1 为什么需要泛型

先看不用泛型的痛苦。如果写 def first(items: list) -> object,调用方拿到的是 object,想用还得手动断言类型;如果写 def first(items: list[int]) -> int,就只能处理 int。泛型的思路是:用一个「类型变量」占位,让调用时的实际类型自动填进去。

9.2.2 TypeVar 与泛型函数

经典写法用 TypeVar 声明一个类型变量:

from typing import TypeVar

T = TypeVar("T")

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

print(first_old([1, 2]))
print(first_old(["a", "b"]))

输出:

1
a

T 只是一个符号,检查器会在调用处把 T 绑定成具体类型。注意:运行时 T 就是 typing.TypeVar 对象,本身不代表任何值。

9.2.3 bound 与约束

有时希望类型变量「限定范围」。两种写法:

from typing import TypeVar

Num = TypeVar("Num", int, float)            # 约束:只能是 int 或 float
Comparable = TypeVar("Comparable", bound=int)  # 上界:必须是 int 的子类

print(Num, Comparable)
~Num ~Comparable
  • 约束(constraints):TypeVar("Num", int, float) 限定只能是列出的这几个类型之一。
  • 上界(bound):bound=int 表示「必须是 int 或其子类」,类型变量上可以调用 int 的所有方法。

约束是「离散集合」,bound 是「继承关系」,不要混用。

9.2.4 泛型类与 Generic

类也可以泛型化。旧写法需要继承 Generic[T]:

from typing import Generic, TypeVar

T = TypeVar("T")

class BoxOld(Generic[T]):
    def __init__(self, item: T) -> None:
        self.item = item
    def get(self) -> T:
        return self.item

print(BoxOld("hi").get())
hi

9.2.5 PEP 695 新语法(3.12+)与旧写法对照

Python 3.12 引入 PEP 695,把泛型语法大幅简化:类型变量直接写在方括号里,不用再单独 TypeVar(...),也不用继承 Generic[T]。下面的对照表是本节的重点:

场景旧写法(3.11 及以前)新写法(3.12+,PEP 695)
类型别名Vector: TypeAlias = list[float]type Vector = list[float]
泛型函数T = TypeVar("T") 后 def f(items: list[T]) -> T:def f[T](items: list[T]) -> T:
泛型类class Box(Generic[T]):class Box[T]:
带 boundTypeVar("C", bound=int)class Box[C: int]:

新写法实战:

type Vector = list[float]           # 类型别名

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

class BoxNew[T]:
    def __init__(self, item: T) -> None:
        self.item = item
    def get(self) -> T:
        return self.item

print(first_new([9, 8]), BoxNew(3.14).get())
print(type(Vector))

输出:

9 3.14
<class 'typing.TypeAliasType'>

type Vector = list[float] 创建的是一个 TypeAliasType 对象,可用 Vector.__value__ 取回 list[float]。如果项目基线是 3.12+,优先用新语法;但需要兼容 3.11 及更早版本的库仍会保留旧写法,两者并存是常态。

9.2.6 Protocol:鸭子类型的静态版

Python 崇尚「鸭子类型」:只要有 area() 方法,就能当图形用,不必继承谁。Protocol 把这种约定写成静态可检查的接口:

from typing import Protocol

class HasArea(Protocol):
    def area(self) -> float: ...

class Circle:
    def __init__(self, r: float) -> None:
        self.r = r
    def area(self) -> float:
        return 3.14 * self.r ** 2

def total_area(shapes: list[HasArea]) -> float:
    return sum(s.area() for s in shapes)

print(total_area([Circle(1.0)]))

输出:

3.14

关键点:Circle 根本没有继承 HasArea,但 pyright 不报错——因为结构上满足。这就是结构化子类型(structural subtyping):检查的是「有没有这些方法」,而不是「继承自谁」。如果加上 @runtime_checkable,还能用 isinstance 在运行时判断:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Sized(Protocol):
    def __len__(self) -> int: ...

class Bag:
    def __len__(self) -> int:
        return 3

print(isinstance(Bag(), Sized))
True

注意 runtime_checkable 的 isinstance 只检查方法是否存在,不检查签名,属于「尽力而为」。

9.2.7 TypedDict 与 Required / NotRequired

给「结构固定的字典」加类型,用 TypedDict:

from typing import TypedDict, Required, NotRequired

class Movie(TypedDict):
    title: str
    year: Required[int]
    rating: NotRequired[float]

m: Movie = {"title": "Inception", "year": 2010}
print(m)
{'title': 'Inception', 'year': 2010}
  • 默认所有键都是必填。
  • Required[...] 显式标必填(在 total=False 的 TypedDict 里很有用)。
  • NotRequired[...] 标可选。rating 不传也合法。

如果漏了必填键,pyright 会报错:

td.py:9:14 - error: Type "dict[str, str]" is not assignable to declared type "Movie"
  "year" is required in "Movie" (reportAssignmentType)

注意:TypedDict 只在静态检查时生效,运行时它就是个普通 dict,键的类型不会真的被校验——这一点 9.3 节会展开。

9.2.8 NamedTuple

需要「带名字的元组」时用 NamedTuple。它既是元组,又能按属性访问:

from typing import NamedTuple

class Pt(NamedTuple):
    x: int
    y: int

p = Pt(1, 2)
print(p, p.x, tuple(p))
Pt(x=1, y=2) 1 (1, 2)

NamedTuple 是不可变的(继承自元组),适合表示坐标、配置项这类「值对象」。需要可变则用 9.3 节的 BaseModel 或 dataclass。

9.2.9 @overload:一个函数,多种签名

当同一个函数对不同输入返回不同类型时,用 @overload 声明多组签名,最后再写一个真正的实现:

from typing import overload

@overload
def parse(x: int) -> int: ...
@overload
def parse(x: str) -> str: ...
def parse(x: int | str) -> int | str:
    return x

print(parse(1), parse("a"))
1 a

调用 parse(1.5) 时 mypy 会精准报错:

ov.py:12: error: No overload variant of "parse" matches argument type "float"  [call-overload]
ov.py:12: note: Possible overload variants:
ov.py:12: note:     def parse(x: int) -> int
ov.py:12: note:     def parse(x: str) -> str

适用场景:标准库式的「多态输入」。不要滥用——@overload 只服务类型检查,运行时最后那个实现才是真的;如果两组签名逻辑差别很大,拆成两个函数更清晰。

9.2.10 Self(3.11+)与 TypeIs(3.13+)

Self(3.11 起) 表示「当前类的类型」,在链式调用与继承场景里特别有用:

from typing import Self

class FluentBuilder:
    def __init__(self) -> None:
        self.parts: list[str] = []
    def add(self, s: str) -> Self:
        self.parts.append(s)
        return self
    def build(self) -> str:
        return "-".join(self.parts)

print(FluentBuilder().add("a").add("b").build())
a-b

用 Self 而不是写死 -> FluentBuilder,子类继承时返回类型会自动跟着变,链式调用不会断。

TypeIs(3.13 起) 用于自定义类型守卫,让检查器在 if 分支里收窄类型:

from typing import TypeIs

def is_str(x: object) -> TypeIs[str]:
    return isinstance(x, str)

def process(x: object) -> int:
    if is_str(x):
        return len(x)   # 检查器知道 x 是 str
    return 0

pyright 对这段代码零报错——TypeIs[str] 告诉检查器「返回 True 时 x 就是 str」。它比旧的 TypeGuard 更强:TypeGuard 只在 True 分支收窄,TypeIs 在 False 分支也能收窄,语义更精确。

9.2.11 变型:直觉与常见误用

变型(variance) 描述「容器的子类型关系」。你只需要记住直觉:

变型含义典型例子
协变 covariantDog 是 Animal ⇒ list[Dog] 可当 list[Animal]只读序列
逆变 contravariant方向相反,只用于「消费」类型函数参数
不变 invariant两者不兼容可变的 list

最常见的误用:以为 list[Dog] 能当 list[Animal] 传。不行——list 是可变的、不变的,因为往里塞一个 Cat 就会破坏类型安全。而 Sequence[Dog](只读)是协变的,可以传给形参 Sequence[Animal]。

不用背理论:只读接口倾向协变,可写接口必须不变。Sequence / Iterable / Mapping 这类只读抽象天然安全,是写库时该优先暴露的接口。

小结

  • 泛型用类型变量占位:TypeVar + Generic[T] 是旧写法,def f[T] / class C[T] / type X = ... 是 3.12 的 PEP 695 新写法。
  • bound 是「继承上界」,约束 TypeVar("N", int, float) 是「离散集合」,二者不要混。
  • Protocol 是鸭子类型的静态版,检查「有没有这些方法」而非「继承自谁」;@runtime_checkable 才支持 isinstance。
  • TypedDict 给固定结构的字典加类型,Required / NotRequired 控制必填性;它只在静态检查时生效。
  • @overload 表达多签名,Self 让链式调用在继承下不断链,TypeIs(3.13+)比 TypeGuard 收窄更精确。
  • 变型的直觉:只读接口倾向协变,可变接口必须不变;list 是不变的。

这一节我们给注解装上了「表达复杂结构」的能力。但你会发现,所有这些工具都有一个共同前提——类型是「写死的、静态的」。当数据来自外部(JSON、环境变量、用户输入)时,静态注解根本管不到。下一节我们就来处理这个边界:运行时校验与 Pydantic。

阅读导航:上一节:类型注解语法与 pyright / mypy · 下一节:运行时校验与 Pydantic 入门 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时