2561 字
约 8 分钟
15
S10-B07 · Python 类型注解入门:给动态语言加一层约束

parse_config(path, timeout) 被传成了 parse_config(timeout, path),程序不报错,跑了两小时后在写日志时炸出 TypeError: unsupported operand type(s),栈里全是框架代码。动态语言的错误不在犯错处发生,而在类型不再兼容的那一行发生。类型注解把这个爆炸点提前到你能看懂的位置——前提是先接受一件事:注解在运行时什么都不校验。


1. 注解到底是什么

def parse_port(raw: str) -> int:
    return int(raw)

print(parse_port.__annotations__)
# {'raw': <class 'str'>, 'return': <class 'int'>}

parse_port(8080) 照样能跑。CPython 只把注解存进 __annotations__ 然后继续执行。注解是给读代码的人和静态检查工具看的元数据,不是运行时守卫。

你以为注解会做 实际发生
传错类型时抛 TypeError 不抛,照常执行到出错为止
返回值自动转换 不转,返回什么就是什么
在 IDE 里给提示 有,这部分是真的
让 mypy 报错 会,但只在跑 mypy 时

所以引入注解必须配套引入一个检查器,否则你只是写了另一种注释。

2. 基础写法与版本差异

def total(prices: list[float], weights: dict[str, int]) -> float: ...   # 3.9+(PEP 585)
def find(key: str | None) -> str | None: ...                            # 3.10+(PEP 604)

from typing import Optional                 # 3.9 的等价写法
def find(key: Optional[str]) -> Optional[str]: ...
写法 最低版本 备注
list[int]、dict[str, int] 3.9 3.8 运行时 TypeError: 'type' object is not subscriptable
str | None 3.10 3.9 运行时 unsupported operand type(s)
typing.List / typing.Dict 3.5+ 老项目兼容写法,新代码不必
from __future__ import annotations 3.7+ 注解不求值,全部变字符串

最后一行是跨版本兼容的关键:加上它,def f(x: list[int]) 在 3.8 上定义时也不会报错,因为注解压根不求值。代价是运行时拿不到真实类型;需要运行时类型时用 typing.get_type_hints() 还原。

3. Optional、None 与可变默认值

Optional[X] 不是"可选参数",它的准确含义是 X | None:值允许是 None。参数有没有默认值是另一件事。

def fetch(url: str, timeout: Optional[float] = None) -> bytes: ...
def fetch(url: str, timeout: float = None) -> bytes:    # mypy 报错

第二种在旧版 mypy 里被默许(隐式 Optional),新版默认开启 --no-implicit-optional 会直接报错。只有 Optional[float] = None 是安全的写法。

可变默认参数是另一类问题,注解帮不上,但常与之同时出现:

def append_tag(tag: str, tags: list[str] | None = None) -> list[str]:
    tags = [] if tags is None else tags      # 默认写 None,体内构造
    tags.append(tag)
    return tags

注解在此的价值是逼你把"可能是 None"写出来,从而被迫处理它,而不是让 None.append 在运行时炸。

4. Union 与 Literal

from typing import Literal, Union

Mode = Literal["r", "w", "a"]               # 只接受这三个字面值
def open_file(path: str, mode: Mode = "r") -> object: ...

open_file("a.txt", "x")                     # mypy: incompatible type "Literal['x']"

def parse(value: Union[str, bytes]) -> dict: ...
场景 选择 理由
枚举式字符串参数 Literal["a", "b"] 拼错立刻报错,胜过文档
一个参数接受两种类型 Union[A, B] 或 A | B 函数内要做类型收窄
区分业务状态 Enum 或 Literal Enum 更适合跨模块传
int 与 float 混用 直接标 float PEP 484 允许 int 赋给 float

Literal 是低成本高回报的一类注解:把"只能取某几个值"从注释变成可检查的约束。

5. TypedDict、dataclass、NamedTuple

from dataclasses import dataclass
from typing import NamedTuple, TypedDict

class UserDict(TypedDict):
    id: int
    name: str

@dataclass
class User:
    id: int
    name: str = ""

class Point(NamedTuple):
    x: float
    y: float
结构 运行时行为 适用
TypedDict 就是个 dict,无校验 解析 JSON、外部 API 的中间结构
dataclass 真类,有 __init__/__eq__ 业务对象、需要默认值和方法
NamedTuple 不可变元组,可解包 小值对象、要当元组用的返回值

选错不会立刻出错,只会长期别扭:用 dataclass 接 JSON 要写转换层,用 TypedDict 当业务对象则没有方法与不可变保证。

6. 函数签名与 Callable

from typing import Callable

def register(name: str, on_done: Callable[[int, str], None]) -> None: ...
def query(sql: str, *, limit: int = 100) -> list[tuple]: ...

Callable[[int, str], None] 读法是"接受 int 与 str、返回 None 的可调用对象"。参数列表要按位置写全,Callable[..., None] 是放弃检查的兜底写法。

漏标处 后果
返回值 检查器默认 Any,错误往下游传
关键字参数 调用方写错参数名也不报
*args / **kwargs 内部取值全是 Any,等于没标

7. Protocol:给鸭子类型写注解

from typing import Protocol

