← 返回题目列表

mypy 怎么用好?渐进式类型检查该怎么落地?

中等 第 24 / 27 题 更新于 2026/08/03
mypy类型检查类型注解工程实践

简化版

Python 的类型注解在运行时基本不做任何事(除了被 Pydantic、FastAPI、dataclass 这类库主动读取),它的价值要靠静态检查器兑现——mypy 是最主流的,pyright(VS Code 的 Pylance 内核)速度更快、对新语法支持更早,ty 和 pyrefly 是新兴的 Rust 实现。mypy 的核心行为要先理解一件事:默认配置下它非常宽松——没有类型注解的函数体根本不会被检查--check-untyped-defs 才会),任何来自无类型第三方库的值都是 Any,而 Any 会像病毒一样传播Any 参与的任何运算结果还是 Any,检查全部失效)。所以「加了 mypy 但没发现问题」通常不是代码干净,而是根本没检查到落地的正确路径是「渐进式」:先在 CI 里跑起来(哪怕零配置),然后逐步收紧——disallow_untyped_defs(要求函数有注解)→ warn_return_anystrict = true对遗留模块用 [[tool.mypy.overrides]] 单独放宽,新代码严格要求。几个最常撞到的报错Optional 相关(x: str = None 在新版本会直接报错,要写 str | None)、可变默认参数Dict[str, Any] 用得太多导致检查失效、以及第三方库没有类型存根(装 types-xxx 或写 .pyi)。类型检查和测试是互补的类型检查覆盖「所有代码路径的类型正确性」但不验证逻辑,测试验证逻辑但只覆盖你写到的路径——两者结合才完整。核心记忆:注解运行时不生效,靠静态检查兑现默认很宽松,Any 会传播渐进式收紧 + 遗留模块单独放宽类型检查和测试互补

详细版

mypy 严格度的关键开关

选项作用建议
disallow_untyped_defs函数必须有注解首先开启
check_untyped_defs检查无注解函数的函数体过渡期用
warn_return_any返回 Any 时警告开启
no_implicit_optional禁止 x: str = None默认已开
warn_unused_ignores无用的 # type: ignore 报警开启
strict = true打开一整组严格选项新项目直接开
# ① ★★pyproject.toml 的推荐配置★★
[tool.mypy]
python_version = "3.12"
★strict = true★                          # ★新项目直接开★
★warn_unused_ignores = true★             # ★★清理过期的 ignore★★
★warn_redundant_casts = true★
★show_error_codes = true★                # ★★便于精确 ignore★★
★pretty = true★
exclude = ["build/", "migrations/"]

# ★★对遗留模块单独放宽(渐进式的关键)★★
[[tool.mypy.overrides]]
module = ["myapp.legacy.*", "myapp.scripts.*"]
★ignore_errors = true★                   # ★暂时完全跳过★

[[tool.mypy.overrides]]
module = ["myapp.old_module"]
★disallow_untyped_defs = false★          # ★只放宽这一项★

# ★★第三方库没有存根★★
[[tool.mypy.overrides]]
module = ["some_untyped_lib.*", "another_lib"]
★ignore_missing_imports = true★
# ② ★★Any 的传播(最重要的认知)★★
import untyped_lib                        # ★没有类型信息★
def process(data):                        # ★★没注解 → 整个函数体不检查★★
    result = untyped_lib.get()            # ★result: Any★
    return result.anything.at.all()       # ★★不报错!Any 上什么都合法★★

# ✓ 加上注解和边界类型
def process(data: dict[str, str]) -> User:
    raw: Any = untyped_lib.get()
return User.model_validate(raw)★      # ★★在边界处收敛成具体类型★★

# ③ ★现代类型注解语法(3.10+)★
def f(
    a: ★int | None= None,               # ★取代 Optional[int]★
    b: ★list[str]★ = [],                  # ★取代 List[str](3.9+)★
    c: ★dict[str, int]★ | None = None,
) -> ★tuple[int, str]★: ...
from collections.abc import ★Sequence, Callable, Iterator★   # ★不用 typing 里的★

# ④ ★★Protocol:结构化子类型(比 ABC 更 Pythonic)★★
from typing import Protocol
class Repo(★Protocol★):
    def get(self, id: int) -> User | None: ...
    def save(self, u: User) -> None: ...

def service(repo: Repo) -> None: ...       # ★★任何有这两个方法的对象都行★★
# ★★不需要显式继承,测试用的 FakeRepo 也自动满足★★

# ⑤ ★类型收窄(narrowing)★
def f(x: int | str | None) -> str:
    if x is None:
        return "none"                      # ★这里 x: None★
    ifisinstance(x, int)★:
        return str(x)                      # ★这里 x: int★
    return x.upper()                       # ★★这里 mypy 知道 x: str★★

# ★TypeGuard:自定义收窄(3.10+)★
from typing import TypeGuard
def is_str_list(v: list[object]) -> ★TypeGuard[list[str]]★:
    return all(isinstance(x, str) for x in v)

# ⑥ ★★常用的高级类型★★
from typing import TypeVar, Generic, Literal, Final, TypedDict, overload, cast
T = TypeVar("T")
def first(xs: list[T]) -> T | None: ...    # ★泛型★

Mode = ★Literal["r", "w", "a"]★            # ★★字面量类型(比 str 精确)★★
MAX: ★Final★ = 100                         # 常量

class UserDict(★TypedDict★):                # ★★给 dict 加结构★★
    id: int
    name: str
    email: ★NotRequired[str]★               # 3.11+

@overload★                                # ★不同参数不同返回类型★
def get(k: str) -> str: ...
@overload
def get(k: str, default: T) -> str | T: ...

