Rust 编译器中 Trait 约束与 Projection(关联类型投影)约束分离设计解析
2026/9/13 1:20:32 网站建设 项目流程

Rust 编译器中 Trait 约束与 Projection(关联类型投影)约束分离设计解析

【免费下载链接】rustEmpowering everyone to build reliable and efficient software.项目地址: https://gitcode.com/GitHub_Trending/ru/rust

导读

在 Rust 类型系统中,一条形如T: Foo<AssocA = u32, AssocB = i32>的 where 约束,最终并不会被表示成一条"带关联类型赋值列表的 Trait 约束",而是被拆解为一条Trait(Foo<T>)约束加上若干条独立的Projection约束。本文以 rustc 开发者指南中的 separate-projection-bounds.md 为核心骨架,结合 rustc 源码中旧求解器(rustc_trait_selection)与新求解器(rustc_next_trait_solver)的实际实现,深入说明这一设计决策的原因、候选(candidate)优先级规则带来的影响、rigid alias 与 RPITIT 相关的已知缺陷,以及统一表示方案为何至今难以落地。读完本文,你将理解 rustc 归一化(normalization)与 trait 求解的底层机理,并能从源码层面解释许多看似"违反直觉"的类型错误。

背景:Trait约束与Projection约束的拆分

rustc 的类型系统将谓词(predicate)划分为多种ClauseKind,其中与本文相关的两类是:

  • Trait约束TraitClause/ClauseKind::Trait):表示T: Foo这样的 trait 实现要求;
  • Projection约束ProjectionClause/ClauseKind::Projection):表示<T as Foo>::AssocA这一投影项被归一化(normalize)为某个具体类型/常量,如<T as Foo>::AssocA = u32

当用户写出:

fn foo<T>() where T: Foo<AssocA = u32, AssocB = i32>, { }

rustc 在 lowering 阶段并不会保留Foo<AssocA = u32, AssocB = i32>这种"约束与赋值绑定在一起"的形式,而是将其拆成三条独立的谓词:

Trait(<T as Foo>) Projection(<T as Foo>::AssocA, u32) Projection(<T as Foo>::AssocB, i32)

这一拆分的直接动因来自求解器的架构:Projection约束的证明,直接依赖于对应Trait约束的证明。也就是说,想要归一化<T as Foo>::AssocA,首先必须确认T: Foo成立,随后才谈得上从某个"候选"(candidate)中取出关联类型的值。

这一依赖关系在两条求解器实现中都有明确的代码证据:

  • 旧求解器中,assemble_candidates_from_impls在解析<T as TraitRef<...>>::Item == Type时,第一步就是"先选择谓词T as TraitRef<...>",见 project.rs;
  • 新求解器中,compute_normalizes_to_goal会先从投影目标中取出trait_ref,然后调用compute_trait_goal去证明对应的 trait 目标,得到proven_via后再进入候选装配阶段,见 normalizes_to.rs。

正是因为Projection的求解"寄生"在Trait求解之上,将两者拆开表示,可以让求解器分别处理"这个 trait 是否被实现"和"其关联类型如何计算"两个相对独立的问题。

为什么不合并成一条带关联类型的 Trait 约束?

一个很自然的想法是:既然<T as Foo>::AssocA = u32必然伴随T: Foo,为什么不直接引入一条统一的Trait(Foo[T], [AssocA = u32, AssocB = i32])约束?文档给出了核心答案:归一化(normalization)所使用的候选,与证明对应 trait 约束所使用的候选,可能并不相同

这是 trait 系统重构计划中反复出现的一个核心难点,可参考仓库内 candidate-preference.md 的详细论述。其中与拆分问题直接相关的两条优先级规则是:

  1. 总是考虑 AliasBound 候选:即使 trait 目标是通过环境中的 where 约束(ParamEnv候选)证明的,只要 where 约束没有指定关联项的具体取值,归一化时依然要考虑别名绑定(alias-bound)候选,而不是把别名当成 rigid 处理。
  2. 全局 where 约束优先于 impl:形如u32: Id<This = T>这样的全局(global)where 约束在归一化时要优先于impl<T> Id for T

这两条规则直接导致了"证明 trait 用了一条路径、归一化关联类型却走了另一条路径"的局面。如果强行把两者合并成一条谓词,求解器就必须保证两条路径始终一致,而目前的候选机制显然做不到这一点。