class Reader(Protocol):
    def read(self, n: int = -1) -> bytes: ...

def consume(src: Reader) -> int:
    return len(src.read())

class FakeFile:                       # 不继承 Reader,也不需要知道它
    def read(self, n: int = -1) -> bytes:
        return b"abc"

consume(FakeFile())                   # mypy 通过:结构匹配

与抽象基类的区别在于结构化匹配:实现方无需知道接口存在,因此不会在你的包和实现方的包之间制造 import 依赖。这也是它适合做测试替身的原因。

8. 泛型的入门:先知道它解决什么

from typing import TypeVar

T = TypeVar("T")
def first(items: list[T]) -> T: ...     # first([1]) -> int;first(["a"]) -> str

TypeVar 的作用是保留入参与出参之间的类型关系。不写泛型,first 只能返回 Any 或某个具体类型,调用方就丢了信息。

但顺序建议是:先把公共接口标准,泛型只在"同一类型从入参流到出参"处引入。过早造出 T、KT、VT 三四个类型变量,可读性掉得比收益快。

9. 检查器:三档严格度

档位 命令/配置 拦得住什么
宽松 mypy src 只查已注解部分的矛盾
中等 --disallow-untyped-defs --warn-return-any 未注解函数、返回 Any
严格 --strict(含 --disallow-any-generics 等) 裸 Any、裸泛型、隐式 Optional
# pyproject.toml
[tool.mypy]
python_version = "3.11"
disallow_untyped_defs = true
warn_return_any = true
no_implicit_optional = true

pyright 的对应物是 pyrightconfig.json 里的 "typeCheckingMode": "basic|standard|strict"。两者规则不完全一致,选定一个当门禁、另一个当参考即可——都当门禁会陷入规则对不齐的争论。严格度也不要一步到位:打开 --strict 的第一天通常满屏报错,正确路径是先中等档跑通存量,再对新目录开严格档。

10. 运行时归 pydantic,静态归 mypy

from pydantic import BaseModel, Field

class CreateUser(BaseModel):
    name: str = Field(min_length=1)
    age: int = Field(ge=0)

CreateUser(name="a", age="x")          # 运行时抛 ValidationError
关注点 静态检查 运行时校验
何时生效 开发时、CI 每次调用
拦得住外部输入吗 拦不住,外部数据是 dict/Any 拦得住
性能开销 无 有,每次构造都校验
保证什么 进程内调用一致 数据边界合规

分工一句话:跨进程边界的数据用 pydantic 校验,进程内的函数调用用注解 + mypy 约束。只做前者,内部调用照样传错;只做后者,外部输入永远是不可信类型。

11. 最小可跑示例

# calc.py
def average(values: list[int]) -> float:
    return sum(values) / len(values)

print(average(["1", "2"]))
$ mypy calc.py
calc.py:4: error: List item 0 has incompatible type "str"; expected "int"

修正的关键不是改注解了事,而是判断转换该发生在哪:average 不该接收字符串再自己转,转换属于读入数据的边界(int(raw) 或 pydantic 模型)。改完之后用严格档复核并落进 CI:

$ mypy --strict src/

12. 常见坑

坑 现象 处理
老版本用 list[str] 'type' object is not subscriptable 升 3.9+,或加 from __future__ import annotations
注解写成字符串 检查器认,get_type_hints 才还原 正常行为,需要运行时类型时显式调用
Any 泛滥 全绿但无约束 禁裸 Any,换具体类型或 object
循环引用 两模块互相注解 字符串注解 + TYPE_CHECKING 块
注解与实现不一致 检查过、运行崩 注解是承诺,改实现先改注解

13. 排错

现象 原因 处理
一个文件报几百条 一次开了 --strict 退回默认档,按目录分批收紧
Module has no attribute 第三方库无存根 装 types-* 包或写最小 .pyi
CI 与本地结果不同 Python/mypy 版本不一致 CI 固定版本,与 python_version 对齐
pydantic 模型静态报错但能跑 检查器不知道模型会转换 用模型类型作入参注解,别用 dict

14. 边界

场景 注解帮不上 该用什么
外部 JSON / 表单 静态检查看不到运行时数据 pydantic 或手写校验
数值范围与业务规则 类型不表达 age >= 0 Field 约束或校验函数
动态属性、setattr 拼装 检查器无法追踪 测试覆盖
极小脚本 收益低于维护成本 不标也行

落地顺序:公共 API 与跨模块调用 → 数据模型 → 内部辅助函数 → 测试代码。前两层覆盖了绝大多数跨边界错误,后两层是重构时的护栏。

自测

  1. parse_port(8080) 为什么不因注解是 str 而报错?
  2. Optional[float] = None 里 Optional 修饰的是"参数可选"还是"值可为 None"?
  3. 加了 from __future__ import annotations 后,为什么 3.8 也能写 list[int]?
  4. Protocol 相比抽象基类,省掉了哪一类依赖?
  5. 什么情况下必须用 pydantic,而不能只靠 mypy?
  6. 打开 --strict 第一天满屏报错,正确的推进顺序是什么?
S10-B07 · Python 类型注解入门:给动态语言加一层约束
https://www.clxhxhhr.top/posts/6101/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。