x = ★cast(User, raw)★                       # ★★告诉 mypy「相信我」(运行时无效)★★

# ⑦ ★★精确的 ignore★★
result = lib.call()  ★# type: ignore[no-any-return]★   # ★带错误码★
# ✗ # type: ignore                          ★★裸 ignore 会掩盖新问题★★
# ★配 warn_unused_ignores 清理过期的★

⚠️ 三个必须记住的点:① 类型注解在运行时几乎不做任何事def f(x: int) -> str: 传进去一个字符串不会有任何报错——Python 只是把注解存进 __annotations__ 里,除非有库主动去读它(Pydantic 用它做校验、FastAPI 用它生成文档和解析参数、dataclasses 用它生成 __init__)。所以注解的价值完全靠静态检查器兑现:不跑 mypy/pyright 的注解,只是一种「有类型检查器验证过的注释」的错觉——它甚至可能是错的(注解写 int 实际传 str,不检查就永远不知道)。这也意味着:类型注解必须进 CI,否则会逐渐腐烂。② Any 会像病毒一样传播,是「加了 mypy 却查不出问题」的头号原因。任何来自无类型第三方库的返回值都是 Any,而 Any 参与的任何操作结果还是 Any——Any 上访问任何属性、调用任何方法、做任何运算,mypy 都认为合法。所以一个 Any 能让整条调用链的检查全部失效。应对方式是在边界处收敛:从外部(第三方库、JSON、数据库)拿到的数据,立刻用 Pydantic 校验或 cast 成具体类型,别让 Any 流进业务逻辑。用 --disallow-any-expr 之类的严格选项可以强制暴露它们。③ mypy 默认配置极其宽松,最典型的是「没有类型注解的函数体完全不检查」。一个几百行的老函数,只要没写参数和返回值的注解,mypy 就整个跳过它——你会得到「Success: no issues found」的假象。过渡期可以开 check_untyped_defs = true(检查无注解函数的函数体),最终目标是 disallow_untyped_defs = true(强制所有函数都有注解)。

完整版教学

一、类型注解在运行时做什么

★ ★★运行时:几乎什么都不做★★:
  def f(x: int) -> str:
      return x                            # ★★返回 int,运行时完全不报错★★
  f("hello")                              # ★★传字符串,也不报错★★
  ★ ★Python 只是把注解存起来★:
    f.__annotations__  → {'x': int, 'return': str}

★ ★★谁会真的用注解★★:
  ┌────────────────────────────────────────────────────┐
  │ ★dataclasses★    :★用注解生成 __init__ 等★          │
  │ ★Pydantic★       :★用注解做运行时校验和转换★        │
  │ ★FastAPI★        :★用注解解析参数 + 生成 OpenAPI★   │
  │ ★attrs / SQLModel★:类似                            │
  │ ★typing.get_type_hints()★:手动读取                  │
  │ ★静态检查器★     :★★mypy / pyright(不在运行时)★★  │
  └────────────────────────────────────────────────────┘

★ ★from __future__ import annotations(PEP 563)★:
  ★ 效果:★所有注解变成字符串,不在定义时求值★
  ★ ✓ 好处:
    - ★可以用还未定义的类型(前向引用)★
    - ★import 更快(不用真的构造类型对象)★
    - ★可以在 3.9 用 list[str] 这类新语法★
  ★ ✗ 坑:
    - ★★依赖运行时读注解的库可能出问题★★
      (Pydantic 需要能解析这些字符串 → 通常没问题但要注意作用域)
    - ★get_type_hints() 需要能访问到那些名字★

★ ★TYPE_CHECKING:只为类型检查而 import★:
  from typing import TYPE_CHECKING
  ★if TYPE_CHECKING:★
      from myapp.models import User       # ★★运行时不 import★★
  def f(u: ★"User"★) -> None: ...          # ★用字符串(或配 future annotations)★
  ★ ✓ ★解决循环导入★
  ★ ✓ ★避免为了类型注解引入重依赖★

★ ★运行时类型校验的方案(如果确实需要)★:
  ★ ✓ Pydantic:★最主流★
  ★ ✓ ★beartype / typeguard★:装饰器形式的运行时检查
    @beartype
    def f(x: int) -> str: ...             # ★★运行时真的会检查★★
  ★ ★但注意:运行时检查有性能开销★
  ★ ★大多数场景:静态检查 + 边界处用 Pydantic 就够了★

★ ★★注解的另一个价值:文档和 IDE★★:
  ★ ✓ ★IDE 补全、跳转、重构(改名能找到所有引用)★
  ★ ✓ ★函数签名即文档★(比 docstring 里写"参数是个字典"可靠)
  ★ ✓ ★code review 时能看出意图★
  ★ ★即使不跑 mypy,注解本身也有价值——但价值小得多★

类型注解在运行时几乎什么都不做——def f(x: int) -> str: return x 传字符串、返回 int 都不会报错,Python 只是把注解存进 __annotations__真正会读它的是几类库:dataclasses(生成 __init__)、Pydantic(运行时校验)、FastAPI(解析参数和生成文档)、以及静态检查器。from __future__ import annotations 让所有注解变成字符串不求值——好处是支持前向引用、import 更快,但依赖运行时读注解的库要注意作用域问题TYPE_CHECKING 块用于「只为类型检查而 import」——解决循环导入、避免为了注解引入重依赖。确实需要运行时校验时用 Pydantic 或 beartype,但大多数场景「静态检查 + 边界处 Pydantic」就够了

二、mypy 的默认宽松与收紧

