Python 是动态类型语言,但今天任何一个稍具规模的工程,几乎都离不开类型注解。问题在于很多人停留在"加了 : int 显得专业"的层面,没搞清楚:注解在运行时到底有没有用?mypy 是怎么在不运行代码的情况下判断类型对不对的?Listlist 有什么区别?这些才是把类型用对、用好的关键。

场景:注解在运行时被无视

先记住一个反直觉的事实——类型注解默认不影响运行时行为

1
2
3
4
def add(a: int, b: int) -> int:
return a + b

print(add("x", "y")) # 输出 'xy',不报错!

注解不是断言,解释器不会用它做类型检查。它的真实身份是元数据,存在 __annotations__ 里:

1
print(add.__annotations__)   # {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}

这就引出类型系统的核心定位:注解是给静态分析工具和人看的,不是给解释器看的。真正去检查 add("x", "y") 不合法的,是 mypy / pyright 这类独立工具,它们在你提交代码前、不运行程序的情况下完成检查。

机制:静态检查器如何推断

mypy 做的事叫"类型推断 + 类型检查"。它解析 AST,为每个表达式赋一个静态类型,再验证赋值、调用、返回是否兼容。两个支撑它的核心理论概念值得理解:

渐进类型(Gradual Typing)。 Python 允许部分注解、部分不注解。未注解的地方被赋予特殊类型 AnyAny 与任何类型都兼容——它是检查器和动态世界之间的"逃生舱"。这让你能给老代码逐步加注解而不必一次到位。但代价是:Any 会"传染",一旦某个值是 Any,依赖它的推断都会松掉,检查形同虚设。滥用 Any 等于关掉类型检查。

协变与逆变(Variance)。 这是最容易踩坑的地方。List[Dog] 是不是 List[Animal] 的子类型?答案是不是。因为 list 可变:如果允许把 List[Dog]List[Animal] 用,别人就能往里塞一只 Cat,破坏了原 list 的不变量。所以可变容器是不变的(invariant)。而只读的 Sequence[Dog] 可以当 Sequence[Animal] 用,它是协变的(covariant)。这解释了一条实用准则:

1
2
3
4
5
from collections.abc import Sequence, Iterable

# 函数入参尽量用只读抽象类型,调用方传 list/tuple/任意可迭代都行
def total(items: Sequence[int]) -> int: # 比 List[int] 更宽松、更协变友好
return sum(items)

源码:常用注解的演进

1
2
3
4
5
6
7
8
9
10
11
from typing import Optional, Union

# 旧写法(typing 模块的泛型别名)
def f(x: Optional[int]) -> Union[str, bytes]: ...

# 现代写法(PEP 585 + PEP 604)
def g(x: int | None) -> str | bytes: ...

# 容器:直接用内置类型做泛型
nums: list[int] = []
mapping: dict[str, int] = {}

现代 Python 已经可以直接用 list[int]dict[str, int]int | None,不必再从 typing 导入 ListDictOptionalUnionX | None 就是 Optional[X],二者等价,前者更直观。

TypeVar 让你写真正的泛型函数,保留输入输出的类型关联:

1
2
3
4
5
from typing import TypeVar
T = TypeVar("T")

def first(items: list[T]) -> T: # 传 list[int] 返回 int,传 list[str] 返回 str
return items[0]

Protocol 则把 Python 的"鸭子类型"写进了类型系统——只要结构匹配就算兼容,不需要显式继承:

1
2
3
4
5
6
7
from typing import Protocol

class Closeable(Protocol):
def close(self) -> None: ...

def shutdown(r: Closeable) -> None: # 任何有 close() 的对象都接受
r.close()

这是结构化子类型(structural subtyping),契合 Python 一贯的"长得像就行"哲学,比强制继承基类优雅得多。

工程权衡与边界

  • 运行时零成本,但不是零代价。 注解不拖慢运行(除非你用 get_type_hints 反射),但维护注解、配置 mypy 严格度、处理误报都要投入。小脚本不值得,大项目和库则回报丰厚。
  • from __future__ import annotations 开启后所有注解变成字符串延迟求值,解决前向引用(类引用自身、互相引用)和导入循环问题,也避免在模块加载时构造昂贵的注解对象。代价是运行时拿注解必须用 typing.get_type_hints 去解析字符串。
  • 严格度是连续光谱。 mypy 从默认的宽松到 --strict 之间有十几个开关(disallow_untyped_defswarn_return_any 等)。新项目建议直接上较严格配置;老项目逐模块收紧,配合 # type: ignore[code] 精确压制而非全局放水。

常见误区

  • 以为加了注解就有了运行时校验。 没有。要运行时校验得用 Pydantic、dataclass + 校验,或 typeguard 这类库,它们才真正读注解做断言。
  • # type: ignore 不写错误码。# type: ignore 会吞掉该行所有类型错误,包括以后新引入的真问题。永远写成 # type: ignore[arg-type] 限定范围。
  • Anyobject 用。 object 是所有类型的顶,但对它几乎不能做任何操作(类型安全);Any 则什么都能做(放弃检查)。需要"接受任意类型但仍安全"时用 objectisinstance 收窄,而不是 Any
  • 可变默认值 + 注解的双重坑。 def f(x: list[int] = []) 既有可变默认值陷阱,注解也帮不了你,用 x: list[int] | None = None 在体内初始化。

小结

类型注解的本质是"运行时无视、静态有用"的元数据:解释器不读它,mypy/pyright 读。要把它用好,得理解渐进类型里 Any 的逃生舱与传染性、可变容器的不变性、Protocol 带来的结构化鸭子类型。现代语法已经能用 list[int]int | None 写得很干净。记住一句话——类型系统是工具不是枷锁,它的价值在于让大型代码库在重构和协作中"提前在编译期暴露错误",而不是装饰你的函数签名。