新求解器中的assemble_and_merge_candidates函数正是这一分裂的集中体现:它接收proven_via参数(即 trait 目标是通过ParamEnvAliasBound还是其他方式证明的),并据此决定归一化时从哪些来源装配候选,见 assembly/mod.rs。具体逻辑如下:

  • 若 trait 目标通过ParamEnvAliasBound证明,归一化只从环境中装配候选(AssembleCandidatesFrom::EnvAndBounds);若环境里没有任何可用的投影约束,则注入一个rigid 候选inject_normalize_to_rigid_candidate),即把别名当作不可归一的 rigid 类型处理;
  • 若环境中同时存在 where 约束与 alias-bound,则保留 where 约束候选(candidates.retain(|c| matches!(c.source, CandidateSource::ParamEnv(_)))),对应文档中"我们优先选择 where 约束而非别名绑定"的规则,参见 candidate-preference.md;
  • 若 trait 目标通过其他方式(如 impl)证明,则从所有来源装配候选(AssembleCandidatesFrom::All)。

这段代码直观地展示了"候选可以来自多个不同来源"这一现实:候选可能来自环境 where 约束(CandidateSource::ParamEnv)、别名自身绑定(CandidateSource::AliasBound)、用户 impl、trait 对象(BuiltinImplSource::Object)等。既然证明路径可以分裂,那么把 trait 与投影合并成单一谓词,就必须统一这些来源的优先级与约束生成逻辑——这正是文档所说的"quite difficult"。

rigid alias:无法统一表示的"最蠢"原因

文档将 rigid alias 场景称为"最愚蠢的原因"(The most stupid),但它恰恰是最容易理解的失败案例:

对于 rigid alias,尝试归一化它们时,不会考虑证明 trait 约束所带来的任何生命周期约束。

所谓 rigid alias,是指那些没有对应归一化规则、保持原样的投影类型(projection type),例如当环境中的 where 约束没有约束关联类型时,<T as Trait<'a>>::Assoc就被当作 rigid 处理。这种情况下,归一化失败是"立即的、不产生任何约束的";而证明T: Trait<'a>则可能建立形如'a: 'b的生命周期约束。两者产生的副作用完全不同。

