Python 中的类型提示和 mypy 检查
1. 概述
Python 是一种动态类型语言,这意味着你不需要提前声明数据类型。这很灵活,但当项目增长时容易造成运行时错误。类型提示(PEP 484)允许你注记数据类型而不改变运行时的行为,而 mypy 会读取这些注解来在编写代码时立即检测到类型错误。
读完本文后,你将学会如何为变量、函数、类编写类型提示,使用 Optional、Union、TypedDict、泛型,以及将 mypy 集成到代码检查流程中。
2. 基本类型提示
2.1. 为变量和函数声明类型
def calculate_total(price: float, quantity: int) -> float:
return price * quantity
customer_name: str = "张三"
total: float = calculate_total(price=15.5, quantity=3)
print(total)
price: float、quantity: int是输入参数的类型。-> float是返回值的类型。customer_name: str是变量的注解,帮助 IDE 提供提示和 mypy 进行检查。
46.5
注意: 类型提示在运行时不会强制类型转换。如果你传递
calculate_total("10", 2),Python 仍会正常运行,因为解释器忽略注解——只有像 mypy 这样的工具才会报错。
2.2. 安装 mypy
pip install mypy
检查文件:
mypy order_service.py
如果没有错误,mypy 输出:
Success: no issues found in 1 source file
3. Optional 和 Union:用于可能为 None 的值
许多函数在找不到数据时返回 None。使用 Optional 来明确表示这一点。
from typing import Optional
class Customer:
def __init__(self, customer_id: int, email: str) -> None:
self.customer_id = customer_id
self.email = email
def find_customer(customer_id: int) -> Optional[Customer]:
database = {1: Customer(1, "a@example.com")}
return database.get(customer_id)
customer = find_customer(2)
if customer is not None:
print(customer.email)
Optional[Customer]等价于Union[Customer, None]。- 如果你调用
customer.email而不先检查None,mypy 会报错Item "None" of "Optional[Customer]" has no attribute "email"。
from typing import Union
def format_id(value: Union[int, str]) -> str:
return str(value).zfill(6)
Union[int, str] 允许参数为 int 或 str。从 Python 3.10 开始,你可以用 int | str 代替 Union[int, str],用 Customer | None 代替 Optional[Customer],写法更简洁。
4. 集合类型:list、dict、tuple
from typing import Dict, List, Tuple
def summarize_orders(order_totals: List[float]) -> Tuple[float, int]:
return sum(order_totals), len(order_totals)
def build_price_table(items: Dict[str, float]) -> Dict[str, float]:
return {name: price * 1.1 for name, price in items.items()}
List[float]表示浮点数列表。Tuple[float, int]表示恰好包含 2 个元素的元组:先是float,后是int。Dict[str, float]是键为str类型、值为float类型的字典。
注意: 从 Python 3.9 开始,你可以直接使用
list[float]、dict[str, float],无需从typing导入。使用typing.List/typing.Dict主要是为了兼容 Python 3.8 及以下版本。
5. TypedDict:用于结构化的字典数据
当你使用 JSON 或具有固定 schema 的字典时,TypedDict 帮助 mypy 检查每个键。
from typing import TypedDict
class OrderPayload(TypedDict):
order_id: int
customer_email: str
total: float
def create_order(payload: OrderPayload) -> str:
return f"Order #{payload['order_id']} - {payload['customer_email']} - {payload['total']}"
payload: OrderPayload = {
"order_id": 101,
"customer_email": "b@example.com",
"total": 250.0,
}
print(create_order(payload))
如果你缺少键 total 或误输入 "totl",mypy 会报错:
error: Missing key "total" for TypedDict "OrderPayload"
这相比使用 Dict[str, Any] 的一大优势——你可以控制数据结构,同时在运行时仍然保持纯 Python 字典。
6. 泛型和 TypeVar
当编写适用于多种数据类型的函数或类时,你使用 TypeVar 来保持输入和输出类型之间的关系。
from typing import TypeVar, List
T = TypeVar("T")
def first_item(items: List[T]) -> T:
return items[0]
first_name: str = first_item(["Hùng", "Lan"])
first_number: int = first_item([10, 20, 30])
TypeVar("T")创建一个类型占位符。- 当调用
first_item(["Hùng", "Lan"])时,mypy 推断出T = str,因此返回结果为str。 - 当使用
List[int]调用时,T = int。这样 mypy 就能根据每次调用准确推断返回类型,而不是笼统地返回Any。
7. 类和 Protocol:代替接口
Python 没有像 Java 那样的接口,但 Protocol(PEP 544)允许你定义结构化类型——检查“是否有正确的方法”,而不是必须继承。
from typing import Protocol
class PaymentGateway(Protocol):
def charge(self, amount: float) -> bool:
...
class VnPayGateway:
def charge(self, amount: float) -> bool:
print(f"正在通过 VNPay 充值 {amount}")
return True
def process_payment(gateway: PaymentGateway, amount: float) -> bool:
return gateway.charge(amount)
process_payment(VnPayGateway(), 199000.0)
VnPayGateway 不需要继承 PaymentGateway——只要有方法 charge(self, amount: float) -> bool,mypy 就会认可它满足 Protocol。这是一种非常“Pythonic”的做法,既保留了鸭子类型,又有静态检查。
8. 为项目配置 mypy
将依赖添加到 requirements-dev.txt:
mypy==1.11.2
在根目录创建配置文件 mypy.ini:
[mypy]
python_version = 3.12
disallow_untyped_defs = True
warn_return_any = True
warn_unused_ignores = True
ignore_missing_imports = True
disallow_untyped_defs = True强制所有函数都有类型提示,避免遗漏。warn_return_any = True当函数以某种方式返回Any时发出警告。ignore_missing_imports = True在使用尚无类型存根的第三方库时很有用。
检查整个项目:
mypy src/
9. 为具有类型提示的行为编写测试
类型提示不能替代测试,但能使测试对输入/输出数据类型更清晰。使用 pytest:
<!-- requirements-dev.txt 的等价形式,仅为示意依赖结构 -->
pytest==8.3.2
from order_service import calculate_total
def test_calculate_total_returns_correct_sum() -> None:
result = calculate_total(price=20.0, quantity=5)
assert result == 100.0
def test_calculate_total_with_zero_quantity() -> None:
result = calculate_total(price=50.0, quantity=0)
assert result == 0.0
运行测试:
pytest -v
test_order_service.py::test_calculate_total_returns_correct_sum PASSED
test_order_service.py::test_calculate_total_with_zero_quantity PASSED
你可以在 CI 中组合这两个步骤:先运行 mypy 捕获类型错误,然后运行 pytest 检查业务逻辑。这两个工具互补——mypy 捕获“类型错误”,pytest 捕获“逻辑错误”。
10. 常见错误和何时不需要 mypy
- 修改逻辑时忘记更新类型提示:例如函数从返回
Optional[Customer]改为总是返回Customer,但你忘记更新注解——如果代码仍然符合旧类型,mypy 不会报错,导致注解信息不再准确。 - 滥用
Any:声明def process(data: Any) -> Any会导致 mypy 失去检查能力,在那个位置静态类型检查形同虚设。 - 第三方库注解不正确或缺少类型存根:许多老旧的 package 还没有发布类型存根,mypy 会报
error: Cannot find implementation or library stub。你可以通过ignore_missing_imports来处理,或者安装types-<package>如果有的话。 - 何时不应该严格使用 mypy:对于小脚本、一次性使用的脚本(throwaway script)或者快速原型验证想法的情况——强制完整的类型提示可能会拖累编写速度,收益不成比例。
注意: mypy 只对源代码进行静态分析(static analysis),不运行程序。它无法捕获依赖于运行时数据的错误,例如从配置文件或外部 API 读取的值是否具有声明的正确类型。
11. 结论
类型提示使 Python 代码更易读、易维护,减少类型错误,而 mypy 将这些注解转变为真实的检查工具,而不仅仅是文档。你应该从公共函数、重要模块开始逐步应用,然后通过配置 disallow_untyped_defs 逐步扩展到整个项目。在 CI 中结合 mypy 和 pytest 测试是最实用的做法,既保持了 Python 的灵活性,又拥有接近静态类型语言的安全性。