类型注解不是给解释器看的,是给「下一个读代码的人」和「静态检查工具」看的——它能在一个 Bug 跑到生产环境之前就把它拦下来。
你将学到
- 为什么动态类型语言也要写类型注解,以及它到底解决什么问题
- 变量、函数、容器的基本标注语法(含 3.10+ 的
|联合类型) typing里的高频成员:Optional/Union/Callable/Literal/Protocol/TypeVarfrom __future__ import annotations到底在做什么- 用 mypy / pyright 做静态检查,以及
dataclasses、pydantic与类型的关系与边界
前置知识
建议先读完 上一篇:性能剖析与优化,对 Python 对象模型和函数调用有基本认识。
一、为什么要在动态语言里写类型
Python 是动态类型:变量没有类型,值才有类型。这带来灵活性,也带来一个问题——只有运行到那一行,才知道参数类型对不对。
def add_days(days, user): ... # 3 个月后有人调用 add_days("3", user),没人报错,
# 直到线上数据算错,你半夜爬起来查日志
类型注解的核心价值:
- 文档化:函数签名自己说明白要什么、返回什么,不用翻注释。
- 静态检查:mypy 在运行前就能发现「你传了 str,这里要 int」。
- IDE 提示:补全、跳转、重构都依赖类型信息才精准。
- 重构安全:改一个函数签名,所有调用点的错误会一次性暴露出来。
注意:注解默认不产生运行时行为。下面这段标注离谱的代码照样能跑——解释器根本不看注解,检查是 mypy 这类工具的事,这是理解后面一切的前提。
def f(x: int) -> str:
return x # 类型标错了,Python 照样执行
print(f(1)) # 输出: 1
二、基础标注
变量与常量
name: str = "Ada"
age: int = 36
scores: list[float] = [90.5, 88.0] # 3.9+ 直接用小写 list
config: dict[str, int] = {"retries": 3}
# 只声明不赋值也合法(mypy 会追踪后续赋值)
result: str
result = "ok"
函数
def greet(name: str, excited: bool = False) -> str:
suffix = "!" if excited else "."
return f"Hello, {name}{suffix}"
# 没有返回值的函数标注 -> None
def log(msg: str) -> None:
print(msg)
print(greet("Ada", True)) # 输出: Hello, Ada!
容器
from collections.abc import Sequence, Mapping
# 精确到元素类型
names: list[str] = ["a", "b"]
ages: tuple[int, str] = (1, "one") # 定长、类型逐个对应
pair: tuple[int, ...] = (1, 2, 3) # 变长、同类型
# 抽象类型优先:接收方只关心"能按顺序取"
def first(items: Sequence[str]) -> str:
return items[0] if items else ""
原则:参数用抽象类型(Sequence / Iterable / Mapping),返回值用具体类型(list / dict)。这样调用方传 list 或 tuple 都行。
三、typing 常用成员
Optional / Union / |
from typing import Optional
def find(name: str) -> Optional[str]: # 返回 str 或 None
return None
# 3.10+ 用 | 更简洁,等价写法
def parse(text: str) -> int | None:
return int(text) if text.isdigit() else None
# 多类型:int | float 对应旧的 Union[int, float]
Num = int | float | str
X | None 就是 Optional[X]。谨慎用 None 表示"找不到"——要让所有调用方都记得判空,漏一个就是 AttributeError。
Callable
from typing import Callable
def apply(fn: Callable[[int, int], int], a: int, b: int) -> int:
return fn(a, b)
print(apply(lambda x, y: x + y, 2, 3)) # 输出: 5
Callable[[参数类型...], 返回类型],参数为空写 Callable[[], int]。
Literal
限制只能是某几个字面值,比 str 严格得多。
from typing import Literal
def set_level(level: Literal["debug", "info", "error"]) -> None:
...
set_level("info") # ✅
# set_level("warn") # mypy 报错:不在允许的字面值里
TypeVar 与泛型
想表达「传进来什么类型,就返回什么类型」,用 TypeVar。
from typing import TypeVar
T = TypeVar("T")
def first_or(items: list[T], default: T) -> T:
return items[0] if items else default
reveal_type(first_or([1, 2], 0)) # mypy 推断为 int
reveal_type(first_or(["a"], "z")) # 推断为 str
3.12+ 可以直接在函数名后声明类型参数:def first_or[T](items: list[T], default: T) -> T: ...。
Protocol:结构化子类型
不用继承,只要「长得像」就算数。这叫鸭子类型的类型化表达。
from typing import Protocol
class Sized(Protocol):
def __len__(self) -> int: ...
def show_length(obj: Sized) -> int:
return len(obj)
show_length([1, 2, 3]) # ✅ list 有 __len__,天然满足
show_length("abc") # ✅ str 也满足
Protocol 是解耦利器:你的函数依赖的是「能力」而不是「某个基类」。标准库里 collections.abc 的 Iterable、Sequence 本质上就是这么定义的。
四、from __future__ import annotations
在模块顶部写这一行,所有注解都会变成字符串,不再在运行时求值。
from __future__ import annotations
class Node:
def __init__(self, child: Node | None = None): # 3.9 上也能跑
self.child = child
它解决两件事:
- 前向引用:类里引用还没定义完的类,不用再加引号
"Node"。 - 新语法降级:3.9 也能写
int | None(在注解位置)。
代价:运行时拿注解要调 typing.get_type_hints() 解析字符串,直接读 __annotations__ 得到的是字符串。
五、静态检查:mypy / pyright
装好直接跑,最基础的用法就是「指向包或文件」。
pip install mypy
mypy src/ # 检查整个目录
mypy --strict src/ # 严格模式,推荐新项目开启
配置写进 pyproject.toml:
[tool.mypy]
python_version = "3.11"
strict = true
warn_unused_ignores = true
exclude = ["tests/"]
[[tool.mypy.overrides]]
module = "some_untyped_lib.*" # 没有类型标注的第三方库
ignore_missing_imports = true
pyright 是微软出的另一个检查器(VSCode 内置 Pylance 的底座),速度更快、对推断更强:
pip install pyright
pyright src/
两者选一个统一在团队里用,别混着跑,否则规则打架。mypy 生态老、插件多;pyright 快、开箱体验好。调试推断结果时可以用 reveal_type(x),mypy 会打印它推断出的类型。
六、dataclasses 与 pydantic 的边界
@dataclass 用注解生成 __init__ / __repr__ / __eq__,mypy 完全能理解它:
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
p = Point(1.0, 2.0)
# Point("1", "2") # 运行时不报错,但 mypy 会拦下
关键点:dataclass 的注解是给静态检查器用的,运行时不会做任何校验。传错类型照样进对象。
而 pydantic 在 __init__ 时真正校验、转换数据:
from pydantic import BaseModel, ValidationError
class User(BaseModel):
name: str
age: int
u = User(name="Ada", age="36") # 注意:字符串会被强制转成 int
print(u.age, type(u.age)) # 输出: 36 <class 'int'>
try:
User(name="Ada", age="abc")
except ValidationError as e:
print("校验失败") # 输出: 校验失败
边界怎么划:
- 内部的数据结构、函数签名 → 类型注解 + mypy,够用且零运行开销。
- 系统边界(读配置、接 API 请求、解析外部 JSON)→ pydantic,必须做运行时校验。
- dataclass 想加校验可以配
pydantic.dataclasses,但别自己手写一堆__post_init__打补丁。
常见坑
坑 1:把注解当成运行时校验
# ❌ 以为运行时会检查
def double(x: int) -> int:
return x * 2
double("ab") # 输出: abab,没人拦你
# ✅ 需要运行时校验就用 pydantic,或手动 assert
def double(x: int) -> int:
if not isinstance(x, int):
raise TypeError(f"expected int, got {type(x).__name__}")
return x * 2
坑 2:可变默认参数(和类型无关,但常和 dataclass 一起踩)
# ❌ 所有实例共享同一个 list
@dataclass
class Bag:
items: list = []
# ✅ 用 field(default_factory=...)
from dataclasses import field
@dataclass
class Bag:
items: list[str] = field(default_factory=list)
小结
- 类型注解默认无运行时行为,它的价值在静态检查、IDE 和文档化。
- 基础标注覆盖变量/函数/容器;参数偏好抽象类型,返回值用具体类型。
Optional=X | None;Callable描述函数;Literal限制字面值;TypeVar表达泛型;Protocol表达结构化鸭子类型。from __future__ import annotations让注解惰性化,解决前向引用并支持新语法降级。- mypy / pyright 二选一,新项目开
strict;用reveal_type排查推断。 - dataclass 的注解不校验运行时;边界数据用 pydantic 做真校验。
延伸阅读
- PEP 484 / 526 / 604 / 612 —— 类型注解的官方定义演进
- mypy 官方文档的
--strict检查项清单 - 《Fluent Python》第二版第 8 章:类型提示
上一篇:性能剖析与优化 · 下一篇:包管理与虚拟环境
文章回复
0 条公开回复