★ ★★默认配置有多宽松(★关键认知★)★★:
  # ★零配置跑 mypy★
  def process(data):                      # ★★没注解★★
      return data.whatever.chain()        # ★★完全不检查★★
  # mypy: ★Success: no issues found★      ← ★★假象!★★

  ★ ★默认行为:★
    ✗ ★无注解的函数体不检查(--check-untyped-defs 才检查)★
    ✗ ★无注解的参数默认是 Any★
    ✗ ★无类型的第三方库 → 所有导入的东西都是 Any★
    ✗ ★不要求函数必须有注解★

★ ★★strict = true 打开的一整组★★:
  disallow_untyped_defs        ★函数必须有注解★
  disallow_incomplete_defs     ★不能只注解一部分参数★
  check_untyped_defs           ★检查无注解函数的函数体★
  disallow_untyped_decorators
  ★no_implicit_optional★        ★禁止 x: str = None★
  ★warn_redundant_casts★
  ★warn_unused_ignores★         ★★无用的 ignore 报警★★
  ★warn_return_any★             ★返回 Any 时警告★
  ★disallow_subclassing_any★
  ★disallow_any_generics★       ★★禁止裸的 list / dict(要写 list[str])★★
  strict_equality              ★禁止不可能相等的比较★

★ ★★渐进式收紧的路线★★:
  ★阶段 0:跑起来★
    mypy .                                # ★零配置,先看有多少错★
    ★[tool.mypy] ignore_errors = true★     # ★全局先关,只让它能跑★

  ★阶段 1:新代码严格★
    [tool.mypy]
    ★disallow_untyped_defs = true★
    [[tool.mypy.overrides]]
    module = ["myapp.legacy.*"]
    ★ignore_errors = true★                 # ★★老代码先豁免★★

  ★阶段 2:逐模块开启★
    # ★每次挑一个模块,去掉它的豁免,修完再合★
    module = ["myapp.legacy.a", "myapp.legacy.b"]   # ★列表越来越短★

  ★阶段 3:strict★
    ★strict = true★
    # ★剩余的个别问题用精确的 type: ignore[code]★

★ ★★衡量进度:类型覆盖率★★:
  mypy ★--html-report report/★             # ★可视化每个文件的覆盖★
  mypy ★--linecount-report .★
  # ★或用 mypy 的 --any-exprs-report★
  ★ ✓ ★把"类型覆盖率"当作和测试覆盖率并列的指标★

★ ★★CI 里的两种策略★★:
  ① ★全量检查(推荐)★
     mypy .                               # ★配合 overrides 逐步收紧★
  ② ★只检查改动的文件★
     ★git diff --name-only origin/main | grep '\.py$' | xargs mypy★
     ★ ✗ ★问题:改动可能影响其他文件★
     ★ ✓ 适合超大项目的过渡期

★ ★缓存加速★:
  mypy ★--cache-dir=.mypy_cache★           # ★默认就有★
  ★ ✓ ★CI 里缓存这个目录能大幅提速★
  mypy ★--incremental★                     # 默认开启

必须理解 mypy 的默认配置有多宽松无注解的函数体完全不检查、无注解参数是 Any、无类型的第三方库导入的一切都是 Any——所以「Success: no issues found」可能是根本没检查的假象。strict = true 打开一整组选项,其中几个高价值的:disallow_untyped_defswarn_unused_ignores(清理过期的 ignore)warn_return_anydisallow_any_generics(禁止裸的 list/dict渐进式收紧的路线是四个阶段:先跑起来(全局 ignore_errors)→ 新代码严格 + 老代码豁免 → 逐模块去掉豁免(豁免列表越来越短) → 最终 strict = true把「类型覆盖率」当作和测试覆盖率并列的指标mypy --html-report)。CI 里缓存 .mypy_cache 能大幅提速

三、常见报错与处理

★ ★① Optional 相关(最高频)★:
  ✗ def f(name: str = None): ...
    ★error: Incompatible default for argument "name"★
  ✓ def f(name: ★str | None★ = None): ...

  ✗ user = get_user()                     # 返回 User | None
    ★print(user.name)★
    ★error: Item "None" of "User | None" has no attribute "name"★
  ✓ ★收窄★:
    if user is None: raise NotFound()
    print(user.name)                      # ★这里 mypy 知道非 None★
  ✓ ★或断言★:
    ★assert user is not None★
  ✓ ★或用 walrus★:
    if (user := get_user()) is not None: ...

★ ★② 可变默认参数★:
  ✗ def f(items: list[str] = ★[]★): ...    # ★mypy 不报错但这是 bug★
  ✓ def f(items: list[str] | None = None):
        items = items ★or []★

★ ★③ 第三方库没有类型信息★:
  ★error: Skipping analyzing "somelib": module is installed,
   but missing library stubs or py.typed marker★
  ✓ ★方案一:装官方存根★
    pip install ★types-requests types-PyYAML types-redis★
    # ★或 mypy --install-types★
  ✓ ★方案二:忽略★
    [[tool.mypy.overrides]]
    module = ["somelib.*"]
    ★ignore_missing_imports = true★
  ✓ ★方案三:自己写 .pyi 存根★
    # stubs/somelib/__init__.pyi
    def do_thing(x: int) -> str: ...
    # pyproject: mypy_path = "stubs"

★ ★④ Dict[str, Any] 泛滥(★检查失效的元凶★)★:
  ✗ def process(data: ★dict[str, Any]★) -> ★dict[str, Any]★:
        return {"result": data["key"].something}   # ★★全是 Any,不检查★★
  ✓ ★用 TypedDict★:
    class Input(TypedDict):
        key: str
        count: int
    def process(data: Input) -> Output: ...
  ✓ ★或用 dataclass / Pydantic 模型★
  ★ ★经验:看到 Dict[str, Any] 就想想能不能给它结构★

