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 的灵活性,又拥有接近静态类型语言的安全性。

pythontype-hintsmypystatic-typing