Ruff 之 ty 类型检查器的循环类型别名检测:cyclic-type-alias-definition 规则全解析

原创2026-09-08 17:25:531,830 阅读
文章标签:开发工具代码质量Lint格式化静态分析CLI

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,
    }
}

值得注意的两点:

  1. 规则文档即代码注释:规则正文就是通过 include_str! 嵌入的 cyclic-type-alias-definition.md,因此文档与实现永远同源同步,读者在 IDE 中悬停规则名即可看到下文讲解的全部语义。
  2. 默认即为 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——这与上文"参数流向图判定是否无界增长"的机制一一对应,说明循环检测与恢复策略在类型系统中是打通的整体设计。

如何在仓库中验证与学习

如果你希望亲自观察该规则的行为,仓库内已内置大量可执行素材,无需手写样例:

小结

一句话概括本规则的心智模型:递归不可怕,无保护的直接递归才可怕。 把递归引用放进 list[...]、tuple[...] 等能逐层累积结构的容器里,别名就是良基、可推理的;让别名赤裸裸地指向自身或成为 union 的直接成员,就是 cyclic-type-alias-definition 要拦截的错误。而支撑这条看似简单的规则,仓库底层付出了相当精巧的设计——跨定义引用图追踪、参数流向图的 SCC 分析、Direct/Nested 增长边建模,以及"精确重复、定义身份折叠、保守兜底"三层的递归终止保障,最终换来的是对合法递归别名又快又稳的类型推理,以及报错后依然可用的恢复类型。

登录后查看全文
ruff