★ ★⑤ 装饰器丢失类型★:
  ✗ def my_decorator(f):                  # ★没注解 → 被装饰的函数变 Any★
        def wrapper(*a, **kw): return f(*a, **kw)
        return wrapper
  ✓ ★用 ParamSpec(3.10+)★:
    from typing import ParamSpec, TypeVar, Callable
    ★P = ParamSpec("P")★; R = TypeVar("R")
    def my_decorator(f: ★Callable[P, R]★) -> ★Callable[P, R]★:
        @functools.wraps(f)
        def wrapper(*a: ★P.args★, **kw: ★P.kwargs★) -> R:
            return f(*a, **kw)
        return wrapper

★ ★⑥ 需要"逃生舱"时★:
  x = ★cast(User, raw_data)★               # ★告诉 mypy「相信我」★
  ★# type: ignore[error-code]★             # ★★带错误码,精确★★
  def f() -> ★Any★: ...                    # 显式承认无法表达
  ★ ★三条纪律:★
    ① ★ignore 必须带错误码★(# type: ignore[arg-type])
    ② ★开 warn_unused_ignores 清理过期的★
    ③ ★ignore 旁边写注释说明为什么★

★ ★⑦ 常见错误码速查★:
  ┌──────────────────────┬────────────────────────────┐
  │ ★arg-type★            │ 参数类型不匹配              │
  │ ★return-value★        │ 返回值类型不匹配            │
  │ ★attr-defined★        │ ★对象没有这个属性★          │
  │ ★union-attr★          │ ★★Optional 没收窄★★         │
  │ ★assignment★          │ 赋值类型不兼容              │
  │ ★no-any-return★       │ ★返回了 Any★                │
  │ ★import-untyped★      │ ★第三方库没类型★            │
  │ ★call-arg★            │ 参数个数/名字不对           │
  │ ★override★            │ ★子类方法签名不兼容★        │
  └──────────────────────┴────────────────────────────┘

常见报错里Optional 相关最高频——x: str = None 现在直接报错(要写 str | None)、user.nameUser | None 上报 union-attr(要先收窄)。dict[str, Any] 泛滥是检查失效的元凶——看到它就想想能不能用 TypedDict 或 dataclass 给它结构装饰器会丢失类型(被装饰的函数变 Any)——ParamSpec 保留签名。第三方库没类型时优先装官方存根types-requestsmypy --install-types)。需要逃生舱时有三条纪律ignore 必须带错误码# type: ignore[arg-type])、warn_unused_ignores 清理过期的旁边写注释说明原因

四、类型设计的实践

★ ★★Protocol:比 ABC 更适合 Python★★:
  # ✗ ABC:要显式继承
  class RepoABC(ABC):
      @abstractmethod
      def get(self, id: int) -> User: ...
  class SqlRepo(★RepoABC★): ...             # ★必须继承★

  # ✓ ★Protocol:结构化子类型★
  class Repo(★Protocol★):
      def get(self, id: int) -> User | None: ...
  class SqlRepo: ...                       # ★★不用继承,方法对上就行★★
  class FakeRepo: ...                      # ★★测试替身也自动满足★★
  def service(repo: ★Repo★): ...
  ★ ✓ ★不侵入第三方类★
  ★ ✓ ★测试的 Fake 天然兼容★
  ★ ✓ ★依赖的是"能力"而不是"血缘"★
  # ★运行时也想 isinstance 检查:★
  @runtime_checkable
  class Repo(Protocol): ...

★ ★★用类型表达业务约束★★:
  ✗ def transfer(from_: str, to: str, amount: float): ...
    ★两个 str 参数可以传反、float 有精度问题★
  ✓ ★NewType 区分语义★:
    ★AccountId = NewType("AccountId", str)★
    ★UserId = NewType("UserId", str)★
    def transfer(from_: AccountId, to: AccountId, amount: Decimal): ...
    # ★★传 UserId 进去会被 mypy 拦住★★
  ✓ ★Literal 限定取值★:
    def open_file(mode: ★Literal["r", "w", "a"]★): ...
    open_file("x")                        # ★★mypy 报错★★
  ✓ ★Enum★
  ✓ ★Final 防止意外修改★

★ ★★TypedDict:给 JSON 结构加类型★★:
  class Config(TypedDict):
      host: str
      port: int
      ★debug: NotRequired[bool]★           # ★3.11+ 可选键★
  cfg: Config = json.load(f)               # ★★现在有结构了★★
  cfg["hostt"]                             # ★★mypy 报错:拼写错误★★
  ★ ✓ ★适合"就是字典但有固定结构"的场景★
  ★ ✗ ★不做运行时校验★(要校验用 Pydantic)

★ ★★泛型与 TypeVar★★:
  T = TypeVar("T")
  def first(xs: ★Sequence[T]★) -> ★T | None★:
      return xs[0] if xs else None
  # ★有约束的 TypeVar★
  ★Num = TypeVar("Num", int, float)★        # ★只能是这两种之一★
  ★TModel = TypeVar("TModel", bound=BaseModel)★   # ★★必须是 BaseModel 子类★★

  # ★泛型类★
  class Repository(★Generic[TModel]★):
      def get(self, id: int) -> ★TModel | None★: ...
  class UserRepo(★Repository[User]★): ...   # ★★类型自动传播★★

  # ★3.12 的新语法★
  ★def first[T](xs: list[T]) -> T | None: ...★
  ★class Repository[TModel]: ...★

