Ruff 之 ty 类型检查器的循环类型别名检测:cyclic-type-alias-definition 规则全解析
Ruff 之 ty 类型检查器的循环类型别名检测:cyclic-type-alias-definition 规则全解析
本篇文章围绕 Ruff 仓库中内置类型检查器(ty)的 lint 规则 cyclic-type-alias-definition 展开,结合其官方规则文档与源码实现,讲清楚三条主线:什么是循环类型别名、为什么它是错误、递归引用在何种前提下是合法的。读完你既能写出规范可用的递归别名(如 Tree = int | list[Tree]),也能理解类型检查器底层如何用图算法保证循环检测不卡死、不误报。
规则是什么:检测"直接"循环的类型别名
cyclic-type-alias-definition 是一条默认开启、严重级别为 Error 的类型检查 lint 规则,它的职责非常聚焦:检查存在循环展开的类型别名(type alias)定义,即一个别名直接或间接地展开回自己,且该循环不受任何类型构造器的"保护"。
该规则在源码中以 declare_lint! 宏注册,规则元数据位于 types/diagnostic.rs:
declare_lint! {
#[doc = include_str!("../../resources/lint_docs/cyclic-type-alias-definition.md")]
pub(crate) static CYCLIC_TYPE_ALIAS_DEFINITION = {
summary: "detects cyclic type alias definitions",
status: LintStatus::stable("0.0.1-alpha.29"),
default_level: Level::Error,
}
}
值得注意的两点:
- 规则文档即代码注释:规则正文就是通过
include_str!嵌入的 cyclic-type-alias-definition.md,因此文档与实现永远同源同步,读者在 IDE 中悬停规则名即可看到下文讲解的全部语义。 - 默认即为 Error 级别:这意味着出现此类别名时,类型检查会直接报错,而不是告警或建议。
为什么这是错误:合法递归与非法循环的边界
Python 的注解系统天然支持前向引用(forward reference),类型别名在结构上出现自我引用也并非一概非法。判定标准取决于"递归引用发生的位置":
当递归引用出现在另一个类型(容器/构造器)内部时,别名是合法的,例如
list[Tree]; 当别名直接展开为自己、或把自己作为 union 的成员时,是非法的。
这个边界来自一个朴素的集合论事实:Tree = int | list[Tree] 描述的是一棵有限的"树"(树的结点要么是 int,要么是装着若干子树的 list),每次递归都会经由 list[...] 增加一层结构,类型仍然良基、可判定;而 Alias = Alias 或 Alias = int | Alias 会让别名直接无限内联自身,类型无法被有限表示,也无法被一致地推理。
在实现中,这个边界被表达为"递归是否被容器保护":类型推断会尝试展开别名值(expand_eagerly),若展开结果是发散的(divergent)或存在无保护的别名循环(has_unguarded_alias_cycle),则判定为循环定义,见 builder.rs:
/// Check both alias syntaxes, including union members that disappear during expansion.
fn check_type_alias_cycle(&mut self, name: &str, value: &ast::Expr, node: impl Ranged) {
let db = self.db();
let value_ty = self.expression_type(value);
let expanded = value_ty.expand_eagerly(db, self.program_environment());
if (expanded.is_divergent() || value_ty.has_unguarded_alias_cycle(db))
&& let Some(builder) = self
.context
.report_lint(&CYCLIC_TYPE_ALIAS_DEFINITION, node)
{
builder.into_diagnostic(format_args!("Cyclic definition of `{name}`"));
}
if expanded.is_divergent() {
// Preserve the dynamic recovery type for aliases that cannot be expanded at all.
// Union cycles retain their non-recursive members for recovery.
self.expressions.insert(value.into(), expanded);
}
}
顺带一提,该规则适用于两种别名语法:
- PEP 695 的
type语句(如type A = B); - 通过
typing.TypeAliasType在运行时创建的别名对象(如Cycle = TypeAliasType("Cycle", "Cycle"))。
二者最终都汇入同一套"别名值→展开→循环判定"的类型推断机制,因此行为一致。同理可对比仓库中另一条邻近规则 cyclic-class-definition(见 cyclic-class-definition.md):它管的是 stub 文件里类继承自自身(导致无法求解 MRO);而本规则管的是别名的展开循环,两者一"类"一"别名",不可混淆。
触发条件与完整示例
原文示例需要 PEP 695 语法,因此检查环境需为 Python 3.12+,对应的项目环境声明如下:
[environment]
python-version = "3.12"
以下是完整的判例集合,可直接放入任意 Python 3.12 文件验证:
from typing import TypeAliasType
type Itself = Itself # error: 直接自我展开
type A = B # error: 间接循环(互相引用)
type B = A # error
type IntOr = int | IntOr # error: 自己成为 union 成员,无限并集
Cycle = TypeAliasType("Cycle", "Cycle") # error: 运行时创建的别名同样被拦截
type Tree = int | list[Tree] # valid: 递归发生在 list[...] 容器内部
逐条拆解:
| 代码 | 结果 | 原因 |
|---|---|---|
type Itself = Itself |
error | 别名值就是自身,展开永不终止 |
type A = B; type B = A |
error | 互相引用构成环,即便跨多个定义也逃不过检测 |
| `type IntOr = int | IntOr` | error |
Cycle = TypeAliasType("Cycle", "Cycle") |
error | 别名语法换成运行时 TypeAliasType 同样被拦截 |
| `type Tree = int | list[Tree]` | valid |
从"互相引用"这条可以推断:循环检测不是局部的、单定义的检查,而是会跨定义追踪引用图,A → B → A 这样的间接环同样逃不掉。
源码级的循环终止保障:递归类型图与"增长检测"
既然合法的递归别名(如 Tree)在类型推理中会被反复展开,检查器就必须回答一个更底层的问题:在遍历递归类型时如何保证自己不会死循环?这正是 cyclic.rs 这个模块的职责——它为类型系统提供通用的环检测基础设施(CycleDetector、TypeIdentity、TypeTransformer),让一切递归遍历在遇到重复身份(identity)时及时收敛并返回保守结果。
其中与别名最相关的是别名类型的循环身份(recursive identity)。对于"特化会不断变化"的递归别名,例如 type Growing[T] = T | Growing[list[T]],其每次展开的类型实参都在变化(list[T]、list[list[T]]…),不会有精确重复的类型可用来终止遍历。因此源码为这类别名定义了 GrowingTypeAlias 身份,将"增长中的别名"直接折叠到其定义本身,使遍历能够停下(见 cyclic.rs 的注释):
# 特化无限增长的别名:折叠到定义层才能让访问停止
type Growing[T] = T | Growing[list[T]]
# 特化会精确复现的别名(tuple 保护):无需定义层身份,
# 检测器会在重复类型本身处停下
type RecursiveT = int | tuple[RecursiveT, ...]
更进一步,模块内部用一张**参数流向图(parameter flow graph)**来建模"一个别名的特化是否可能无界增长",这是决定需要哪种终止策略的关键判据。图中的边分为两类(见 cyclic.rs):
Direct:源参数被原样传递,或只经过归一化的集合运算;Nested:源参数出现在会"累积"的结构内部。
只有当某条经过根参数的有向环包含至少一条 Nested 边时,特化才会无限增长(模块文档中的四个示例言简意赅,见 cyclic.rs):
type Growing[X, Y] = Growing[list[Y], X] # Y -> X nested, X -> Y direct: 增长环
type Shifting[A, B, C] = Shifting[B, C, None] # 全 direct:三步后精确重复
type Resetting[X, Y] = Resetting[list[Y], None] # Y -> X nested,但 X 无处可流:无环
type GrowingOuter[T] = GrowingHelper[list[T]] # 增长环可跨多个定义
type GrowingHelper[U] = GrowingOuter[U]
实现上通过 may_have_unbounded_specialization(cyclic.rs,salsa 惰性求值并缓存)判断某个定义是否存在此类增长,再以强连通分量(SCC)分析对参数做环归属判定;TypeAliasType::is_recursive 亦复用此图来判断别名能否指回自身定义(cyclic.rs)。这套"精确重复直接停、增长特化折叠定义身份停、保守结果兜底"的分层策略,保证了即使是合法的递归别名,类型推理依然能终止并给出有意义的类型。
错误恢复:报错之后类型仍然可用
源码值得称道的一点是:报告循环别名错误不等于放弃该类型。在 check_type_alias_cycle 的结尾,若展开是发散的,检查器会把发散展开结果写入表达式表以保留动态恢复类型;而对"union 成员形式的循环",会保留其中非递归的那些成员用于后续恢复推断。
这一点在仓库的 markdown 测试(mdtest)中有非常直观的验证,例如 call/type.md:
from ty_extensions import Intersection
type First = Second | int # error: [cyclic-type-alias-definition]
type Second = First | str # error: [cyclic-type-alias-definition]
def recursive(value: Intersection[First, Second]):
reveal_type(type(value)) # revealed: type[int | str]
两个互相递归的别名各自报错,但当代码拿到 First | Second 的值并询问其类对象时,检查器仍能保留所有非递归成员(int 与 str),揭示出 type[int | str],而不是彻底退化为 Unknown。
同文件还覆盖了更刁钻的场景:特化有限轮换的循环与特化持续增长的循环。前者如
type Rotate[T, U] = T | Rotate[U, T] # error: [cyclic-type-alias-definition]
def rotating(value: Rotate[int, str]):
reveal_type(type(value)) # revealed: type[int | str](保留初始实参并集)
后者如
type Growing[T] = T | Growing[list[T]] # error: [cyclic-type-alias-definition]
def growing(value: Growing[int]):
reveal_type(type(value)) # revealed: type(实参持续增长,保守返回最宽类型)
可见错误恢复也是有策略的:当特化只会有限轮换时,恢复类型保留初始实参的并集;当特化会无限增长时,则保守地退化为最宽泛的 type——这与上文"参数流向图判定是否无界增长"的机制一一对应,说明循环检测与恢复策略在类型系统中是打通的整体设计。
如何在仓库中验证与学习
如果你希望亲自观察该规则的行为,仓库内已内置大量可执行素材,无需手写样例:
- 规则官方文档:lint_docs/cyclic-type-alias-definition.md,即本文依据的主文档;
- mdtest 语义测试:crates/ty_python_semantic/resources/mdtest/call/type.md 用
# error: [cyclic-type-alias-definition]标注错误位置,并用reveal_type断言恢复类型,是最佳的"可执行规格"; - 别名泛型测试:mdtest/generics/pep695/aliases.md 覆盖 PEP 695 别名体系的其余错误族(如重复类型参数、非法特化),可对照区分规则边界;
- 命名混淆语料:corpus/cyclic_type_alias.py 用脱敏后的名字验证循环别名在条件分支、match、作用域交错等复杂控制流下依旧不会让推断死循环或误报;
- 相邻规则:lint_docs/cyclic-class-definition.md 用于对比"类继承环"与"别名展开环"的差异。
小结
一句话概括本规则的心智模型:递归不可怕,无保护的直接递归才可怕。 把递归引用放进 list[...]、tuple[...] 等能逐层累积结构的容器里,别名就是良基、可推理的;让别名赤裸裸地指向自身或成为 union 的直接成员,就是 cyclic-type-alias-definition 要拦截的错误。而支撑这条看似简单的规则,仓库底层付出了相当精巧的设计——跨定义引用图追踪、参数流向图的 SCC 分析、Direct/Nested 增长边建模,以及"精确重复、定义身份折叠、保守兜底"三层的递归终止保障,最终换来的是对合法递归别名又快又稳的类型推理,以及报错后依然可用的恢复类型。