Python 编程基础与工程实践(七):类型标注与静态检查

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. AnyobjectNever

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:,}"

用户自定义收窄函数必须真实验证它声称的条件。错误的 TypeIsTypeGuard 会欺骗类型检查器,形成比缺少标注更危险的假保证。

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(),并根据用途选择 VALUEFORWARDREFSTRING 格式,而不是假设 __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 中执行同样命令;不要为了让告警消失而大量引入 Anycast() 或无说明的忽略注释。

采用类型标注可以循序渐进:

  1. 先标注公共函数和模块边界;
  2. 对新代码启用较严格规则;
  3. 把无类型外部数据集中在适配层;
  4. 用测试验证运行时行为;
  5. 逐步减少 Any,而不是一次性追求百分之百覆盖。

11. 小结

  • Python 保持动态类型;类型标注默认不执行运行时校验。
  • object 要求先收窄,Any 会传播并削弱检查,应限制在边界。
  • 现代泛型语法、ProtocolTypedDict 能表达常见工程契约。
  • cast() 只影响静态分析,不能验证 JSON 或其他外部输入。
  • Python 3.14 默认延迟求值注解,反射工具应使用 annotationlib 并考虑求值风险。
  • 静态检查、运行时校验和自动化测试解决不同问题,缺一不可。

下一篇将讨论线程、进程和 asyncio,并准确解释 Python 3.14 中 GIL 与自由线程构建的边界。

参考资料

Licensed under CC BY-NC-SA 4.0