★ ★★把类型当作设计工具★★:
  ★ ✓ ★让非法状态无法表示★:
    ✗ class Order:
          status: str
          paid_at: datetime | None        # ★status="paid" 但 paid_at=None?★
    ✓ ★用联合类型表达状态机★:
      @dataclass
      class PendingOrder: created_at: datetime
      @dataclass
      class PaidOrder: created_at: datetime; ★paid_at: datetime★
      ★Order = PendingOrder | PaidOrder★
      # ★★"已支付但没有支付时间"这种状态无法构造★★
  ★ ✓ ★函数签名即契约★:
    参数多 = 职责多;返回 Any = 说不清楚

Protocol 比 ABC 更适合 Python——结构化子类型,不需要显式继承,测试的 Fake 天然满足,依赖的是「能力」而不是「血缘」用类型表达业务约束是高价值的实践:NewType 区分语义AccountId vs UserId,传反了会被 mypy 拦住)、Literal 限定取值EnumFinalTypedDict 给 JSON 结构加类型(拼写错误能被发现,但不做运行时校验)。最高阶的用法是「让非法状态无法表示」——把 status: str + paid_at: datetime | None 改成 PendingOrder | PaidOrder 的联合类型,「已支付但没有支付时间」这种状态就无法构造了

五、mypy vs 其他检查器

★ ★★四个主要选择★★:
  ┌────────────┬──────────────────────────────────────────┐
  │ ★mypy★      │ ★官方参考实现、生态最成熟★                │
  │            │ ✗ ★慢(大项目几十秒到几分钟)★            │
  │            │ ✓ 配置最灵活、文档最全                    │
  │ ★pyright★   │ ★★VS Code Pylance 的内核★★                │
  │            │ ✓ ★快很多(TypeScript 写的)★             │
  │            │ ✓ ★对新 PEP 支持更早★                     │
  │            │ ✓ ★类型收窄更聪明★                        │
  │            │ ✗ 配置和 mypy 不完全兼容                  │
  │ ★ty★        │ ★Astral(ruff 团队)的 Rust 实现★         │
  │            │ ✓ ★极快★    ✗ ★还在早期★                  │
  │ ★pyrefly★   │ Meta 的 Rust 实现                        │
  └────────────┴──────────────────────────────────────────┘
  ★ ★现实:很多团队 IDE 里用 pyright(即时反馈)+ CI 里用 mypy★
  ★ ✗ ★两者的报错不完全一致 → 可能互相打架★
  ✓ ★选一个作为"权威"(CI 里跑的那个),另一个只当辅助★

★ ★★类型检查 vs 测试:互补关系★★:
  ┌──────────────────────────────────────────────────────┐
  │ ★类型检查★                                            │
  │   ✓ ★覆盖所有代码路径★(不用写用例)                   │
  │   ✓ ★重构时立刻发现签名不匹配★                        │
  │   ✓ ★IDE 补全和跳转★                                  │
  │   ✗ ★不验证逻辑正确性★(类型对但算错了照样过)         │
  │   ✗ ★不发现运行时问题★(None 从外部来、数据格式变了)  │
  ├──────────────────────────────────────────────────────┤
  │ ★测试★                                                │
  │   ✓ ★验证行为正确★                                    │
  │   ✓ 覆盖运行时和集成问题                              │
  │   ✗ ★只覆盖你写到的路径★                              │
  │   ✗ 写起来慢                                          │
  └──────────────────────────────────────────────────────┘
  ★ ★典型分工:★
    - ★"参数类型对不对、字段名有没有拼错、重构有没有漏改" → 类型检查★
    - ★"业务规则对不对、边界行为、集成是否正常" → 测试★
  ★ ★有了类型检查,某些测试可以不写★:
    ✗ def test_returns_string(): assert isinstance(f(), str)
    → ★类型检查已经保证了★

★ ★和 ruff 的配合★:
  ★ruff★:★格式化 + lint(速度极快)★
  ★mypy★:★类型检查★
  ★ 两者不重叠:ruff ★不做类型推断★
  # pyproject.toml
  [tool.ruff.lint]
  select = ["E", "F", "I", "N", "UP", "B", "ANN"]
  ★"ANN"★  # ★★flake8-annotations:要求有注解(但不检查正确性)★★

★ ★pre-commit 集成★:
  # .pre-commit-config.yaml
  - repo: https://github.com/pre-commit/mirrors-mypy
    hooks:
      - id: mypy
        ★additional_dependencies: [types-requests, pydantic]★
        ★args: [--strict]★
  ★ ✗ ★注意:pre-commit 只检查改动的文件 → 可能漏掉影响★
  ✓ ★CI 里跑全量★

★ ★运行时校验的边界(★重要区分★)★:
  ★ ★类型检查管不到"外部来的数据"★:
    - ★HTTP 请求体★
    - ★JSON 文件★
    - ★数据库返回★
    - ★环境变量★
  ★ ★这些必须运行时校验★ → ★Pydantic★
  ★ ★典型架构:★
    ★边界处 Pydantic 校验 → 内部全是有类型的对象 → mypy 保证正确使用★

四个检查器的选择:mypy 生态最成熟但慢、pyright(Pylance 内核)快很多且对新 PEP 支持更早、ty 和 pyrefly 是新兴的 Rust 实现。现实中很多团队 IDE 用 pyright、CI 用 mypy——但两者报错不完全一致可能打架,要选一个作为权威类型检查和测试是互补的类型检查覆盖所有代码路径但不验证逻辑,测试验证逻辑但只覆盖写到的路径——分工是「参数类型、字段拼写、重构漏改」归类型检查,「业务规则、边界行为、集成」归测试。有了类型检查,assert isinstance(f(), str) 这类测试就可以不写了。最后一个重要区分:类型检查管不到「外部来的数据」(HTTP 请求、JSON、数据库、环境变量)——这些必须用 Pydantic 做运行时校验,典型架构是「边界处校验 → 内部全是有类型的对象 → mypy 保证正确使用」。

