Python 是动态类型语言,但动态类型不等于“没有类型”。每个运行时对象都有类型,只是名称不被永久绑定到某一种类型。类型标注在此基础上提供一套供静态分析器、编辑器和读者使用的契约语言。
本文以 Python 3.14 为基线,使用现代泛型语法,并特别说明 3.14 的注解延迟求值变化。目标不是把 Python 写成静态语言,而是在重要边界获得更早反馈。
1. 类型标注不负责运行时强制
1
2
3
4
5
| def add(left: int, right: int) -> int:
return left + right
print(add("a", "b")) # 运行时仍得到 'ab'
|
CPython 不会因为标注而自动拦截这个调用。类型检查器可以在运行前报告问题,IDE 可以提供补全和重构支持,读者也能看到函数契约,但运行时行为仍由代码决定。
类型标注的主要价值包括:
- 暴露错误的数据流和不可达分支;
- 记录公共 API 的输入输出;
- 支持安全重命名、补全和导航;
- 让模块之间的契约不必依赖口头约定。
它不能证明业务逻辑正确、输入已经校验、线程安全或网络调用一定成功。
2. 从函数边界开始标注
1
2
3
4
5
6
7
| def normalize_name(name: str, *, fallback: str | None = None) -> str:
cleaned = name.strip()
if cleaned:
return cleaned
if fallback is None:
raise ValueError("名称不能为空")
return fallback
|
现代 Python 使用 | 表示联合类型,str | None 表示值可以是字符串或 None。容器直接使用内置泛型:
1
2
3
4
| def average(values: list[float]) -> float:
if not values:
raise ValueError("values 不能为空")
return sum(values) / len(values)
|
如果函数只需要遍历,不要无谓要求调用者提供列表:
1
2
3
4
5
6
7
8
9
10
11
12
| from collections.abc import Iterable
def average(values: Iterable[float]) -> float:
total = 0.0
count = 0
for value in values:
total += value
count += 1
if count == 0:
raise ValueError("values 不能为空")
return total / count
|
选择最窄的能力接口,而不是最具体的实现类型。
3. Any、object 与 Never
Any 会选择性关闭静态检查:它既可赋给任意类型,也可接收任意类型。object 则表示“某个未知对象”,使用前必须收窄:
1
2
3
4
5
6
7
8
9
10
11
| from typing import Any
def unsafe(value: Any) -> str:
return value.not_checked() # 类型检查器通常放行
def safe(value: object) -> str:
if isinstance(value, str):
return value.upper()
return repr(value)
|
第三方无标注库、动态 JSON 边界等地方可能暂时需要 Any,但它应停留在边界,解析后尽快转成已知类型。
永不正常返回的函数可以标注 Never:
1
2
3
4
5
| from typing import Never
def fail(message: str) -> Never:
raise RuntimeError(message)
|
4. 类型收窄
类型检查器会理解常见运行时判断:
1
2
3
4
5
6
| def length(value: str | bytes | None) -> int:
if value is None:
return 0
if isinstance(value, str):
return len(value)
return len(value)
|
复杂检查可用 TypeIs 封装。它在真、假两个分支都能帮助收窄,且目标类型必须与输入类型兼容:
1
2
3
4
5
6
7
8
9
10
11
| from typing import TypeIs
def is_string(value: object) -> TypeIs[str]:
return isinstance(value, str)
def format_value(value: str | int) -> str:
if is_string(value):
return value.upper()
return f"{value:,}"
|
用户自定义收窄函数必须真实验证它声称的条件。错误的 TypeIs 或 TypeGuard 会欺骗类型检查器,形成比缺少标注更危险的假保证。
5. 现代泛型语法
Python 3.12 起可以直接在函数和类声明中定义类型参数:
1
2
3
4
5
6
7
8
9
10
11
12
| def first[T](values: list[T]) -> T:
if not values:
raise ValueError("values 不能为空")
return values[0]
class Box[T]:
def __init__(self, value: T) -> None:
self.value = value
def get(self) -> T:
return self.value
|
类型别名也有专用语法:
1
2
| type DeviceId = str
type Pair[T] = tuple[T, T]
|
别名不会在运行时创建新的名义类型。如果要防止把两个底层都是字符串的标识混用,可以使用 NewType:
1
2
3
4
5
| from typing import NewType
DeviceId = NewType("DeviceId", str)
UserId = NewType("UserId", str)
|
NewType 主要影响静态检查,运行时调用开销很小,也不会获得完整的新类行为。
6. Protocol:为鸭子类型补上静态契约
Python 运行时常按行为而不是继承树判断对象是否可用。Protocol 把这种结构化子类型写成静态契约:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| from typing import Protocol
class Writer(Protocol):
def write(self, message: str) -> None: ...
class ConsoleWriter:
def write(self, message: str) -> None:
print(message)
def publish(writer: Writer, message: str) -> None:
writer.write(message)
publish(ConsoleWriter(), "done")
|
ConsoleWriter 无需显式继承 Writer,只要成员类型兼容即可。Protocol 适合依赖注入和第三方适配,但接口应保持小而稳定。
默认的 Protocol 不能用于 isinstance()。添加 @runtime_checkable 后只能在运行时检查属性是否存在,不会核对完整方法签名:
1
2
3
4
5
6
| from typing import Protocol, runtime_checkable
@runtime_checkable
class Closable(Protocol):
def close(self) -> None: ...
|
因此运行时检查不能替代真正的输入验证或静态分析。
7. TypedDict 与结构化字典
外部 JSON 常以字典进入程序。TypedDict 可以描述固定键结构:
1
2
3
4
5
6
7
| from typing import NotRequired, TypedDict
class DevicePayload(TypedDict):
device_id: str
value: float
unit: NotRequired[str]
|
它仍是普通字典,运行时不会自动验证网络输入:
1
2
3
4
5
6
7
8
9
10
11
12
| def parse_payload(raw: object) -> DevicePayload:
if not isinstance(raw, dict):
raise ValueError("payload 必须是对象")
device_id = raw.get("device_id")
value = raw.get("value")
if not isinstance(device_id, str):
raise ValueError("device_id 必须是字符串")
if not isinstance(value, int | float) or isinstance(value, bool):
raise ValueError("value 必须是数字")
return {"device_id": device_id, "value": float(value)}
|
边界数据必须先校验,再告诉类型检查器它是什么。直接 cast(DevicePayload, raw) 只改变静态观点,不检查任何运行时内容。
8. 回调、装饰器与 ParamSpec
为了保留装饰器的调用签名,可以用参数规格变量:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| from collections.abc import Callable
from functools import wraps
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def logged(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
|
这里沿用兼容性良好的 ParamSpec/TypeVar 声明;现代类型参数语法同样支持参数规格,但是否采用要考虑项目最低 Python 版本和工具链支持。
9. Python 3.14 的注解延迟求值
Python 3.14 默认采用 PEP 649/749 的延迟求值语义:注解表达式通常在访问注解时才求值,而不是定义函数或类时立即求值。
1
2
3
4
5
6
| def handle(item: Item) -> None:
print(item)
class Item:
pass
|
在默认 Python 3.14 语义下,定义 handle 时不要求 Item 已存在;访问求值后的注解时,Item 必须能够解析。使用 from __future__ import annotations 的模块仍采用字符串化语义,不能把两者混为一谈。
框架若需要读取注解,应使用 3.14 新增的 annotationlib.get_annotations(),并根据用途选择 VALUE、FORWARDREF 或 STRING 格式,而不是假设 __annotations__ 永远包含已求值对象或字符串。还要注意:求值注解表达式可能执行代码,不能把反射不受信任模块的注解当成安全解析。
普通业务代码只用注解给静态检查器看时,通常不需要直接操作这些反射 API。
10. 建立可持续的检查策略
类型检查器是外部工具,团队需要在项目中固定选择和配置。以 mypy 为例,可以在虚拟环境安装并运行:
1
2
| python -m pip install mypy
python -m mypy src tests
|
配置可放入 pyproject.toml:
1
2
3
4
| [tool.mypy]
python_version = "3.14"
strict = true
warn_unused_ignores = true
|
不同检查器在未完全规定的收窄、插件和诊断规则上可能不同。项目应选定一套工具与版本,在 CI 中执行同样命令;不要为了让告警消失而大量引入 Any、cast() 或无说明的忽略注释。
采用类型标注可以循序渐进:
- 先标注公共函数和模块边界;
- 对新代码启用较严格规则;
- 把无类型外部数据集中在适配层;
- 用测试验证运行时行为;
- 逐步减少
Any,而不是一次性追求百分之百覆盖。
11. 小结
- Python 保持动态类型;类型标注默认不执行运行时校验。
object 要求先收窄,Any 会传播并削弱检查,应限制在边界。- 现代泛型语法、
Protocol 和 TypedDict 能表达常见工程契约。 cast() 只影响静态分析,不能验证 JSON 或其他外部输入。- Python 3.14 默认延迟求值注解,反射工具应使用
annotationlib 并考虑求值风险。 - 静态检查、运行时校验和自动化测试解决不同问题,缺一不可。
下一篇将讨论线程、进程和 asyncio,并准确解释 Python 3.14 中 GIL 与自由线程构建的边界。
参考资料