接手别人的代码,最痛苦的事莫过于对着一个函数猜参数:这个 price 到底是字符串还是浮点数?乘一下会不会报错?类型注解就是给代码装上的"说明书",让每个函数签名自己开口说话。读完本文,你会掌握注解的基础语法、typing 模块的常用工具,以及如何用 mypy 让注解真正发挥作用。

1. 为什么需要类型注解

Python 是动态类型语言,变量可以随时换类型。灵活是它的优点,但在大型项目里也是灾难之源:一个函数被十个地方调用,每个调用方传的参数类型都不一样,改代码时全靠人脑记住契约。看下面这个例子:

# 没有注解:你能猜出 price 是什么类型吗?
def total(items, price):
    return len(items) * price

# 加上注解:签名本身就是文档
def total(items: list, price: float) -> float:
    return len(items) * price

两版代码行为完全一样,但第二版把"参数是什么、返回什么"写在了签名里。注解是元数据,不是约束——Python 解释器不会因为类型对不上就报错,它只是把信息记下来给人看、给工具看。

动态类型是 Python 的爽点,也是痛点:写起来快,但代码一长,没人说得清每个变量装的是什么。静态语言(比如 Java)把类型写在声明里,编译器帮你盯着;Python 放弃了这个保障,换来灵活性。类型注解就是"既要又要"的折中方案——保留动态执行的自由,同时把类型信息写下来,让 IDE 提示、代码审查和 mypy 这类工具替你盯梢。这就像给快递箱贴标签:贴不贴标签,箱子都能送,但贴了,分拣员和收件人都省心。

2. 注解基础语法:变量、参数与返回值

变量注解写在冒号后面,函数注解则是"参数名: 类型",返回值类型写在箭头 -> 后面。注意带默认值的参数,默认值要放在类型注解之后:

name: str = "CodeLab"
age: int = 3

def greet(name: str, times: int = 1) -> str:
    return f"{name}! " * times

print(greet("你好"))     # 你好!
print(greet("嗨", 2))    # 嗨! 嗨!
print(greet.__annotations__)

运行后 __annotations__ 会打印出 {'name': <class 'str'>, 'times': <class 'int'>, 'return': <class 'str'>},证明注解只是存在函数的属性字典里。还有个细节:age: int = 3 这种写法不赋初始值也行(比如 count: int 只声明),但那时变量是未定义的,直接使用会报错。

两个新手常见疑问先解决。第一,注解会不会拖慢程序?不会——它只是往字典里塞了几个对象,函数调用时压根不看,性能开销可以忽略。第二,int/str 之外能不能写别的?可以,price: float、names: list、甚至自定义类的实例都行,注解本质上"可以是任何表达式"。如果某个参数类型实在说不清,就用 Any 表示"随意"——它相当于没有注解,但比不写更明确地表达了"这里我不打算约束"。

3. typing 模块:描述复合类型

单个 int、str 好写,但真实场景里更多的是"字符串到整数的字典""可能为空的列表"这种复合类型。这时就要请出 typing 模块:

from typing import Dict, List, Optional, Union

def find_user(users: Dict[str, int], name: str) -> Optional[int]:
    return users.get(name)

def parse_number(value: Union[int, str]) -> int:
    if isinstance(value, str):
        return int(value)
    return value

print(find_user({"alice": 1, "bob": 2}, "bob"))   # 2
print(parse_number("42"))                          # 42

Dict[str, int] 表示键是字符串、值是整数;Optional[int] 表示"可能是 int,也可能是 None"。Python 3.10 之后可以直接写 int | None 代替 Optional[int],更简洁。注意 Optional[X] 只是 Union[X, None] 的语法糖:

实战中还有两组常用容器注解:List[str] 描述"字符串列表",Tuple[int, str] 描述"第一个元素是 int、第二个是 str 的二元组",Set[str] 描述去重集合。它们可以任意嵌套,比如 Dict[str, List[int]] 表示"字符串到整数列表的映射"。写法上 Python 3.9 之前必须从 typing 导入大写的 List/Dict,3.9 之后内置的 list/dict 直接支持下标写法,和 typing 版完全等价——新项目直接用内置版,少一次导入,代码更干净。

写法含义适用版本
Optional[int]int 或 None3.5+
Union[int, str]int 或 str3.5+
int | Noneint 或 None3.10+