六、实践清单

★ ★新项目的推荐配置★:
  [tool.mypy]
  python_version = "3.12"
  ★strict = true★
  warn_unused_ignores = true
  show_error_codes = true
  pretty = true
  exclude = ["migrations/", "build/"]
  plugins = ★["pydantic.mypy"]★            # ★★Pydantic 插件★★

  [[tool.mypy.overrides]]
  module = ["tests.*"]
  ★disallow_untyped_defs = false★          # ★测试可以放宽(有争议)★

★ ★遗留项目的落地步骤★:
  ① ★先跑起来★:mypy . 看有多少错
  ② ★全局 ignore_errors,只让 CI 能过★
  ③ ★新代码强制注解★(pre-commit + ANN 规则)
  ④ ★按模块逐个开启★(豁免列表越来越短)
  ⑤ ★核心模块优先★(业务逻辑 > 工具函数 > 脚本)
  ⑥ ★最终 strict★

★ 检查清单:
  【配置】
  □ ★CI 里跑了 mypy(否则注解会腐烂)★
  □ ★开了 warn_unused_ignores★
  □ ★show_error_codes(便于精确 ignore)★
  □ ★遗留模块用 overrides 而不是全局放宽★
  □ ★缓存 .mypy_cache 加速 CI★
  【代码】
  □ ★用现代语法(int | None、list[str])★
  □ ★避免 dict[str, Any](用 TypedDict/dataclass)★
  □ ★Optional 记得收窄★
  □ ★装饰器用 ParamSpec 保留签名★
  □ ★ignore 带错误码 + 注释原因★
  □ ★外部数据用 Pydantic 校验(类型检查管不到)★
  【设计】
  □ ★依赖用 Protocol 而不是具体类★
  □ ★NewType 区分同为 str 的不同语义★
  □ ★Literal / Enum 限定取值★
  □ ★用联合类型让非法状态无法表示★

★ ★★常见问题速查★★:
  ┌────────────────────────────────────┬──────────────────┐
  │ mypy 说没问题但线上报 AttributeError │ ★Any 传播/没检查★ │
  │ Success 但函数体明显有问题           │ ★★函数没注解★★    │
  │ 第三方库全是 error                  │ ★装 types-xxx★    │
  │ Optional 报 union-attr              │ ★没收窄★          │
  │ 装饰器后类型全丢                     │ ★用 ParamSpec★    │
  │ IDE 不报错但 CI 报                  │ ★pyright vs mypy★ │
  │ mypy 很慢                           │ ★缓存 + 增量★     │
  └────────────────────────────────────┴──────────────────┘

★ 一句话总结:
  ★"类型注解运行时几乎不做事,价值全靠静态检查器兑现——
    所以必须进 CI,否则会腐烂;
    mypy 默认极其宽松(无注解的函数体根本不检查、Any 会像病毒一样传播),
    『没报错』常常是『没检查』;
    落地靠渐进式:新代码 strict + 遗留模块 overrides 豁免,逐个收复;
    类型检查和测试互补——前者覆盖所有路径但不验证逻辑,
    而外部数据必须用 Pydantic 做运行时校验。"★

新项目直接 strict = true 并加 Pydantic 的 mypy 插件。遗留项目按六步落地,核心是「新代码强制 + 遗留模块豁免 + 逐个收复」。问题速查表里最有价值的两条:「mypy 说没问题但线上报 AttributeError」是 Any 传播或根本没检查「Success 但函数体明显有问题」是函数没注解

记忆钩子:「★Python 的类型注解在运行时几乎不做任何事★——def f(x: int) -> str: return x 传字符串、返回 int 都不报错,Python 只是存进 annotations,★除非有库主动去读(Pydantic 校验、FastAPI 解析参数、dataclass 生成 init)★;★所以注解的价值全靠静态检查器兑现,必须进 CI 否则会腐烂★。★两个关键认知★:★① mypy 默认极其宽松——没有类型注解的函数体根本不会被检查★(要 —check-untyped-defs),所以★『Success: no issues found』常常是『根本没检查』的假象★;★② Any 会像病毒一样传播★——任何来自无类型第三方库的值都是 Any,而 ★Any 参与的任何操作结果还是 Any★(属性访问、方法调用、运算全部合法),★一个 Any 能让整条调用链的检查失效★ → 对策是★在边界处收敛★(外部数据立刻用 Pydantic 校验或 cast 成具体类型)。★落地靠渐进式★:先跑起来(全局 ignore_errors)→ ★新代码 disallow_untyped_defs + 遗留模块用 [[tool.mypy.overrides]] 豁免★ → 逐模块去掉豁免(★豁免列表越来越短★)→ 最终 ★strict = true★;★把类型覆盖率当作和测试覆盖率并列的指标★。常见报错:★Optional 最高频★(x: str = None 现在直接报错要写 str | Noneuser.nameUser | None 上报 union-attr 要先收窄)、★dict[str, Any] 泛滥是检查失效的元凶(用 TypedDict 或 dataclass 给它结构)★、★装饰器会丢失类型(用 ParamSpec 保留签名)★、第三方库没存根就装 types-xxx。★逃生舱三条纪律:ignore 必须带错误码、开 warn_unused_ignores 清理过期的、旁边写注释说明原因★。类型设计的高价值实践:★Protocol 结构化子类型比 ABC 更 Pythonic(不用继承、测试的 Fake 天然满足、依赖能力而非血缘)★、★NewType 区分同为 str 的不同语义(AccountId vs UserId 传反会被拦住)★、★Literal/Enum 限定取值★、★最高阶是用联合类型让非法状态无法表示(PendingOrder | PaidOrder,『已支付但没有支付时间』就无法构造)★。★类型检查和测试互补★:★前者覆盖所有代码路径但不验证逻辑,后者验证逻辑但只覆盖写到的路径★;★类型检查管不到外部来的数据(HTTP 请求、JSON、数据库、环境变量),这些必须 Pydantic 运行时校验★——典型架构是★边界处校验 → 内部全是有类型的对象 → mypy 保证正确使用★。工具选择:★mypy 生态最成熟但慢,pyright(Pylance 内核)快很多且对新 PEP 支持更早★,★两者报错不完全一致会打架,要选一个作为权威★。」

