1. 引言:为什么需要类型注解
Python 是一门动态类型语言,变量在运行时才确定类型。这种灵活性带来了开发效率,也埋下了隐患:函数参数传错类型、返回值被误用、重构后调用方没同步修改......这些问题往往要等到运行时甚至线上才暴露。
类型注解(Type Hints)在 PEP 484 中被正式引入,它让开发者可以在源码中声明变量、参数和返回值的类型。注解本身不影响运行时行为,但配合静态检查工具 mypy,就能在编码阶段发现大量类型错误。
python
def greet(name: str) -> str:
return f"Hello, {name}"
greet(42) # mypy 会报错:Argument 1 has incompatible type "int"; expected "str"
类型注解带来的收益主要有三点:
- 可读性:函数签名即文档,调用方无需阅读实现就能知道该传什么、会得到什么。
- 可维护性:重构时静态检查能快速定位所有受影响的位置。
- 协作效率:团队对接口契约有统一认知,减少口头沟通成本。
下图展示了类型注解在开发流程中发挥作用的位置:
2. 基础类型注解
2.1 变量注解
Python 3.6 起支持变量注解语法:
python
name: str = "Alice"
age: int = 30
height: float = 1.68
is_active: bool = True
注解也可以只声明不赋值,此时变量处于「已声明未定义」状态,直接使用会引发 NameError:
python
count: int
print(count) # NameError: name 'count' is not defined
2.2 函数参数与返回值
python
def add(a: int, b: int) -> int:
return a + b
def log(message: str) -> None:
print(message)
-> None 表示函数不返回有意义的值。注意它与「没有返回值注解」不同:后者意味着 mypy 不会检查返回值类型。
2.3 常用内置类型
| 注解 | 含义 | 示例 |
|---|---|---|
int |
整数 | x: int = 1 |
float |
浮点数 | y: float = 1.0 |
str |
字符串 | s: str = "a" |
bool |
布尔值 | b: bool = True |
bytes |
字节串 | data: bytes = b"abc" |
None |
空值 | n: None = None |
Any |
任意类型(放弃检查) | v: Any = object() |
Any 是一把双刃剑:它能快速消除报错,但也会让类型检查在该处失效,应谨慎使用。
3. 容器类型:List、Dict、Optional、Union
3.1 泛型容器
Python 3.9 之前需要从 typing 导入大写形式的容器类型;3.9 起可以直接使用内置类型作为泛型:
python
# Python 3.9+
names: list[str] = ["Alice", "Bob"]
scores: dict[str, int] = {"Alice": 95, "Bob": 88}
point: tuple[int, int] = (1, 2)
tags: set[str] = {"python", "typing"}
# Python 3.8 及更早
from typing import List, Dict, Tuple, Set
names: List[str] = ["Alice", "Bob"]
scores: Dict[str, int] = {"Alice": 95}
推荐在新项目中统一使用小写内置泛型,老项目保持与现有风格一致即可。
3.2 Optional:可能为 None
Optional[T] 等价于 T | None,表示值要么是 T,要么是 None:
python
from typing import Optional
def find_user(user_id: int) -> Optional[str]:
if user_id == 1:
return "Alice"
return None
# Python 3.10+ 写法
def find_user(user_id: int) -> str | None:
...
使用 Optional 时,mypy 会强制你在访问属性前做 None 判断:
python
user = find_user(2)
print(user.upper()) # mypy 报错:Item "None" of "Optional[str]" has no attribute "upper"
if user is not None:
print(user.upper()) # 通过检查
3.3 Union:多种类型之一
Union[A, B] 表示值可以是 A 或 B:
python
from typing import Union
def parse(value: Union[int, str]) -> int:
if isinstance(value, str):
return int(value)
return value
# Python 3.10+ 写法
def parse(value: int | str) -> int:
...
Union 与 Optional 的关系:Optional[T] 就是 Union[T, None] 的简写。
3.4 类型收窄
mypy 能根据 isinstance、is None、in 等判断自动收窄类型:
python
def process(value: int | str | list[int]) -> int:
if isinstance(value, int):
return value # 此处 value 为 int
if isinstance(value, str):
return len(value) # 此处 value 为 str
return sum(value) # 此处 value 为 list[int]
类型收窄的判断流程可以概括为:
下面这张图梳理了本节几种容器与联合类型的包含关系:
4. 函数与类的注解
4.1 默认参数与关键字参数
python
def create_user(
name: str,
age: int = 0,
tags: list[str] | None = None,
) -> dict[str, object]:
if tags is None:
tags = []
return {"name": name, "age": age, "tags": tags}
注意可变默认值陷阱:不要写 tags: list[str] = [],应使用 None 作为默认值再在函数体内初始化。
4.2 可调用对象
函数本身也可以被注解,使用 Callable:
python
from typing import Callable
def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
return func(a, b)
apply(lambda x, y: x + y, 1, 2)
Callable[[参数类型...], 返回类型] 中,参数列表用方括号包裹,返回类型放在最后。
4.3 类的属性与方法
python
class User:
name: str
age: int
def __init__(self, name: str, age: int) -> None:
self.name = name
self.age = age
def greet(self) -> str:
return f"Hi, I'm {self.name}"
@classmethod
def from_dict(cls, data: dict[str, object]) -> "User":
return cls(str(data["name"]), int(data["age"]))
类属性在类体中声明类型,实例属性在 __init__ 中赋值。返回自身类型时,Python 3.11+ 可用 Self,更早版本用字符串前向引用 "User"。
4.4 数据类
dataclass 与类型注解天然契合:
python
from dataclasses import dataclass, field
@dataclass
class Order:
order_id: int
items: list[str] = field(default_factory=list)
remark: str | None = None
field(default_factory=list) 解决了可变默认值问题,mypy 也能正确推断字段类型。
5. 泛型初步
5.1 为什么需要泛型
假设要写一个「取第一个元素」的函数:
python
def first(items: list) -> object:
return items[0]
返回 object 会丢失类型信息,调用方拿到后还得手动断言。泛型让类型信息在调用链中保持:
python
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
n = first([1, 2, 3]) # n 被推断为 int
s = first(["a", "b"]) # s 被推断为 str
5.2 带约束的 TypeVar
python
from typing import TypeVar
Number = TypeVar("Number", int, float)
def double(x: Number) -> Number:
return x * 2
double(1) # OK,返回 int
double(1.5) # OK,返回 float
double("a") # mypy 报错
也可以使用 bound 限定上界:
python
from typing import TypeVar
class Animal:
def speak(self) -> str:
return "..."
A = TypeVar("A", bound=Animal)
def make_speak(animal: A) -> str:
return animal.speak()
5.3 泛型类
python
from typing import Generic, TypeVar
T = TypeVar("T")
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
stack: Stack[int] = Stack()
stack.push(1)
stack.push("a") # mypy 报错
Python 3.12 起支持更简洁的 PEP 695 语法:
python
class Stack[T]:
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
6. mypy 检查与团队协作
6.1 安装与基本使用
bash
pip install mypy
mypy your_module.py
mypy 会输出所有类型错误及行号:
go
your_module.py:5: error: Argument 1 to "greet" has incompatible type "int"; expected "str"
Found 1 error in 1 file (checked 1 source file)
6.2 配置文件
在项目根目录创建 mypy.ini 或 pyproject.toml:
toml
# pyproject.toml
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
no_implicit_optional = true
strict_equality = true
[[tool.mypy.overrides]]
module = "third_party.*"
ignore_missing_imports = true
关键选项说明:
disallow_untyped_defs:禁止无注解的函数定义,强制补全类型。no_implicit_optional:禁止把None隐式当作默认值。warn_return_any:返回Any时给出警告。strict:一键开启全部严格检查,适合新项目。
6.3 渐进式接入策略
对存量项目,一次性开启严格模式往往会产生成百上千条报错。推荐渐进式接入:
- 先跑通 :只检查新增代码,存量代码用
# type: ignore或ignore_errors临时豁免。 - 按模块推进 :从核心模块开始,逐个开启
disallow_untyped_defs。 - 收紧配置 :模块覆盖率提升后,逐步启用
strict相关选项。 - 纳入 CI:在流水线中执行 mypy,阻止类型错误合入主干。
yaml
# .github/workflows/type-check.yml
name: Type Check
on: [push, pull_request]
jobs:
mypy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install mypy
- run: mypy src/
渐进式接入的整体节奏如下:
6.4 团队协作规范
- 接口先行:公共函数、模块边界必须写完整注解,内部实现可逐步补充。
- 避免滥用 Any :
Any会传播,一个Any可能让下游全部失去检查。 - 统一工具链:mypy 版本、配置、Python 版本在团队内保持一致,避免「本地通过、CI 失败」。
- 与 IDE 联动:PyCharm、VS Code(Pylance)都能读取注解提供补全和提示,注解质量直接影响开发体验。
- 代码评审关注类型:把类型注解纳入 Code Review 检查项,比事后补注解成本更低。
6.5 常见问题
问题一:第三方库没有类型存根
toml
[[tool.mypy.overrides]]
module = "some_untyped_lib.*"
ignore_missing_imports = true
或安装社区维护的存根包:pip install types-requests。
问题二:循环引用
使用 from __future__ import annotations 让所有注解延迟求值,或使用字符串前向引用:
python
from __future__ import annotations
class Node:
def __init__(self, next: Node | None = None) -> None:
self.next = next
问题三:动态属性
对动态添加的属性,可用 setattr 配合 # type: ignore,或改用 TypedDict、Protocol 等更精确的结构化类型。
7. 总结
类型注解不是负担,而是把「隐式契约」变成「显式契约」的过程。本文从基础注解讲到泛型与 mypy 实践,核心要点可以归纳为:
- 基础类型注解让函数签名自解释,是类型化的起点。
List/Dict/Optional/Union覆盖了绝大多数日常场景,Python 3.10+ 可用|语法简化。- 函数与类的注解要关注默认值陷阱、前向引用和
Callable的写法。 - 泛型让容器和工具函数保持类型信息,
TypeVar是入门关键。 - mypy 配合渐进式接入和 CI 检查,才能真正在团队中落地。
建议从新代码开始写注解,用 mypy 守住增量,再逐步回补存量代码。类型化的收益会随着代码规模增长而放大。
最后用一张图回顾全文的知识脉络: