一段代码写了 def first(items: list[T]) -> T:,并"靠类型检查保证"不返回 None。而线上拿到 None 时抛了 AttributeError——因为传进来的是空列表,而类型标注在运行时完全不生效。
Python 的类型系统是"给检查器看的注释"。它能在开发期发现很多错误,但有三类边界它管不到,这个"注释"的定位不是贬低:它确实能在开发期发现很多错误,只是"发现"依赖"有人跑检查器"——而 CI 里没有检查的项目,标注等于装饰。
而这三类恰好是"线上事故"的高发区。
1. 类型标注的三个"运行时真相"
| 真相 | 含义 |
|---|---|
| 标注不参与执行 | 不检查、不转换 |
| 泛型被擦除 | 运行时 list[int] 就是 list |
| 标注可被读取 | __annotations__ 里有,但仅供工具 |
第二条解释了"为什么 T 在运行时拿不到":TypeVar 是静态概念,运行时没有"具体的 T"。第二条的"实际影响"在"运行期类型判断"上:isinstance(x, list[int]) 会报错(不能对泛型做 isinstance),于是"按类型分支"只能退化成"按元素检查"。
所以"用泛型保证类型安全"这句话只在"检查器跑过"的前提下成立。
// 运行时的事实
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T: ...
print(first.__annotations__) # {'items': list[~T], 'returns': ~T}
// 注意:运行时 list[T] 与 list 没有行为差别
2. 三类"检查器管不到"的错误
| 类型 | 例子 |
|---|---|
空输入(T 无值可取) |
空列表取第一个 |
| 运行期数据形状不符 | JSON 反序列化后类型变了 |
| 动态行为(反射/插件) | getattr 拿到的对象 |
第一项的"后果"很具体:"返回 T"的签名在空输入时无法满足——于是实现里要么返回 None(违反签名),要么抛异常(不在签名里)。而检查器两种都不报错,因为它看不到"空"。
第一项是"泛型自身无法表达"的:list[T] 不蕴含"非空"。所以"返回 T"在空输入时无解——而检查器不会报错(因为标注没说非空)。
第二项是"边界"问题:外部数据进入时没有类型,于是"标注正确、数据不符"。第二项的"修法"是"在边界做运行期校验"(Pydantic/attrs/dataclass + 校验),于是"进去的数据"被保证形状,之后的标注才有意义。
3. "空输入"怎么表达
| 表达 | 效果 |
|---|---|
| 返回 `T | None` |
要求非空(Sequence + 断言) |
调用方保证 |
| 抛异常 | 明确的失败 |
用 Optional + 检查 |
显式处理 |
第一项的"代价"是"调用方多一层 if"——而这正是它的价值:让"可能为空"这件事在代码里可见,而不是靠"读到文档才知道"。
第一项是"最诚实"的:把"可能没有"写进签名,于是调用方必须处理 None。
第三项在"空输入是编程错误"时更合适:直接抛 ValueError,让调用点尽早暴露。
// 两种表达(选一种,别混)
def first_or_none(items: Sequence[T]) -> T | None:
return items[0] if items else None
def require_first(items: Sequence[T]) -> T:
if not items:
raise ValueError("empty sequence")
return items[0]
// 关键:签名告诉调用方"要不要处理空",而不是靠文档
4. 泛型"有用"的三个场景
| 场景 | 价值 |
|---|---|
| 容器与算法(无业务语义) | 高 |
| 工厂函数(返回同类型) | 高 |
| 装饰器(保持签名) | 中(需 ParamSpec) |
| 业务模型 | 低(几乎不用泛型) |
第一项与第二项是"泛型的主场":map/filter/first 这类函数返回值类型取决于输入第一项的"判据"是"这个函数会不会被用于多种元素类型":会(通用工具),泛型必要;只会被用于一种类型,泛型是噪声。
,泛型能准确表达。
第四项的"识别信号"是"泛型参数在类里只出现一次":那说明它没有"关联两个位置",于是泛型没有提供任何信息。
第四项是"过度使用"的重灾区:给业务类加泛型参数,通常只是让签名更长。
"运行期校验"的成本与"类型标注"的收益要在同一处比较,否则容易选错:
// 两种防线的分工
类型标注 + 检查器:开发期发现"内部代码之间"的类型错
运行期校验(Pydantic):边界处发现"外部数据"的形状错
// 关键:标注管"进来的数据之后的流转",校验管"进来时是不是数据"
5. 三个常见误用
| 误用 | 后果 |
|---|---|
| 用泛型代替协议(Protocol) | 类型约束表达不出"有哪些方法" |
bound=Any |
等于没约束 |
| 类型标注与实现不符 | 检查器报错但被 # type: ignore 压掉 |
第二项很常见:T = TypeVar("T", bound=Any) 看起来"加了约束",实际等于没有。
第三项最危险:# type: ignore 用多了,等于关掉检查第三项的"量化"方式是"数 type: ignore 的条数":超过十处,基本可以判断"类型设计有洞",而"补标注"解决不了它。
——而它往往集中在"最难写的部分",也就是最需要检查的地方。
// 用 Protocol 表达"有哪些方法"(TypeVar 做不到)
from typing import Protocol
class Closeable(Protocol):
def close(self) -> None: ...
def cleanup(x: Closeable) -> None: x.close()
// TypeVar 只能表达"同一种类型",不能表达"具备某方法"
"空输入"这类问题可以在签名层就拦住,用一个"非空序列"的行内类型:
from typing import Annotated
from collections.abc import Sequence
NonEmpty = Annotated[Sequence[T], "len >= 1"] # 文档化约束(检查器不强制)
def require_first(items: NonEmpty[T]) -> T:
if not items: raise ValueError("empty") # 仍需运行期检查
return items[0]
// 说明:Annotated 只是"写清约束",不产生运行期效果——所以检查要自己写
6. 排错:五种"类型标注不生效"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 传错类型没报错 | 没跑检查器 | CI 加 mypy/pyright |
| 空输入崩了 | T 不蕴含非空 |
返回 `T |
| 外部数据形状不符 | 边界未校验 | 用 Pydantic 校验 |
| 反射拿到怪对象 | 动态路径 | 运行期 isinstance 检查 |
type: ignore 遍地 |
类型设计有洞 | 改设计,减少压制 |
第一项的"落地"很便宜:CI 加一行 mypy --strict(或 pyright),并对"新增代码"要求零错误——而不必"一次修完老代码"。
第一项是"根本原因":类型标注不跑检查器就等于注释——而很多项目"写了标注但 CI 里没有检查"。
最后一个"渐进"路径:先给"边界"与"公共 API"标注,内部函数后补。因为"边界"决定外部数据形状,"公共 API"决定别人怎么用它——这两处的标注收益最高。
// 三步走(按收益排序)
1 边界:请求/响应模型、DB 行 → 有运行期校验 + 标注
2 公共 API:对外函数签名 → 有标注
3 内部:热点与易错处 → 逐步补
// 反例:先给"内部工具函数"补一堆泛型 → 收益低、噪声大
7. 边界:什么情况不必上类型标注
| 场景 | 为什么 | 做法 |
|---|---|---|
| 一次性脚本 | 不长期 | 不标注 |
| 极小的工具函数 | 一眼能看完 | 不标注 |
| 快速原型 | 结构在变 | 先不标 |
| 已有强运行期校验(Pydantic) | 校验在运行期 | 标注可简 |
判断口径一句话:"这个类型错误会不会在运行期被发现"。最后一条与"成本"有关的边界:标注的成本在"写的时候",收益在"改的时候"。所以"改得越多的地方"越值得标注——而"一次写完不再动"的代码,标注的回报很低。
会(有校验、有测试),标注的边际价值小;不会(长链路、动态数据),标注 + 检查器就是唯一防线。
8. 自测
- 类型标注的三个"运行时真相"里,哪一条解释了"
T拿不到"? - 为什么
list[T]不蕴含"非空"?"返回 T"在空输入时为什么无解? T | None与"抛异常"分别适合什么场景?- 泛型在哪些场景价值高?为什么"业务模型加泛型"通常是过度使用?
bound=Any为什么等于没约束?# type: ignore用多了有什么后果?- 判断"是否值得上类型标注"的那个问题是什么?