七、常见误区与追问

  • 误区:写了类型注解,传错类型时程序会报错。 完全不会。Python 的类型注解是「给工具看的元数据」,解释器执行时只是把它们存进 __annotations__ 字典,不做任何检查——def f(x: int) -> str: return x 这个函数,你传字符串进去、它返回整数,运行时一切正常。真正会消费注解的只有几类:Pydantic(用它做运行时校验和类型转换)、FastAPI(用它解析请求参数、生成 OpenAPI)、dataclasses/attrs(用它生成 __init__)、以及静态检查器(mypy、pyright,在你的代码运行之前)。所以「加了注解」和「类型安全」之间隔着一个必要条件:必须在 CI 里跑静态检查。否则注解不但没有保护作用,还会逐渐腐烂成错误的文档——代码改了但注解没改,读的人反而被误导。如果确实需要运行时校验,边界处用 Pydantic,或者用 beartype/typeguard 这类装饰器(有性能开销)。
  • 误区:mypy 跑完显示「Success: no issues found」,说明代码类型没问题。 很可能是根本没检查。mypy 的默认配置极其宽松,最关键的一条是:没有类型注解的函数,它的函数体完全不会被检查。所以一个几百行、内部到处是属性访问和方法调用的老函数,只要签名上没写注解,mypy 就整个跳过——你得到的「Success」毫无意义。第二个原因是 Any 的传播:如果某个第三方库没有类型信息,从它拿到的所有值都是 Any,而 Any 上做任何操作 mypy 都认为合法,一个 Any 能让整条调用链的检查失效。验证方法:跑 mypy --html-report 看真实的类型覆盖率,或者开 check_untyped_defs = true 看看错误数会不会暴涨。「零错误」应该配合「高类型覆盖率」一起看,就像测试通过要配合覆盖率一起看一样。
  • 误区:Dict[str, Any] 挺方便的,处理 JSON 数据用它就行。 它是类型检查失效的元凶data: dict[str, Any] 意味着:data["anything"] 返回 Anydata["x"].whatever.chain() 也不报错、拼错 key 更是发现不了——你写了类型注解,但检查器什么都查不出来。而且 Any 会继续往下传播:这个函数返回 dict[str, Any],调用方拿到的又是一堆 Any。三个改进方向:TypedDict——给字典加上结构(class Config(TypedDict): host: str; port: int),拼错 key 会被报错,而且不改变运行时行为(它还是普通 dict);② dataclass 或 Pydantic 模型——如果这个数据在系统里流转,转成对象更清晰;③ 如果确实是任意结构的 JSON,至少用 object 而不是 Anyobject 上做任何操作都要先收窄,能强制你显式处理)。经验法则:看到 dict[str, Any] 就问一句「这个字典的结构真的不确定吗」
  • 误区:遗留项目太大,加 mypy 不现实。 正是遗留项目最需要,而且有成熟的渐进式路径。核心工具是 [[tool.mypy.overrides]]——它允许你对不同模块设置不同的严格度。落地路线:① 先让它能跑——零配置执行一次看看错误规模,然后全局 ignore_errors = true 让 CI 先绿;② 新代码强制——把 disallow_untyped_defs = true 设为全局默认,同时把所有现存模块加进 overrides 豁免列表,这样新写的代码必须有注解,老代码不受影响③ 逐模块收复——每个迭代挑一两个模块,从豁免列表里删掉、修完报错、合并,豁免列表会越来越短(这个列表本身就是进度条);④ 优先级——核心业务逻辑 > 公共工具函数 > 边缘脚本,因为类型检查在「被大量复用的代码」上收益最高。整个过程可以持续几个月,关键是「新代码不再欠债」+「老代码稳步偿还」
  • 误区:装饰器加上去之后类型就没了,只能忍。 ParamSpec 可以完整保留签名(Python 3.10+,更早版本可以从 typing_extensions 导入)。问题的根源是:def my_decorator(f): def wrapper(*a, **kw): return f(*a, **kw); return wrapper 里,wrapper 的签名是 (*args, **kwargs)——mypy 只能推断出被装饰后的函数「接受任意参数」,于是调用时传错参数、传错类型都不报错,而且返回值也变成 Any。正确写法是用 ParamSpec 捕获参数签名、TypeVar 捕获返回类型:P = ParamSpec("P"); R = TypeVar("R"),然后 def my_decorator(f: Callable[P, R]) -> Callable[P, R],内部 def wrapper(*a: P.args, **kw: P.kwargs) -> R。这样被装饰的函数签名完全不变,IDE 补全和 mypy 检查都正常。如果装饰器会改变签名(比如注入一个参数),可以用 Concatenate。这是一个高价值的改进,因为装饰器往往用在项目最核心的地方(认证、缓存、重试、事务)。
  • 追问:mypy 和 pyright 该选哪个? 两者可以共存,但要明确谁是「权威」mypy 是 PEP 类型系统的官方参考实现,配置最灵活、文档最全、生态插件多(Pydantic、Django、SQLAlchemy 都有官方插件),缺点是(大项目全量检查几十秒到几分钟,虽然有增量缓存)。pyright 是微软用 TypeScript 写的,也是 VS Code 里 Pylance 的内核——速度快得多、对新 PEP 的支持通常更早、类型收窄的推断更聪明(比如对 assertmatch、自定义 TypeGuard 的处理),缺点是配置和 mypy 不完全兼容、某些边缘行为有差异。现实中最常见的组合是:开发者在 VS Code 里享受 pyright 的即时反馈,CI 里跑 mypy 作为门禁。但要注意两者的报错不完全一致——可能出现「IDE 不报错但 CI 挂了」或反过来,容易让人困惑。所以建议明确一个作为权威(通常是 CI 里跑的那个),另一个只当辅助、报错不一致时以权威为准。新兴的 ty(ruff 团队)和 pyrefly(Meta)都是 Rust 实现、速度极快,但还在早期,值得关注但暂时不适合作为唯一依赖。
  • 追问:有了类型检查,还需要写测试吗? 需要,两者覆盖的是完全不同的风险类型检查的优势是「覆盖所有代码路径而不需要写用例」——它能保证「这个函数的所有调用点传的参数类型都对」「重构改了签名后所有调用方都同步了」「字段名没拼错」,这些用测试来保证成本极高(要写大量用例才能覆盖所有分支)。但它完全不验证逻辑正确性——def add(a: int, b: int) -> int: return a - b 类型完美但结果全错。测试的优势是验证行为,但只能覆盖你写到的路径。所以分工很清晰:「参数类型、字段拼写、重构是否漏改、接口是否兼容」交给类型检查;「业务规则、边界行为、集成是否正常」交给测试。一个实际的好处是:有了类型检查,某些测试可以不写了——def test_returns_string(): assert isinstance(f(), str) 这种「验证返回类型」的测试完全是浪费。另外还有一类风险两者都覆盖不了:外部数据的形状(API 响应变了、数据库字段改了、配置文件写错了)——这必须靠运行时校验(Pydantic)
  • 追问:# type: ignore 用多了会不会让类型检查失效? 会,所以要有纪律。裸的 # type: ignore抑制那一行的所有类型错误——包括你本意之外的、以及以后新出现的。三条实践规则:① 必须带错误码——# type: ignore[arg-type] 只忽略这一种错误,如果同一行以后出现了别的问题(比如 attr-defined),mypy 仍然会报出来。这需要开启 show_error_codes = true(新版本默认开)。② 开启 warn_unused_ignores = true——当某个 ignore 因为库更新、代码改动而不再需要时,mypy 会提醒你删掉它;没有这个选项,过期的 ignore 会永久留在代码里,掩盖后来出现的真实问题③ 旁边写注释说明为什么——# type: ignore[no-any-return] # third-party lib returns Any, validated by pydantic below。此外还要区分 ignorecastcast(User, raw) 表达的是「我知道它是 User,只是 mypy 推不出来」(语义更明确、后续检查仍然生效),而 ignore 是「别管这里」——能用 cast 就别用 ignore

