Ruff `ty` 类型检查器 `cyclic-class-definition` 检查详解:为什么类不能(直接或间接)继承自身
循环继承(cyclic inheritance / cyclic class definition)是 Python 类型系统中一种必须被静态拒绝的定义形态:即便在支持前向引用的 .pyi 桩文件里,一个类也绝不能直接或间接地继承自身。本文将围绕 ruff 仓库中 ty 类型检查引擎的诊断文档 cyclic-class-definition.md,完整说明该检查做什么、为什么必须做、它如何在仓库源码中被实现(含 class 语句与 type(...) 动态建类两条路径),以及它与 MRO、metaclass 等相关诊断的协作关系。读完你不仅能理解该诊断的判定标准,还能掌握在仓库源码中定位其声明、触发点与算法实现的方法。
该检查要解决什么问题
cyclic-class-definition 检查的是桩文件(stub file)中那些(直接或间接)继承自自身的类定义。其唯一目标是发现类继承关系中的环:
# foo.pyi
class A(B): ... # error
class B(A): ... # error
在上面的 foo.pyi 中,A 以 B 为基类,而 B 又以 A 为基类,两者互为基类。由于基类表达式里的前向引用在桩文件中天然被支持,两个类「先声明后使用」本身没有问题;问题在于二者形成了一个继承环,任何一条继承链都无法被收束到 object。
为什么循环继承是错误的
文档给出的核心理由非常明确:
虽然前向引用(forward references)在桩文件中被原生支持,继承环仍然是不被允许的——因为对于一个继承自身的类,无法解析出一致的 MRO(方法解析顺序,method resolution order)。
这背后的含义可以拆成两层:
- 前向引用合法 ≠ 继承环合法。桩文件允许类名在定义完成之前被引用(例如作为另一个类的基类),这是为了让
.pyi中的类型声明可以按任意顺序书写。但这只是把「解析」推迟到类型检查阶段,并不等于允许出现无解的继承结构。 - MRO 必须唯一且一致。Python 计算 MRO 的 C3 线性化算法要求继承图是一个有向无环图;一旦存在环,就无法为「该类的属性查找顺序」找到一个自洽的、有限的线性序列。因而循环继承在语义上必然失败。在普通
.py文件中,这种定义会在类创建时直接抛出TypeError(无法构建一致的 MRO);在.pyi桩文件中虽不会运行,但类型检查器仍必须将其标记为错误,否则它推导出的任何类层级关系都是自相矛盾的。
从仓库源码看,类型推导引擎之所以要稳健地识别这种环,还有一层自我保护的原因:相关源码注释明确指出「这类类定义会在运行时失败,但我们必须对它保持健壮,否则可能 panic」,也就是说环检测同时也是保证推导查询不会死循环、能够收敛的必要防线(见 static_literal.rs)。
合法与不合法的形态对比
为了准确判断「什么会被报告」,我们可以把循环继承拆成几种形态:
直接循环(报错): 类把自身写在基类列表中。
class A(A): ...
间接循环 / 双向循环(报错): 两个或多个类互为祖先,示例同文档中的 foo.pyi。
更长的环(报错): 环可以跨越任意多个类。
class A(B): ...
class B(C): ...
class C(A): ... # A -> B -> C -> A,构成闭合环
仅「受牵连」而不参与环(不因此诊断报错): 如果 X 本身不在环上,只是继承了某个处于环中的类,那么 X 的继承链同样无法求解 MRO,但源码将这种形态区分为 Inherited,并不会对 X 报告本诊断(详见下文算法小节)。可见该诊断精确聚焦于「环的参与者」本身。
源码级解析:该诊断如何被声明与触发
1. 诊断的注册与元信息
该诊断在 ty 类型检查引擎的诊断定义文件中声明并注册。注册入口在 diagnostic.rs(registry.register_lint(&CYCLIC_CLASS_DEFINITION);),而声明本身在 diagnostic.rs:
declare_lint! {
#[doc = include_str!("../../resources/lint_docs/cyclic-class-definition.md")]
pub(crate) static CYCLIC_CLASS_DEFINITION = {
summary: "detects cyclic class definitions",
status: LintStatus::stable("0.0.1-alpha.1"),
default_level: Level::Error,
}
}
从中可以确认几个关键事实:
- 该 lint 的用户文档正是通过
include_str!直接内联本篇文章所依据的文档 cyclic-class-definition.md——也就是说,这类lint_docs/*.md是面向用户的规则说明,而非仅供 Rustdoc 渲染的内部注释(见 diagnostic.rs 的文件头注释); - 其默认报告级别为
Error(与文档中示例标注# error一致),以稳定状态声明; - 它和兄弟诊断
CYCLIC_TYPE_ALIAS_DEFINITION(检测循环类型别名定义,对应 cyclic-type-alias-definition.md)同批注册,二者共同覆盖「类型定义层面不允许出现环」这一约束。
2. class 语句路径:静态类定义的后置检查
对于常规 class 语句产生的类,检查发生在后置推断阶段。函数 check_static_class_definitions 的职责是:在所有相关类型基本推断完成后,逐个检查静态类定义在语义上是否合法、是否会在运行时抛异常。之所以必须放在后置阶段,正如其文档注释所述:基类可以被延迟解析(base classes can be deferred),只有等到大多数类型就绪后才能可靠判定。
该函数中与本诊断直接相关的逻辑如下(static_class.rs):
// Check that the class does not have a cyclic definition
if let Some(inheritance_cycle) = class.inheritance_cycle(context.db()) {
if inheritance_cycle.is_participant()
&& let Some(builder) = context.report_lint(&CYCLIC_CLASS_DEFINITION, class_node)
{
builder.into_diagnostic(format_args!(
"Cyclic definition of `{}` (class cannot inherit from itself)",
class.name(db)
));
}
// If a class is cyclically defined, that's a sufficient error to report; the
// following checks (which are all inheritance-based) aren't even relevant.
return;
}
这段代码揭示了三个重要设计:
- 只对「环的参与者」报告:
inheritance_cycle.is_participant()为真时才产生诊断,诊断消息为Cyclic definition of<类名>(class cannot inherit from itself);报告点(range)落在class_node上。 - 短路语义:一旦判定类处于循环继承中,后续所有基于继承关系的检查(插槽冲突、
NamedTuple字段顺序、泛型基类一致性、metaclass 推导等)全部跳过——循环已经使整条继承链不可信,再检查派生问题没有意义。 - 该函数还要兼顾运行时会抛异常的合法性检查(MRO 与 metaclass),说明「类定义合法性」是一个整体后置校验管道,循环继承只是其中最根本的一类错误。
3. 环检测算法:如何区分参与者和受牵连者
环检测的核心实现是 inheritance_cycle:
/// Return this class' involvement in an inheritance cycle, if any.
pub(crate) fn inheritance_cycle(self, db: &'db dyn Db) -> Option<InheritanceCycle> {
if !self.has_explicit_bases(db) {
return None;
}
// ...
}
其内部采用一棵带记忆化的 salsa 跟踪查询 inheritance_cycle_inner(cycle_initial 返回 None,从而保证即使推导自身陷入环查询也能以 None 收敛),并在其中递归遍历:
- 对每个显式基类(
explicit_bases)取出对应的静态类字面量(普通类直接取ClassLiteral,泛型类则经GenericAlias取origin); - 维护两条路径记录:
classes_on_stack(当前递归栈)与visited_classes(已访问的全部基类集合); - 当试图把一个已在栈上的类再次压栈时,说明探测到了环;随后回溯确认起点类是否也在访问集合中;
- 最终分类返回:
Some(InheritanceCycle::Participant)——类自身在环上,对应cyclic-class-definition的诊断触发;Some(InheritanceCycle::Inherited)——类不在环上,只是继承了环上的类(visited_classes包含起点但起点不在环闭合链上);None——没有环。
is_participant() 这一细粒度区分,正是「只有 class A(B) / class B(A) 这种环成员被标记 # error」而环外子类不被本诊断报告的实现依据。
4. 环检测结果如何汇入 MRO 与 metaclass 推导
环的存在会在类定义的整体合法性检查中从多个角度浮出水面,最终都收敛到同一个 CYCLIC_CLASS_DEFINITION 诊断:
- MRO 求解返回循环错误:在 static_class.rs 中,
StaticMroErrorKind::InheritanceCycle分支会报告Cyclic definition of<类名>(class cannot inherit from itself); - metaclass 求解因循环失败:在 static_class.rs 中,
MetaclassErrorKind::Cycle分支同样报告Cyclic definition of<类名>``; - 动态建类同样被覆盖:对于
type(name, bases, namespace)形式动态创建的类,其 MRO 计算若遇到DynamicMroErrorKind::InheritanceCycle,会在调用表达式上报告Cyclic definition of<类名>``(见 dynamic_class.rs)。相关源码注释也说明:动态类的合法性在调用表达式类型推断期间单独检查,与class语句的后置检查路径(post-inference)互补。
值得留意的是,MRO 的错误分类非常细致(InvalidBases、InheritanceCycle、DuplicateBases、UnresolvableMro 等分别路由到 INVALID_BASE、CYCLIC_CLASS_DEFINITION、DUPLICATE_BASE、INCONSISTENT_MRO 等不同诊断),这也解释了为何「继承环」「基类重复」「MRO 不可线性化」虽然是近亲问题,却会被拆成独立诊断分别报告——对照同目录下的 duplicate-base.md 与 inconsistent-mro.md 可以更清楚地看到这组诊断各自聚焦的边界。
实践要点小结
要正确地使用和规避这一检查,可以把结论压缩为四条规则:
- 在
.pyi桩文件中,前向引用可以随便写,但基类链条绝不能成环——直接自继承(class A(A))、双向继承(class A(B)与class B(A))以及任何多类闭合环都会被报告为Error级别诊断。 - 环的判定是传递闭包式的:只要从某类的显式基类出发能走回该类自身,就会命中;中间隔多少层类并不影响判定。
- 一旦命中,后续的继承类检查会被短路:循环已导致该类的 MRO/metaclass 全部不可信,优先修复继承结构本身,其余告警自然会消失。
- 这一约束同时覆盖静态与动态类定义:
class语句由后置推断阶段检查,type(...)动态建类由调用推断阶段检查,两条路径的最终报告都汇聚到同一诊断。
如果你希望进一步了解该诊断在类型系统中的位置,可以从 ty_python_semantic 的 lint 文档目录 出发——它集中存放了 ty 类型检查引擎全部规则的说明(同一目录下还有与继承/MRO 相关的 duplicate-base.md、inconsistent-mro.md、invalid-base.md、cyclic-type-alias-definition.md 等),再对照 diagnostic.rs 中每个 declare_lint! 的 include_str! 与 summary,即可系统梳理由「继承环」扩展出的整组类定义合法性检查。
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