Python: 类型系统的使用约定
Python 是动态类型语言。类型注解没有改变这一点。
本文记录笔者在 Python 项目中使用静态类型的一组约定。目的不是在运行时重新实现一套类型检查,而是在编码阶段尽可能明确接口、数据结构和类型之间的关系,并让静态检查器能够验证这些约定。
本文以 Python 3.14 和 MyPy 为基准。
1. 静态检查必须通过
类型注解如果只用于补全,而不能通过静态检查,其价值有限。
项目中的 MyPy 检查应当保持通过。新代码原则上不引入未解决的类型错误,也不通过大量 # type: ignore 将问题隐藏起来。
一个较严格的基础配置可以从下面开始:
[]
= "3.14"
= true
= true
= true
= true
= true
= true
其中 strict 打开一组严格检查,但对于 Any 的限制还可以进一步收紧。对于必须接入缺少类型信息的第三方库等边界场景,可以针对具体模块单独放宽,而不是降低整个项目的检查级别。
Any
Any 会切断类型检查。
一个值进入 Any 后,静态检查器基本不再约束它的属性访问、参数传递和返回值,因此 Any 很容易沿调用链向外扩散。
因此,具体类型优先于 Any:
# 不推荐
...
# 更明确
...
这并不意味着 Any 永远不能出现。某些动态边界、本身没有类型信息的第三方接口,或者确实需要接受任意 Python 对象的底层抽象,仍可能需要它。
问题不在于某一行代码是否出现 Any,而在于这种动态性是否被限制在明确的边界内。
type: ignore
# type: ignore 只用于已经理解其原因、同时现有类型系统无法正确表达的情况。
应尽量指定错误代码:
= # type: ignore[attr-defined]
而不是:
= # type: ignore
如果一个 ignore 无法说明自己忽略的是什么,它通常已经过于宽泛。
处理类型错误时,大致按照下面的顺序:
- 修正错误的类型声明;
- 补充缺失的泛型、Protocol 或 overload;
- 在确定运行时类型但静态检查器无法推导时,使用窄范围的
cast; - 最后才使用
# type: ignore[code]。
静态检查的目标不是消灭所有特殊情况,而是让特殊情况保持可见。
2. 类型应该表达关系
类型注解不只需要说明“这里允许哪些类型”,还应尽可能表达输入与输出之间的关系。
例如:
return
这份声明允许以下错误组合:
int -> list[float]
float -> list[int]
虽然实现没有这样做,但函数签名没有表达这种约束。
如果返回值与输入类型保持一致,应使用类型参数:
return
现在类型关系成为函数签名的一部分:
T -> list[T]
受约束的类型参数
如果函数只接受有限的几种类型,同时要求参数与返回值保持同一种类型,可以进一步限制类型参数:
] -> :
return +
这里的重点不是 str | bytes,而是三个位置必须由同一个 T 解释。
因此:
# str
# bytes
而混用两种类型应当在静态检查阶段被拒绝。
overload
并不是所有对应关系都适合用一个类型变量表示。
当返回类型取决于某个离散的参数组合时,可以使用 overload:
...
...
return
return
overload 描述的是若干合法调用形式,真正的运行时实现仍然只有最后一个函数。
在能够使用类型参数完整表达关系时,不需要为了“更严格”而堆叠 overload。只有关系无法由一个泛型签名准确表示时,再使用重载。
3. Callable 与参数转发
函数本身也有类型。
下面的注解只说明 callback 可以被调用并返回整数:
:
... 放弃了对参数列表的描述。对于真正接受任意调用形式的接口,这没有问题;但如果一个高阶函数只是把原函数的参数继续向下传递,就没有必要丢失这些信息。
Python 的 ParamSpec 可以保留完整调用签名:
return
return
如果输入函数是:
return +
经过 traced 后,它的静态签名仍然是:
(int, int) -> int
而不是退化为:
(...) -> Any
Python 3.14 的类型参数语法同样可以直接声明带 ParamSpec 的类型别名:
=
动态参数
*args 与 **kwargs 本身没有问题。装饰器、代理和参数转发天然需要它们。
但如果一个普通接口可以通过明确的形参表达,就不应因为省事而改成动态解包:
# 不推荐
...
# 更明确
...
动态接口应该源于接口本身的需求,而不是为了少写几个参数。
4. 优先使用结构类型
Python 本身大量依赖鸭子类型。静态类型设计没有必要破坏这一点。
如果一段代码只关心对象是否具有某些行为,应优先使用 Protocol:
...
return
任何具有兼容 read 方法的对象都可以传给 consume,无需继承 Reader。
这与 Python 原本的接口习惯一致:调用方依赖行为,而不是要求对象声明自己属于某个继承体系。
什么时候使用 ABC
Protocol 并不取代 ABC。
如果某个组件体系本身具有明确的名义关系,需要所有实现显式继承同一个基类,或者基类还承担共享实现、生命周期管理等职责,那么 ABC 更合适。
例如,一个框架内部定义的存储驱动:
...
...
这里的继承关系本身属于框架设计的一部分。
简单地说:
只需要“具备这些行为” -> Protocol
需要“属于这个抽象体系” -> ABC
默认使用结构类型,在确实需要名义约束时再引入继承。
5. 使用已有的类型语义
类型系统中已经存在用于表达常见语义的专门构造。能够准确表达时,不需要重新用 TypeVar 或宽泛的类型拼装一遍。
Self
对于返回当前类型的方法,尤其是 alternative constructor,应使用 Self:
=
return
这样,子类调用 from_string 时,返回类型仍然能够保持为对应的子类。
Self 的前提是方法确实保证返回当前类或其子类的实例。如果实现固定返回某个具体基类,则不应使用 Self。
ClassVar
类变量应明确标记为 ClassVar:
: = 5.0
=
default_timeout 属于类,timeout 属于实例。两者在运行时都可以通过属性访问,但在类型语义上并不是同一类状态。
6. 固定结构的数据不要使用裸字典
字典适合表示映射关系:
:
这里的键由运行时数据决定,dict 就是正确的抽象。
另一类数据虽然也可以写成字典,但字段实际上是固定的:
=
如果这种对象需要跨越函数或模块边界,继续使用未声明结构的 dict 会丢失大量静态信息。字段名拼错、字段缺失或值类型错误,只能更晚发现。
这种数据应当使用具有明确字段定义的结构。
dataclass
普通结构化数据优先考虑 dataclass:
:
:
: = True
它保留普通 Python 对象的使用方式,同时明确字段和类型。对象形状固定时,可以使用 slots=True 避免为每个实例保留独立的 __dict__。
如果对象具有少量与数据紧密相关的行为,也可以直接定义在数据类中,没有必要为了“纯数据”再额外建立一层对象。
TypedDict
如果数据在运行时本来就应该保持字典形态,可以使用 TypedDict:
:
:
:
: =
TypedDict 在运行时仍然只是普通 dict。它增加的是编码阶段的字段约束,而不是运行时校验。
这正符合本文对类型系统的使用方式:让结构在静态阶段清晰,而不强迫运行时对象承担额外机制。
NamedTuple
只有数据本身具有 tuple 语义时,才使用 NamedTuple。
例如坐标、固定位置的轻量记录等:
:
:
如果代码主要通过字段名称访问数据,并且不存在 tuple 语义,通常 dataclass 更自然。
运行时数据模型
Pydantic 等模型解决的是另一类问题:运行时解析与验证。
例如 HTTP 请求、配置文件、消息队列内容等数据来自程序控制范围之外,静态检查无法证明这些值符合本地类型声明。这时才需要运行时验证。
因此,可以大致按照下面的方式选择:
普通结构化内部数据 dataclass
需要保留 dict 形态 TypedDict
本身具有 tuple 语义 NamedTuple
外部输入需要解析或验证 Pydantic 等运行时模型
真正的动态键值映射 dict[K, V]
选择的依据是数据语义,而不是哪种工具提供的功能最多。
7. 静态类型不是运行时类型系统
Python 的类型注解首先服务于静态分析。
MyPy、Pylance、IDE 和代码审查可以利用这些信息检查代码,但普通类型注解不会自动阻止下面的调用:
return +
类型检查器会报告错误,但 Python 解释器不会因为注解本身拒绝执行这个函数。
这不是类型系统的缺陷,而是 Python 的运行模型。
因此,没有必要为了“落实类型”而在每个函数入口重复进行运行时检查:
return +
对于项目内部已经受静态检查约束的代码,这类检查通常只是重复工作。
运行时验证应该放在真正需要它的边界上:
网络请求
配置文件
数据库中的非可信数据
用户输入
第三方消息
动态插件边界
数据通过边界进入受控代码后,内部逻辑继续依靠明确的类型声明和静态检查。
这种划分保留了 Python 的动态运行时,也使类型错误尽可能提前到编码阶段暴露。
结语
静态类型的价值不在于为 Python 增加一套运行时限制,而在于把原本只存在于开发者脑中的约定写进代码。
类型参数表达类型之间的关系,ParamSpec 保留调用签名,Protocol 描述行为,Self 和 ClassVar 表达已有的语言语义,dataclass 与 TypedDict 则让数据结构保持明确。
类型声明应当尽可能准确,但不应该为了追求形式上的完整而破坏 Python 原有的简单性。
能够在静态阶段说明清楚的事情,就在静态阶段说明清楚;确实属于运行时的问题,再交给运行时处理。
[end]