文档指出,这源于对 binder 缺乏足够的假设(lack of assumptions on binders),即求解器无法保证在所有 binder(如for<'a>)场景下,归一化路径与 trait 证明路径能共享同一组生命周期约束。这是一个长期存在的问题(对应 trait-system-refactor-initiative 的 issue 177),理应在未来通过完善 binder 假设来修复。

从源码看,新求解器对 rigid alias 的处理是显式而明确的:在compute_normalizes_to_goal中,assemble_and_merge_candidates的最后一个闭包以ProbeKind::RigidAlias的 probe 进入,调用instantiate_normalizes_to_as_rigid,直接返回Certainty::Yes(即"保持为 rigid 即成功"),见 normalizes_to.rs。同时,在 trait 目标经由环境证明且无可用投影约束时,inject_normalize_to_rigid_candidate也会将别名注入为 rigid 候选(见 assembly/mod.rs)。

这两处实现共同说明了问题的本质:rigid alias 的"归一化"不产生任何约束,而 trait 证明会产生约束。一旦把二者合并,求解器就必须回答"是否应该在归一化时也应用 trait 证明产生的生命周期约束",而当前 binder 假设的缺失使得这个回答无法保证正确。

RPITIT:type_of查询环问题

第二个阻碍合并的独立问题是查询环(query cycle):

目前,为Trait目标获取type_of关联类型,或在被遮蔽(shadowed)的Projection候选中获取关联类型时,可能对 RPITIT(return-position impl trait in trait,trait 中返回位置的 impl Trait)引发查询环。

RPITIT 是 trait 方法签名中以-> impl Future<Output = ...>形式出现的隐式关联类型。当求解器需要获取其type_of时,会触发collect_return_position_impl_trait_in_trait_tys等一系列查询;如果此时又恰好处于求解Trait目标或归一化被遮蔽的Projection候选的过程中,查询之间就会互相依赖,形成环。

文档将这一问题的追踪链接指向 trait-system-refactor-initiative 的 issue 185。在候选优先级文档中,同族问题也有记录:为了避免 RPITIT 的查询环,即使环境中存在 where 约束,也不得使用 impl 候选(见 candidate-preference.md,对应 issue #139762)。这与"合并 trait 与投影约束"的目标是相互冲突的:合并后,求解器在更多场景下需要同时获取 trait 的实现信息与关联类型的type_of,查询环的风险反而更突出。

从旧求解器的代码看,RPITIT 相关查询环的规避手段是"如果投影候选来自 impl,则尝试跳过归一化"等启发式处理,而新求解器则通过候选优先级(where 约束遮蔽 impl)来规避。无论哪种方式,都是围绕"trait 证明"与"关联类型取值"的分离所做的妥协——一旦合并,这些妥协策略将失去立足点。

内置实现(builtin impl)之间的细微差异

文档还指出,某些内置实现(builtin impl)的候选之间也存在细微差异,作者认为这些差异"普遍不受欢迎",并判断它们属于 bug——"如果我们有统一的方案,这些 bug 就会被修复"。

这类差异的典型例子是 trait 对象(trait object)的内置实现。在候选优先级文档中明确记录:我们倾向于优先使用内置的 trait 对象 impl 而非用户编写的 impl,而这一偏好是**不健全(unsound)**的,应当在未来移除(见 candidate-preference.md,对应 issue #57893 与 PR #141347)。

从求解器架构看,内置 impl 的候选源CandidateSource::BuiltinImpl本身就携带多种子类型(TrivialObject等),不同子类型在候选合并时的优先级各不相同。如果 trait 与投影是统一的单一谓词,那么"用哪个候选去证明 trait"与"用哪个候选去归一化关联类型"将被强制绑定,这些细微差异自然消失;而在拆分方案下,两者可以各走各的候选路径,差异被保留下来,表现为各种边界情形下的行为不一致。

拆分方案对 where 约束 lowering 的影响

最后,文档从工程实现角度给出了一个非常实际的论据:不拆分会让 where 约束的 lowering 变得更麻烦

具体来说,当前系统对重复 where 约束并不敏感("having duplicate where-clauses is not an issue"),而重复约束在展开(elaborating)超 trait 约束时很容易出现。如果改成单一谓词表示,就必须引入一个额外的步骤:合并所有关联类型约束,这要求求解器具备"识别两条约束指向同一个关联项并合并其赋值"的能力。

文档给出了两个递进的例子。第一个是不带生命周期参数的简单情形:

trait Super { type A; type B; } trait Trait: Super<A = i32> {} // 如何展开 Trait<B = u32>

T: Trait<B = u32>时,由于Trait: Super<A = i32>,我们需要同时推导出T: Super<A = i32, B = u32>。若使用单一谓词表示,展开算法必须把来自Trait定义的A = i32与用户显式给出的B = u32合并进同一条Super约束,即实现"部分约束合并"。

第二个例子则展示了更严重的高阶(higher-ranked)情形:

trait Super<'a> { type A; type B; } trait Trait<'a>: Super<'a, A = i32> {} // 如何展开 // T: Trait<'a> + for<'b> Super<'b, B = u32>

这里T: Trait<'a>展开出T: Super<'a, A = i32>,而另一个约束for<'b> Super<'b, B = u32>是带 binder 的高阶约束。展开算法需要意识到:对'a这个特定生命周期,Super<'a, A = i32>for<'b> Super<'b, B = u32>的实例Super<'a, B = u32>指向的是同一条 trait 的同一个投影,应当合并为Super<'a, A = i32, B = u32>。在拆分方案下,这些投影约束是相互独立的谓词,重复出现也无关紧要,求解器天然容忍这种冗余;而在合并方案下,每个约束都必须被精确地合并去重,否则会引入歧义或错误。这正是文档所说的"not having this split makes lowering where-clauses more annoying"。

结语:分离是现状,统一是远景

综合来看,rustc 之所以维持TraitProjection约束的分离表示,原因可以归纳为四层:

  1. 候选分裂:归一化候选与 trait 证明候选可能来自不同来源(where 约束、alias-bound、impl、内置 impl),统一表示会迫使两者强制一致;
  2. rigid alias 语义差异:归一化 rigid alias 不产生约束,而证明 trait 会产生生命周期约束,二者在 binder 假设完善前无法安全合并;
  3. RPITIT 查询环:合并表示会扩大type_of查询的触发面,加剧已有的查询环问题;
  4. lowering 复杂度:拆分方案天然容忍重复 where 约束,合并方案则要求实现复杂的关联类型约束合并算法(尤其是高阶约束场景)。

这些原因相互交织,构成了 trait 系统重构计划(trait-system-refactor-initiative)中持续跟踪的开放问题。对于希望深入理解 rustc 类型系统的读者,本文涉及的源码是很好的切入点:

  • 约束拆分与投影求解的旧实现:project.rs(重点看assemble_candidates_from_implsassemble_candidates_from_clauses);
  • 新求解器的归一化入口:normalizes_to.rs(compute_normalizes_to_goal);
  • 候选装配与合并逻辑:assembly/mod.rs(assemble_and_merge_candidates);
  • 候选优先级规则的完整论述:candidate-preference.md。

理解"分离"背后的动机,不仅有助于读懂 rustc 的求解器代码,也能解释许多编译错误信息的产生路径——它们往往是候选优先级与约束分离共同作用的结果。

【免费下载链接】rustEmpowering everyone to build reliable and efficient software.项目地址: https://gitcode.com/GitHub_Trending/ru/rust

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询