老项目用 typing 写法兼容性最好;新项目可以直接用 | 语法,配合 from __future__ import annotations 还能让注解惰性求值,避免循环导入问题。

4. 进阶:泛型与 Callable

泛型解决的是"类型之间的一致性":比如 first() 接收一个列表,返回它的第一个元素——列表里装什么类型,返回的就是什么类型。用 TypeVar 声明一个类型变量,就能表达这种关系:

from typing import Callable, TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T:
    return items[0]

def apply(func: Callable[[int], int], x: int) -> int:
    return func(x)

print(first([10, 20, 30]))          # 10
print(first(["a", "b", "c"]))       # a
print(apply(lambda n: n + 100, 1))  # 101

Callable[[int], int] 描述"接收一个 int、返回一个 int 的函数",非常适合回调函数和高阶函数。list[T] 是 3.9+ 的内置泛型写法,老版本要写 List[T]。这些注解在运行时都不产生任何开销,纯粹是给类型检查工具吃的。

什么时候该用泛型?判断标准只有一条:类型之间有没有"必须一致"的关系。返回列表第一个元素,类型必须和列表元素一致——用 T;把整数转成字符串,输入输出类型无关——直接写死具体类型即可。泛型写多了容易过度设计,一个项目里用上三五个 TypeVar 已经很罕见,别为了炫技到处加。

5. 让注解真正发挥作用:mypy

注解写了对不对,光靠人眼看不出来。安装 mypy 后,它会像一个严格的编译器一样扫描你的代码,把类型矛盾一处不漏地揪出来:

pip install mypy
mypy app.py

比如你声明 x: int = "hello",mypy 会立刻报错,而 Python 运行它却毫无反应。这就是注解的完整工作流:写注解给人看,跑 mypy 给机器查。mypy 的报错分几档:最轻的是 note(提示),error 才是真正的问题;常见的有 arg-type(参数类型不匹配)、return-type(返回值类型不对)、assignment(变量赋值类型冲突)。刚上手时看到一大片红色别慌,从第一个 error 开始逐个修即可。下面这段代码运行时没有任何问题,但 mypy 会给出类型警告:

def add(a: int, b: int) -> int:
    return a + b

# 故意传字符串:mypy 会警告,运行时照样能跑
print(add("1", "2"))

运行结果是 12(字符串拼接),mypy 却会提示 arg-type 错误。这个"分裂"恰恰说明了注解的价值:它把潜在 bug 提前暴露在开发阶段,而不是留到线上。

6. 工程实践:渐进式引入

给一个几千行的老项目全部补注解是不现实的,正确姿势是新代码强制注解、旧代码逐步迁移。遇到暂时不想动的旧代码,可以用 # type: ignore 跳过检查,再配合配置文件统一管理规则:

# 旧代码暂时不想动,先用注释跳过 mypy 检查
value: str = "hello"  # type: ignore

def show(v: str) -> None:
    print(v)

show(value)

# type: ignore 是"分期还款"的利器:老代码来不及补注解,先忽略,等有空再回头处理。但要控制数量——如果整个文件都是 ignore,说明注解形同虚设。更精细的写法是 # type: ignore[arg-type],只忽略特定类别的错误,其他问题照样报。

[tool.mypy]
python_version = "3.11"
strict = true          # 开启严格模式,所有函数都要求注解

把配置写进 pyproject.toml 后,团队所有成员用同一套规则。CI 里加一步 mypy src/,类型错误就会像测试失败一样挡住合并请求——这才是工程化的用法。记住:strict = true 适合新项目,老项目建议从宽松配置开始,逐步收紧,比如先只查新写的 src/new_module/ 目录,稳定后再扩大范围。另外 IDE 的提示也依赖这些注解:装好 Pylance 或 Pyright 后,鼠标悬停函数名就能看到签名,写错类型会立刻出现红色波浪线——注解带来的即时反馈,远比它本身的语法重要。

7. 总结与练习

回顾一下:类型注解是写在代码里的元数据,它不改变程序行为,但让签名变成文档、让 mypy 能帮你抓 bug。核心工具包括变量注解、函数注解、typing 的 Optional/Union/TypeVar/Callable,以及工程上的渐进式迁移策略。

💡 注解最容易被忽略的价值是"让下一个接手的人少踩坑"。写注解花 10 秒,省下的可能是别人 10 分钟的猜谜时间——这笔买卖永远划算。