八、加强记忆

Python 的类型注解在运行时几乎不做任何事——def f(x: int) -> str: return x 传字符串、返回 int 都不会报错,Python 只是把它存进 __annotations__除非有库主动去读(Pydantic 校验、FastAPI 解析参数、dataclass 生成 __init__);所以注解的价值全靠静态检查器兑现,必须进 CI,否则会腐烂成错误的文档两个关键认知① mypy 默认极其宽松——没有类型注解的函数体根本不会被检查(要开 check_untyped_defs),所以**「Success: no issues found」常常是「根本没检查」的假象**;Any 会像病毒一样传播——任何来自无类型第三方库的值都是 Any,而 Any 参与的任何操作结果还是 Any(属性访问、方法调用、运算全都合法),一个 Any 能让整条调用链的检查失效 → 对策是在边界处收敛(外部数据立刻用 Pydantic 校验或 cast 成具体类型)。落地靠渐进式:先跑起来(全局 ignore_errors)→ 新代码 disallow_untyped_defs + 遗留模块用 [[tool.mypy.overrides]] 豁免 → 逐模块去掉豁免(豁免列表越来越短,它本身就是进度条)→ 最终 strict = true把类型覆盖率当作和测试覆盖率并列的指标。常见报错:Optional 最高频x: str = None 现在直接报错、要写 str | Noneuser.nameUser | None 上报 union-attr、要先收窄)、dict[str, Any] 泛滥是检查失效的元凶(用 TypedDict 或 dataclass 给它结构)、装饰器会丢失类型(用 ParamSpec 保留签名)、第三方库没存根就装 types-xxx逃生舱的三条纪律ignore 必须带错误码warn_unused_ignores 清理过期的旁边写注释说明原因(另外能用 cast 就别用 ignore)。类型设计的高价值实践:Protocol 结构化子类型比 ABC 更 Pythonic(不用继承、测试的 Fake 天然满足、依赖的是能力而非血缘)、NewType 区分同为 str 的不同语义AccountId vs UserId,传反了会被拦住)、Literal/Enum 限定取值最高阶的是用联合类型让非法状态无法表示PendingOrder | PaidOrder,「已支付但没有支付时间」就无法构造)。类型检查和测试互补前者覆盖所有代码路径但不验证逻辑,后者验证逻辑但只覆盖写到的路径类型检查管不到外部来的数据(HTTP 请求、JSON、数据库、环境变量),这些必须用 Pydantic 做运行时校验——典型架构是边界处校验 → 内部全是有类型的对象 → mypy 保证正确使用。工具选择上:mypy 生态最成熟但慢,pyright(Pylance 内核)快很多且对新 PEP 支持更早两者报错不完全一致会打架,要明确选一个作为权威