Ruff 类型检查规则解析:invalid-generic-class —— 无效泛型类定义的静态检测与源码实现
导读
invalid-generic-class 是 Ruff 内置静态类型检查器 ty(位于仓库 crates/ty 与 crates/ty_python_semantic)提供的一组类型诊断规则,用于在**编译期(而非运行期)**发现"泛型类定义不合规、运行时会抛出 TypeError"的代码。本文以该规则的官方文档 invalid-generic-class.md 为骨架,完整还原其核心语义(PEP 695 语法与旧式 Generic 语法混用、带默认值的类型参数必须全部排在无默认值参数之后等),并结合 diagnostic.rs、static_class.rs 等源码,拆解该规则在 ty 检查器内部的触发路径。读完本文,你将能够:判断哪些泛型类写法会被本规则拦截并准确理解原因;同时掌握在仓库中用 mdtest 测试夹具复现与验证这些诊断的方法。
规则定位:在类型检查阶段兜住运行期 TypeError
在 Python 中定义泛型类并非"随便写写即可"。类型系统对泛型类的声明方式有一整套必须遵守的约束,违反其中任何一条,都可能在类创建或实例化阶段由运行时直接抛出 TypeError。本规则的职责,就是把这些"会炸"的写法在静态检查阶段就拦截下来。
在源码中,该规则以如下形式声明(见 diagnostic.rs):
declare_lint! {
#[doc = include_str!("../../resources/lint_docs/invalid-generic-class.md")]
pub(crate) static INVALID_GENERIC_CLASS = {
summary: "detects invalid generic classes",
status: LintStatus::stable("0.0.1-alpha.1"),
default_level: Level::Error,
}
}
值得注意的细节:
- 规则文档正文正是通过
include_str!从resources/lint_docs/invalid-generic-class.md直接嵌入,说明本文作为规则说明与该 lint 的实现严格一一对应,属于 ty 诊断体系的"一等公民"。 default_level: Level::Error表明其默认严重级别为 Error:一旦命中,会被当作类型错误处理,而不是可忽略的提示。- 从代码中的调用点(
context.report_lint(&INVALID_GENERIC_CLASS, ...))可以看出,它并不是单条检查,而是一个"规则家族",覆盖从类声明、继承列表到泛型下标(subscript)的多种边界场景(详见下文第 4 节)。
规则规范层面,typing 官方规范(typing spec)中的 Generics 章节对本规则覆盖的"泛型类定义要求"给出了权威定义,这也是该文档在 References 一节中列出的依据来源。
一图看懂两类核心违规场景
在介绍源码前,先完整呈现规则文档中的两个核心示例。它们分别命中本规则两大"主战场"。
[environment]
python-version = "3.12"
from typing_extensions import Generic, TypeVar
T = TypeVar("T")
U = TypeVar("U", default=int)
# 场景一:类同时使用了 PEP-695 语法与旧式语法
class CU: ... # error
# 场景二:带默认值的类型参数排在无默认值类型参数之前
class D(Generic[U, T]): ... # error
配置要点说明:
- 示例环境声明
python-version = "3.12":PEP 695 内置类型参数语法class C[U]需要 Python 3.12+; - 示例从
typing_extensions导入而非typing,是因为TypeVar的default=参数要到 Python 3.13 才进入标准库typing,在 3.12 下必须借助typing_extensions才能写出该特性——这也让"默认值排序"这类校验可以在更早的 Python 版本上被统一测试。
下面分别拆解两个场景的"为什么错"以及 ty 内部如何识别它们。
场景一:PEP 695 语法与旧式 Generic 语法混用
class CU: ... # error
为什么这是错的
class CU 是 PEP 695(Python 3.12+)语法,类型参数写在类名后的方括号中;而 Generic[T] 是 旧式(legacy) 泛型语法,类型参数通过继承 typing.Generic 下标来表达。规范禁止将两套机制混合到同一个类上:
- 若类声明了 PEP 695 类型参数,它就不能再从旧式的
Generic[...]继承; - 反之,若想用旧式
Generic[T],就必须放弃类级方括号语法。
一旦违反,运行时在构建类时就会抛出 TypeError——这也是本文开头"运行时才会炸"说法的第一处典型体现。
ty 内部如何识别
ty 在类检查阶段会尝试构建方法解析顺序(MRO)。当 MRO 解析返回 StaticMroErrorKind::Pep695ClassWithGenericInheritance 错误种类时,代码将诊断上报给本规则(见 static_class.rs):
StaticMroErrorKind::Pep695ClassWithGenericInheritance => {
if let Some(builder) = context.report_lint(&INVALID_GENERIC_CLASS, class_node) {
builder.into_diagnostic(
"Cannot both inherit from `typing.Generic` \
and use PEP 695 type variables",
);
}
}
也就是说,ty 把"旧式 Generic 基类"与"PEP 695 类型参数"的共存视为一个 MRO 层面的结构性错误,诊断文案为:"Cannot both inherit from typing.Generic and use PEP 695 type variables"(不能同时继承 typing.Generic 又使用 PEP 695 类型变量)。
与此同族的另一条独立检查(仍在 static_class.rs)负责拦截更隐蔽的变体——类本身是 PEP 695 泛型,但其旧式基类中夹带了 legacy TypeVar:
if class.has_pep_695_type_params(db)
&& let Some(generic_context) = class.inherited_legacy_generic_context(db)
&& let Some(typevar) = generic_context
.variables(db)
.find(|typevar| !typevar.typevar(db).is_self(db))
&& let Some(builder) = context.report_lint(&INVALID_GENERIC_CLASS, class_node)
{
builder.into_diagnostic(format_args!(
"Legacy type variable `{}` cannot be used in a PEP 695 class base",
typevar.name(db),
));
}
对应诊断信息为:"Legacy type variable {} cannot be used in a PEP 695 class base"(旧式类型变量 X 不能用在 PEP 695 类基类中),从"基类带入的类型变量"维度封堵了 PEP 695 与 legacy 的类型参数体系互相渗透。
场景二:带默认值的类型参数排在无默认值参数之前
class D(Generic[U, T]): ... # error
其中 U = TypeVar("U", default=int) 带默认值,而 T = TypeVar("T") 不带默认值。
为什么这是错的
泛型下标的特化依赖位置参数:D[int] 会把第一个类型参数绑定为 int。类型参数默认值的语义是"特化时可以省略尾部的若干类型实参"——因此规范要求:
一旦出现带默认值的类型参数,其后不得再出现不带默认值的类型参数。
示例中 Generic[U, T] 令带默认值的 U 排在了不带默认值的 T 之前,此时若省略末尾实参(等价于试图只特化 U),T 将无值可取;运行时会因这种歧义抛出 TypeError。
ty 内部如何识别
这一场景由 static_class.rs 中的顺序扫描逻辑检出:对 legacy 泛型上下文中的每个类型参数,一旦记录到某个带默认值的参数(typevar.default_type(db, env).is_some()),后续任何不带默认值的参数都会被收集为违规者,最后统一交给 report_invalid_type_param_order 上报。
诊断本身是一段带多处标注(annotation)的富文本,核心文案在 diagnostic.rs:
- 主诊断:"Type parameters without defaults cannot follow type parameters with defaults"(无默认值的类型参数不能跟在有默认值的类型参数之后);
- 精简消息:
Type parameter \T` without a default cannot follow earlier parameter `U` with a default`——精确到具体是哪两个参数犯了顺序错误; - 主标注:如果只有一个违规参数,则标注
Type variable \T` does not have a default,多个则枚举Type variables {names} do not have defaults`; - 反向标注:在"带默认值、位置靠前"的参数处标注
Earlier TypeVar \U` does`; - 次级标注:在被涉及的两个
TypeVar各自的定义处(T与U的赋值行)追加\U` defined here/`T` defined here`,帮助用户直接跳回定义点理解"默认值归属"。
这段"多点标注 + 定位到定义处"的产出形态,让读者能像阅读编译器错误报告一样快速定位问题根因。
同一规则覆盖的其它边界场景
正如前文所说,INVALID_GENERIC_CLASS 实际承载了多类"无效泛型类"检查。除文档两个主示例外,从源码可以归纳出以下同族边界场景(源码证据均来自当前仓库):
3.1 泛型下标(subscript)层面的约束
在 subscript.rs 中,对 Generic[...] / Protocol[...] 等泛型类的下标特化也统一上报本规则:
- 重复类型参数:
Type parameter \{name}` cannot appear multiple times in `{origin}` subscription——例如把同一个TypeVar` 在同一次特化里传入两次; TypeVarTuple未解包:TypeVarTuple must be unpacked with \*` or `Unpack[]` when used as an argument to `{origin}``;- 多个
TypeVarTuple:Only one \TypeVarTuple` parameter is allowed in a `{origin}` subscription`。
这些约束的共同点在于:它们在"类型被实际用作泛型实参"的瞬间检查合法性,而不是等到类定义完成之后。
3.2 冲突的泛型祖先特化
同一泛型祖先若从不同基类继承到不兼容的特化,类定义同样非法。见 diagnostic.rs 的 report_inconsistent_generic_bases 文档注释示例:
class Grandparent(Generic[T1, T2]): ...
class Parent(Grandparent[T1, T2]): ...
class BadChild(Parent[T1, T2], Grandparent[T2, T1]): ... # Error
BadChild 同时要求 Grandparent 的特化在两条继承路径上不一致,ty 会通过逐条遍历 MRO、为每个泛型祖先记录"首次见到的特化"来发现这类冲突并上报本规则。
3.3 Protocol 与 Generic 同时下标化
static_class.rs 还拦截"既继承下标化的 Protocol 又继承下标化的 Generic"的类,诊断信息为 "Cannot both inherit from subscripted Protocol and subscripted Generic"。当两处传入的类型参数完全相同时,规则还会给出修复建议("Remove the type parameters from the Protocol base")并附带一个将 Protocol[...] 的 [...] 下标部分直接删除的 unsafe fix。
3.4 泛型基类的覆盖范围不足
static_class.rs 中的一条检查要求:
Genericbase class must include all type variables used in other base classes
即:当类既继承 Generic[...] 又继承其它用到了 TypeVar 的泛型基类时,Generic 的实参列表必须覆盖其它基类用到的全部类型变量,否则类无法统一约束这些变量。
3.5 PEP 695 泛型参数的顺序约束(与场景二同构的"现代语法"版)
对 PEP 695 语法的类(class C[T, *Ts, U=...]),static_class.rs 会复用 type_param_validation 模块执行额外检查:
check_single_typevar_tuple_pep695:PEP 695 泛型类中至多允许一个TypeVarTuple;check_no_default_after_typevar_tuple_pep695:带默认值的参数不能排在TypeVarTuple之后(因为TypeVarTuple会吞掉其后所有剩余位置实参,默认值将永远无法命中)。
3.6 默认值引用顺序
report_invalid_typevar_default_reference 负责检查默认值本身的合法性:
- 若某类型参数的默认值引用了更靠后的参数,报 "Default of
Xcannot reference later type parameterY"; - 若引用了列表之外的变量,报 "Default of
Xcannot reference out-of-scope type variableY"。
这与函数参数默认值的"只能引用此前已定义名称"规则遥相呼应,只不过作用域是类型参数列表。
3.7 从类延伸到函数
值得注意的是,本规则并不局限于类:function.rs 中对泛型函数的后推断校验同样会启用 INVALID_GENERIC_CLASS 并调用 report_lint 上报(如其中对函数 PEP 695/legacy 混用等情况的处理)。也就是说,读作"invalid generic class"的规则,其实现语义更准确地说应是"无效的泛型声明",覆盖类与函数两条路径。
触发时机:post-inference 校验阶段
从代码路径可以还原本规则的触发位置。ty 的类型推断(inference)完成后,会对类定义语句执行后推断校验(post-inference validation),集中入口在 static_class.rs,其执行顺序大致为:
- 逐基类检查特殊类型(
NamedTuple、Protocol、Generic等)的使用是否合法(对应上文 3.3、文档场景一); - 尝试解析 MRO,处理
DuplicateBases、InvalidBases、Pep695ClassWithGenericInheritance(场景一在此抛出)、InheritanceCycle等错误种类; - 对泛型上下文做整体校验:检查 legacy 类型变量混入 PEP 695 基类(3.2 变体)、
Generic覆盖范围、默认值排序、默认值引用顺序、PEP 695 的TypeVarTuple顺序等(场景二在此抛出); - 在类型被下标特化时(subscript.rs),执行重复参数、
TypeVarTuple解包等即时检查。
正因为这些检查跑在类创建之前的类型推断管道中,使用者不必等到 TypeError 真正抛出,就能在编辑器/CLI 中收到精确到"哪一个 TypeVar、定义在哪一行"的 Error 级诊断。
在仓库中验证与进一步阅读
若想亲手观察这些诊断的实际输出,仓库提供了两条路径:
1. 规则的官方文档(本文依据)
本规则说明文档即 invalid-generic-class.md,与其相邻的 ruff.toml 及同目录下数十个 invalid-*.md 文档共同构成 ty 的 lint 文档集,并被 declare_lint! 通过 include_str! 编译进规则注册表。
2. mdtest 语言级测试夹具
ty 使用 Markdown 驱动的测试体系(mdtest)来断言诊断行为,其中与本规则直接相关的夹具包括:
- generics/pep695/classes.md:PEP 695 语法下泛型类的合法/非法用例;
- generics/legacy/classes.md:旧式
Generic语法的对应用例; - diagnostics/invalid_type_parameter_order.md:类型参数顺序类错误的专项夹具。
这些夹具中的预期诊断与快照(snapshots 目录下 *.snap 文件)一一对应,是理解每条错误文案精确形态的最佳"参考答案"。规则的完整清单可进一步查阅 crates/ty/docs/rules.md 规则参考页。
小结
invalid-generic-class 把 Python 泛型类"运行期才暴露"的一整类 TypeError 前置到了静态检查阶段:两套泛型语法不能混用、带默认值的类型参数必须全部排后、TypeVarTuple 与默认值的相互制约、泛型祖先特化必须一致、泛型基类必须覆盖全部类型变量……每一条都对应着 ty_python_semantic 中精确到具体类型参数的诊断与源码级实现。理解这一规则家族,既是写出健壮泛型代码的前提,也是读懂 ty 类型检查器"类校验"设计的一把钥匙。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00