本节目标:掌握泛型函数/泛型类、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]: |
| 带 bound | TypeVar("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) 描述「容器的子类型关系」。你只需要记住直觉:
| 变型 | 含义 | 典型例子 |
|---|---|---|
| 协变 covariant | Dog 是 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 入门 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。