< 返回版块

Mota-Link 发表于 2025-10-31 17:30

Tags:rust,cgp,patten

CGP(Context-Generic Programming)是一种设计模式,旨在利用 Rust trait 体系来构建“通用组件接口(generic component interfaces)”,将接口的“使用(consumes)”与接口的“实现(implements)”解耦合,从而高性能、类型安全地进行模块化编程。

本文依据 CGP Book 加上笔者个人理解写就,并非照搬原文翻译,感兴趣的读者可以从官方渠道了解 CGP。

  • 官网:https://contextgeneric.dev/
  • Book:https://patterns.contextgeneric.dev/

1. 术语

1.1 Context

Context 指一个【提供某些功能】的【类型】。

功能(Functionality)主要包括:方法、关联类型、关联常量。

任何时候,只要拥有 Context 的实例值,就可以使用它提供的功能。

// Context
struct Person { 
    name: String,
}

impl Person {
    // Functionality
    fn name(&self) -> &str {
        &self.name
    }
}

上述示例中,类型 Person 就是 Context,提供的功能是 name 方法。

在 CGP 中,并非将所有类型都视为 Context。我们使用 Context 的目的主要是期望实现一定的“模块化(modularity)”。

1.2 Consumer

Consumer 指一个【需要使用特定功能】的【代码块】,是功能的“去向”。

// Context
struct Person { 
    name: String,
}

impl Person {
    // Functionality
    fn name(&self) -> &str {
        &self.name
    }
}

// Consumer
fn greet(person: &Person) {
    println!("Hello, {}!", person.name());
}

上述示例中,greet 函数作为一个 Consumer,需要使用 Person Context 提供的 name 功能。然而,这种实现方式使得 Consumer 与具体的 Person Context 强耦合,无法灵活切换为其他 Context。

可以对这个示例进行改造,使 Consumer 更加通用(Generic),能对任何提供了所需功能的 Context 进行消费。

// Functionality
trait HasName {
    fn name(&self) -> &str;
}

// Consumer
fn greet<T: HasName>(context: &T) {
    println!("Hello, {}", context.name());
}

在上述示例中,greet Consumer 只依赖于 name 功能,而不依赖于任何具体的 Context 类型。这样编写 Consumer 时就无需依赖具体的 Context 实现,更利于模块化开发。

像这种只依赖特定【功能】,而【与提供功能的具体 Context 无关】的 Consumer,被称为 Context-Generic Consumer。

与之相对的,依赖于具体 Context 的 Consumer 被称为 Context-Specific Consumer。

1.3 Provider

Provider 指一个为 Context【提供特定功能】的【代码块】,是功能的“来源”。

Provider “实现功能”的方式有:impl 方法、impl trait。在 CGP 中,主要使用的方式是 impl trait。

Provider 与 Consumer 并不是严格分离的,同一代码块有可能即是 Provider,又是 Consumer。

// Context
struct Person {
    name: String,
}

// Functionality
trait HasName {
    fn name(&self) -> &str;
}

// Provider
impl HasName for Person {
    fn name(&self) -> &str {
        &self.name
    }
}

// Functionality
trait CanGreet {
    fn greet(&self);
}

// Provider & Consumer
impl CanGreet for Person {
    fn greet(&self) {
        println!("Hello, {}!", self.name());
    }
}

上述示例中,impl HasName for Person 代码块作为 Provider,为特定的 Person Context 实现了 name 功能。而 impl CanGreet for Person 代码块既作为 Provider 提供了 greet 功能,同时也作为 Consumer 使用了 name 功能。

与 Consumer 类似,Context-Generic Provider 指【只提供功能】而与具体的 Context 无关的 Provider。与之相对的则是 Context-Specific Provider。

然而,相比于 Context-Generic Consumer,Context-Generic Provider 的实现具有很多限制。最主要的限制是 Rust 的“孤儿规则”:在 impl trait for T 时,trait 和 T 至少有其一定义在同一个 crate。

CGP 认为,Context-Generic Consumer 与 Context-Generic Provider 在灵活性和自由度上的差异,是很多 Rust 程序复杂度的根源。CGP 的目标是打破这种差异,使 Context-Generic Provider 更容易实现。

2. 概念

2.1 Blanket Trait Implementations

Blanket Trait Implementations 技术,能自动为所有满足约束的类型实现 trait,是 CGP 中实现 Context-Generic 的基石。

// Functionality
trait HasName {
    fn name(&self) -> &str;
}

// Functionality
trait CanGreet {
    fn greet(&self);
}

// CG - Provider & Consumer
impl<T: HasName> CanGreet for T {
    fn greet(&self) {
        println!("Hello, {}!", self.name());
    }
}

// Context
struct Person {
    name: String,
}

// Provider & Consumer
impl HasName for Person {
    fn name(&self) -> &str {
        &self.name
    }
}

let person = Person { name: "Alice".to_owned() };
person.greet();

上述示例中,impl<T: HasName> CanGreet for T 自动为所有提供 HasName 功能的 Context 实现 CanGreet 功能,无需专门为每个特定的 Context 单独实现。

Blanket Trait Implementations 在各种 Rust 库中常用于自动实现 extension trait,例如为实现了 Stream trait 的类型自动实现 StreamExt。extension trait 的目的是扩展 base trait 的功能,base trait 与实现它的类型不必定义在当前 crate 内。

若某个 trait 存在 Blanket Trait Implementations 时,仅在类型不满足 blanket impl 的约束条件时,才能手动为某类型实现该 trait。若类型满足所有约束条件(或没有设置约束条件),再次手动实现会导致编译失败(trait 重复实现):

trait Base {}
trait Ext {}
impl<T: Base> Ext for T {}

struct S1;
impl Ext for S1 {} // OK

struct S2;
impl Base for S2 {}
impl Ext for S2 {} // Compile failed

上述示例中,S1 没满足 blanket impl 的约束条件,因此原本是没有实现 Ext trait 的,可以手动实现;但 S2 满足约束,已经被 blanket impl 实现了 Ext,再次手动实现会报 Conflicting Implementations 编译错误(不能多次实现同一个 trait)。

这也是 Blanket Trait Implementations 的局限所在:如果两个 blanket impl 块的【泛型取值范围】存在重叠,就会因重复实现 trait 而导致编译失败。换言之,Blanket Trait Implementations 无法对泛型的 trait 约束进行【或运算】组合。

trait B1 {}
trait B2 {}
trait Ext {}

// 期望对实现了 B1 **或** B2 的类型 blanket impl Ext
impl<T: B1> Ext for T {}
impl<T: B2> Ext for T {} // Compile failed

上述示例中,由于有可能存在【同时实现 B1B2】的类型,这两次 blanket impl 有可能作用于同一类型,可能导致重复实现 trait(即使此类型尚未定义)。

2.2 Impl-side Dependencies

Impl-side Dependencies 是一种编码模式,用于避免泛型代码的“约束泄漏”现象。

约束泄漏(Constraint Leaks)指当多个泛型函数层层封装时,底层函数的泛型 trait 约束会沿着调用链向上泄漏,迫使高层函数声明所有间接使用的约束。

trait Base {}
fn f1<T: Base>(_x: T) {}

// f1 的约束 <T: Base> 泄漏到 f2
fn f2<T: Base, U: Into<T>>(y: U) {
    f1(y.into())
}

// f1 的约束 <T: Base> 泄漏到 f3
// f2 的约束 <U: Into<T>> 泄漏到 f3
fn f3<T: Base, U: Into<T>, V: Into<U>>(z: V) {
    f2(z.into())
}

约束泄漏使底层函数与上层函数紧密耦合,修改底层函数的约束需要修改所有上层调用方。若底层调用了多个有约束的函数,上层函数要声明的约束数量将迅速膨胀。

这种紧耦合的根因在于直接用 fn 函数块来承载功能:fn 函数块的“功能定义”与“功能实现”是一体的,约束直接声明在整个“功能”上,每次使用功能时,该功能的所有约束都会向上泄漏。

Impl-side Dependencies 不再直接使用 fn 函数块,而是将功能的“定义”与“实现”拆开:“定义”由 trait 表示,“实现”由 Blanket Trait Implementations 块表示。

Impl-side Dependencies 将约束视为实现功能的“依赖”,通过以下规则防止依赖泄漏:

  • 允许将“定义”作为依赖:表示实现当前功能需要依赖于另一功能。
  • “定义侧”不声明依赖:将“定义”作为依赖时,就无需引入其他依赖。
  • 仅在“实现侧”声明依赖:必须满足依赖,才允许实现该功能。

根据上述规则,实现某一“功能定义”时必须满足其“实现侧”依赖;而声明“功能定义”依赖时无需引入其他依赖项。因此,仅需声明“功能定义”这一个依赖,就相当于隐式依赖了“功能实现”使用的所有间接依赖,不会显式向上泄漏。

Impl-side Dependencies 的字面含义,就是仅在功能的“实现侧”声明依赖。

trait Base {}

// f1 定义
// 定义侧无约束
trait CanF1 {
    fn f1(self);
}
// f1 实现
// 在实现侧进行约束
// 实现 CanF1 需要依赖 Base
impl<T: Base> CanF1 for T {
    fn f1(self) {
        println!("f1");
    }
}

// f2 定义
trait CanF2<T> {
    fn f2(self);
}
// f2 实现
// 实现 f2 需要依赖 f1 功能与 into 功能
// T: CanF1 依赖“功能定义”,隐藏了 T: Base 的“实现依赖”
impl<T: CanF1, U: Into<T>> CanF2<T> for U {
    fn f2(self) {
        self.into().f1();
    }
}

// f3 定义
trait CanF3<T, U> {
    fn f3(self);
}
// f3 实现
// 实现 f3 需要依赖 f2 功能与 into 功能
// U: CanF2<T> 依赖“功能定义”,隐藏了 T: CanF1, U: Into<T> 的“实现依赖”,也隐藏了 T: Base 的“实现依赖”
impl<T, U: CanF2<T>, V: Into<U>> CanF3<T, U> for V {
    fn f3(self) {
        self.into().f2();
    }
}

上述示例中,CanF2<T> 不能省略泛型参数。

若将其省略为 impl<T: CanF1, U: Into<T>> CanF2 for U,由于可能存在多个类型 T 满足约束 <T: CanF1, U: Into<T>>,可能导致 CanF2 为同一类型 U 重复实现,所以会编译错误。

使用 Impl-side Dependencies 模式让上层开发者无需了解底层功能实现时的依赖细节,只需专注于对功能定义的【直接依赖】。这种技巧在其他编程语言中有时被称为“依赖注入(Dependency Injection)”。

Impl-side Dependencies 虽然分离了功能的定义与实现,但在实现功能时用 Blanket Trait Implementations 为满足依赖的 Context 【直接】提供功能。由于 Rust trait 系统的严格限制,使得 Context 在实现功能时没有选择的余地,不同类型对“同一功能”不能灵活定制“不同实现”。

trait Base1 {
    fn foo(self);
}
trait Base2 {
    fn bar(self);
}
trait CanF1 {
    fn f1(self);
}

// 期望实现了 Base1 的类型可以选择 foo 来实现 f1
// 实现了 Base2 的类型可以选择 bar 来实现 f1

impl<T: Base1> CanF1 for T {
    fn f1(self) {
        self.foo();
    }
}

// Compile failed
// 对同时实现 Base1 与 Base2 的类型重复实现 CanF1
impl<T: Base2> CanF1 for T {
    fn f1(self) {
        self.bar();
    }
}

3. Component System

为了实现“通用组件接口”,CGP 提出了 Component System 最佳实践,将【实现功能】分割成【定义-开发-聚合-选择-绑定】五大环节:

  • 定义:定义功能接口,隐藏底层依赖。
  • 开发:为功能定义预先开发若干种实现方案候选项。
  • 聚合:不同功能的方案互相搭配,聚合为若干个候选方案组。
  • 选择:选择一个方案组作为 Context 各功能的逻辑实现。
  • 绑定:将方案组内的所有方案绑定到 Context 上。

本节将详述这些环节的原理与演进。

3.1 Provider trait & Consumer trait

Impl-side Dependencies 在实现功能时,直接对 Context 泛型 blanket impl 指定了具体实现,导致 Context 无法灵活选择其他实现方式。为了解决这一问题,需要将“功能实现”解耦为【开发方案】与【绑定方案】两部分。

  • 方案:指某一“功能定义”的一种具体“实现方式”,是可执行的逻辑代码。在 CGP 中,“方案”属于 Functionality。
  • 开发方案:对一个“定义”预先创建若干种“方案”,作为功能的“候选逻辑”提供给 Context 选用。在 CGP 中,“开发”属于 Provider。
  • 绑定方案:是从候选“方案”中选定一个,将其封装到 Context 内,使其成为功能的“最终逻辑”。在 CGP 中,“绑定”属于 Consumer。

为实现这种解耦,Component System 引入了 Provider trait 与 Consumer trait 的概念:

  • Provider trait 用于定义提供的功能,对该 trait 的实现用于“开发方案”。Provider trait 并不直接对 Context 类型实现,因此不能在 trait 内通过 Self 隐式指代 Context,而是要显式声明一个泛型参数来表示 Context 类型。

  • Consumer trait 用于定义 Context 需要实现的功能,对该 trait 的实现用于“绑定方案”到 Context。与 Provider trait 相反,Consumer trait 是直接为 Context 类型实现的,因此要将 Provider trait 中用于指代 Context 的泛型类型替换为 Self。

Provider trait 与 Consumer trait 本质上是同一个功能在不同角度的定义:前者是 Provider 角度,后者是 Context 角度。两者的主要区别仅在于泛型类型和参数定义方式不同。

在开发方案时,需要一个类型作为 Provider trait 的实现者。由于 Provider trait 并不依赖实现者类型的 Self,通常将实现者声明为零开销(zero-cost)的 unit-like 结构体,并以“类型关联函数”的形式提供具体的“方案”。

unit-like 结构体仅在编译时作为类型系统的标识符,起到一个 marker 的作用,在运行时并不真实存在,因此是“零开销”的。

提供“方案”的实现者类型在 CGP 中属于 Provider,“开发方案”就是要为这个 Provider 实现 Provider trait;为同一定义开发不同“方案”,就是对不同的 Provider 类型实现同一个 Provider trait。由于实现 trait 的目标类型不同,即使不同方案的泛型参数取值范围有重叠,也不会导致 trait 重复实现。

得益于“泛型代码膨胀(Generics code bloat)”,对同一方案的 Provider 实现不同泛型参数的 Provider trait 时,也不会引发编译错误。

Provider 类型只负责提供“方案”,与使用“方案”的 Context 无关。任何 Context 只要满足依赖,都能够绑定该“方案”来作为功能的“最终实现”。CGP 中,这里的 Provider 属于 Context-Generic Provider。

trait Base1 { fn foo(self); }
trait Base2 { fn bar(self); }

// Provider trait - 定义 f1 功能
// 泛型参数 Context 表示具体的 Context 类型
trait F1<Context> { fn f1(x: Context); }

// Consumer trait - 定义 f1 功能
// 用 Self 指代具体的 Context 类型
trait CanF1 { fn f1(self); }

// 方案 1 的 Provider
struct FooF1;
// 开发方案 1:依赖 Base1
impl<Context: Base1> F1<Context> for FooF1 {
    fn f1(x: Context) { x.foo(); }
}

// 方案 2 的 Provider
struct BarF1;
// 开发方案 2:依赖 Base2
impl<Context: Base2> F1<Context> for BarF1 {
    fn f1(x: Context) { x.bar(); }
}

// Context
struct Ctx;
// Context 满足 Base1 依赖
impl Base1 for Ctx {
    fn foo(self) { println!("foo"); }
}
// Context 满足 Base2 依赖
impl Base2 for Ctx {
    fn bar(self) { println!("bar"); }
}

// 绑定方案 2 作为功能 f1 的实现
// 封装 BarF1::f1 关联函数来为 Context 实现 f1
impl CanF1 for Ctx {
    fn f1(self) { BarF1::f1(self) }
}

上述示例中,CanF1 Consumer trait 可以自由绑定 FooF1BarF1 方案作为 Ctx Context 的 f1 功能实现。如果 Consumer trait 绑定了依赖未被满足的方案,则会在编译时产生错误。

3.2 HasCgpProvider trait

上节实现了“开发方案”与“绑定方案”的解耦,其中“开发方案”实现了 Context-Generic Provider,但“绑定方案”时仍需为每个 Context 手动实现 Consumer trait 来选择并封装方案,尚未实现能自动化绑定、与具体 Context 无关的 Context-Generic Consumer。

实现 Context-Generic Consumer 需借助 Blanket Trait Implementations 技术来实现 Consumer trait,但若要让不同 Context 灵活选择不同方案,就不能将“选择方案”写死在 blanket impl 块中。因此,应将“方案选择”从“绑定方案”中解耦,在外部为每个 Context 单独选择方案。“绑定方案”只需在 blanket impl 时获取并封装 Context 已选择的方案即可,无需关注具体 Context 或方案细节。

Component System 引入了 HasCgpProvider trait 来实现解耦合的“选择方案”。HasCgpProvider 是一个特定的 trait,顾名思义,HasCgpProvider 用于表示某个 Context 拥有(Has)特定的功能方案(CgpProvider)。其定义如下:

pub trait HasCgpProvider {
    type CgpProvider;
}

“选择方案”就是要为具体的 Context 实现 HasCgpProvider,并将关联类型 CgpProvider 指定为所选方案的 Provider 类型(即 Provider trait 的实现者)。

“绑定方案”需在 blanket impl 时声明“依赖”:只要 Context 实现了 HasCgpProvider,且关联类型 CgpProvider 是对应功能定义(Provider trait)的方案 Provider,blanket impl 就会自动为这个 Context 绑定选择的方案,实现对应功能。

HasCgpProvider 既不是 Provider 也不是 Consumer,而是作为一种“选择关系”的抽象。引入 HasCgpProvider trait 后,每个 Context 只需关注方案的选择,无需参与到 Provider 与 Consumer 的琐碎实现细节中。

// Provider trait
trait F1<Context> { fn f1(x: Context); }

// Consumer trait
trait CanF1 { fn f1(self); }

// 方案 1 Provider
struct FooF1;
impl<Context> F1<Context> for FooF1 {
    fn f1(_x: Context) { println!("foo"); }
}

// 方案 2 Provider
struct BarF1;
impl<Context> F1<Context> for BarF1 {
    fn f1(_x: Context) { println!("bar"); }
}

pub trait HasCgpProvider {
    type CgpProvider;
}

// Context
struct Ctx;

// 选择方案
// Ctx 选择方案 2 作为功能 f1 的实现
impl HasCgpProvider for Ctx {
    type CgpProvider = BarF1;
}

// 绑定方案
// 自动为所有满足依赖的 Context 绑定
impl<Context> CanF1 for Context
where
    Context: HasCgpProvider, // 依赖于 Context 已选择方案 Provider
    Context::CgpProvider: F1<Context>, // 且选择的 Provider 提供功能 f1 的方案
{
    fn f1(self) {
        // 使用 CgpProvider 提供的方案,实现功能 f1
        Context::CgpProvider::f1(self)
    }
}

上述示例中,CanF1 Consumer 自动为所有已选择 F1 功能方案的 Context 绑定所选的方案。由于 Ctx Context 通过 HasCgpProvider 选择了 BarF1 方案,因此 Ctx Context 自动实现了功能 f1

3.3 Component & DelegateComponent trait

上节引入 HasCgpProvider trait 实现了“选择方案”与“绑定方案”的解耦。然而,模块化编程不仅要“解耦”,还需实现多个功能的“聚合”。HasCgpProvider trait 只能绑定一个关联类型,这意味着每个 Context 只能为一个功能选择方案,无法聚合多个功能。

虽然有些简单的方式可以让 Context 选择多个方案,但每种方式都有其缺点:

一种方式是为 HasCgpProvider trait 添加泛型参数。利用泛型代码膨胀,让 Context 能多次实现 HasCgpProvider<T>,为不同功能分别选择方案。其中泛型 T 是仅用于标识“功能名称”的 unit-like 结构体。

这种方式的缺点是会增加 Context 的实现负担:要为每个功能都实现一次 HasCgpProvider。此外,这种方式也无法复用常见的功能方案组合:当多个 Context 需要为同一组功能选择相同方案搭配时,仍需重复编写大量样板代码。

另一种方式是定义 Aggregated Provider 类型。它类似于普通的方案 Provider,都是实现了 Provider trait、能提供“方案”的 unit-like 结构体。不同之处在于,Aggregated Provider 可以同时实现多个 Provider trait,从而为多个功能提供方案。这样,Context 在实现 HasCgpProvider trait 时只需选择一个 Aggregated Provider 类型,即可绑定多个方案。

这种方式不会增加 Context 的负担,并且可以通过复用同一个 Aggregated Provider 来复用功能方案搭配。但其缺点在于,每次需要新的方案搭配时,都要为新的 Aggregated Provider 类型实现所有相关的 Provider trait,仍然存在样板代码的问题。

Component System 综合以上两种方式,引入了 Component 概念与 DelegateComponent trait,实现对功能方案的“聚合”。

Component 代表整个“功能”,由一对 Provider trait 和 Consumer trait 共同定义。对 Component 的聚合,就是定义一个 Aggregated Provider 类型,并为其实现各个 Component 的 Provider trait。若 Context 选择了该 Aggregated Provider,各个 Consumer trait 的 blanket impl 会自动为 Context 实现相应功能。

对 Component 的聚合,不会生成新的 Component,而是要生成 Aggregated Provider。

为了复用现有逻辑,Aggregated Provider 在实现 Provider trait 时,会将 Component 的某一方案封装为 Provider trait 的内部实现。这与前文“手动封装方案实现 Consumer trait”的做法很像,因此可以套用将“选择”与“绑定”解耦的优化思路:为 Aggregated Provider 选择方案,并通过 blanket impl 封装所选方案,来实现 Provider trait。

DelegateComponentHasCgpProvider 都是起到“选择”的作用的 trait,但前者的目标类型是 Aggregated Provider,而不是 Context。DelegateComponent trait 的定义如下:

pub trait DelegateComponent<Name> {
    type Delegate;
}

DelegateComponent 通过泛型 Name 来区分不同的 Component,利用泛型代码膨胀,使 Aggregated Provider 能够聚合不同 Component 的方案,并限制每个 Component 只能通过关联类型 Delegate 最多选择一种方案参与聚合。

由于 DelegateComponent 通过泛型类型区分 Component,因此每个 Component 都得额外定义一个 unit-like 结构体来标识“功能名称”。在 blanket impl 对应功能的 Provider trait 时,需要声明依赖:只要 Aggregated Provider 实现了 DelegateComponent<Name>,且关联类型 Delegate 是功能名称 Name 所示的 Component 的方案 Provider,blanket impl 就会自动为这个 Aggregated Provider 实现对应的 Provider trait。

顾名思义,DelegateComponent<Name> 的作用是让实现者类型(即 Aggregated Provider)能够代表(Delegate)泛型参数 Name 对应的 Component。而 HasCgpProvider 只需指定该 Aggregated Provider 类型,即可“代为选择”对应 Component 的方案 Provider。

pub trait HasCgpProvider { type CgpProvider; }
pub trait DelegateComponent<Name> { type Delegate; }

//-----------------------------------------

// Component 1 - Name
struct F1Component;

// Component 1 - Provider trait
trait F1<Context> { fn f1(x: Context); }

// Component 1 - Consumer trait
trait CanF1 { fn f1(self); }

// Component 1 - 方案 Provider
struct F1P;
impl<Context> F1<Context> for F1P {
    fn f1(_x: Context) {}
}

// Component 1 - 自动为 AggregatedProvider 聚合方案
impl<Context, AggregatedProvider> F1<Context> for AggregatedProvider
where
    AggregatedProvider: DelegateComponent<F1Component>, // 依赖 AggregatedProvider 聚合了 F1Component
    AggregatedProvider::Delegate: F1<Context>, // 且选择的 Provider 确实提供 F1Component 的方案
{
    fn f1(x: Context) {
        // AggregatedProvider 直接提供 AggregatedProvider::Delegate 所提供的方案
        AggregatedProvider::Delegate::f1(x)
    }
}

// Component 1 - 自动为 Context 绑定方案
impl<Context> CanF1 for Context
where
    Context: HasCgpProvider,
    Context::CgpProvider: F1<Context>,
{
    fn f1(self) {
        Context::CgpProvider::f1(self)
    }
}

//-----------------------------------------

// Component 2 - Name
struct F2Component;

// Component 2 - Provider trait
trait F2<Context> { fn f2(x: Context); }

// Component 2 - Consumer trait
trait CanF2 { fn f2(self); }

// Component 2 - 方案 Provider
struct F2P;
impl<Context> F2<Context> for F2P {
    fn f2(_x: Context) {}
}

// Component 2 - 自动为 AggregatedProvider 聚合方案
impl<Context, AggregatedProvider> F2<Context> for AggregatedProvider
where
    AggregatedProvider: DelegateComponent<F2Component>,
    AggregatedProvider::Delegate: F2<Context>,
{
    fn f2(x: Context) {
        AggregatedProvider::Delegate::f2(x)
    }
}

// Component 2 - 自动为 Context 绑定方案
impl<Context> CanF2 for Context
where
    Context: HasCgpProvider,
    Context::CgpProvider: F2<Context>,
{
    fn f2(self) {
        Context::CgpProvider::f2(self)
    }
}

//-----------------------------------------

// Aggregated Provider
struct CtxAggregatedProvider;

// 将 F1Component 聚合到 CtxAggregatedProvider
impl DelegateComponent<F1Component> for CtxAggregatedProvider {
    // 选择 F1P 作为 F1Component 聚合的方案
    type Delegate = F1P;
}

// 将 F2Component 聚合到 CtxAggregatedProvider
impl DelegateComponent<F2Component> for CtxAggregatedProvider {
    // 选择 F2P 作为 F2Component 聚合的方案
    type Delegate = F2P;
}

//-----------------------------------------

// Context
struct Ctx;

// 选择 CtxAggregatedProvider,提供聚合的 F1P 与 F2P 方案
impl HasCgpProvider for Ctx {
    type CgpProvider = CtxAggregatedProvider;
}

上述示例中,CtxAggregatedProvider 实现了两次 DelegateComponent,成功聚合 F1PF2P 方案。因此 Context Ctx 只需通过 HasCgpProvider 选择 CtxAggregatedProvider,就能自动绑定这两个方案。

Component System 通过上述一系列设计,将“选择方案”有效地解耦为“聚合方案”与“选择方案组”。这样,不同功能方案可以自由搭配、灵活切换,甚至可以将多个 Aggregated Provider 再次聚合,真正实现了“通用组件接口”的目标。

3.4 Debug

当前代码虽然实现了“通用组件接口”,但存在两个主要问题,使得代码难以调试:

  1. 方案 Provider 的 Impl-side Dependencies 是“惰性依赖”:只有在 Context 实际用到相关功能时才会进行依赖检查,无法在早期暴露依赖缺失的问题。
  2. 当 Context 缺少依赖时,编译器的报错信息不够直观,难以准确定位缺失的具体依赖。

CGP 期望在未来改进 Rust 编译器以显示更准确的错误信息,但在目前需要使用临时方案:引入 IsProviderForCanUseComponent trait 显式传播依赖关系,并通过一个 check trait 进行强制依赖检查。

IsProviderFor trait

pub trait IsProviderFor<Component, Context, Params = ()> {}

IsProviderFor 需要为“提供方案”的 Provider 类型实现,用于标识该类型可以为某个 Context 提供某个 Component 的实现方案。各泛型参数作用如下:

  • Component 接收一个 Component Name 类型,标识该 Provider 所属的功能。
  • Context 会设置为 impl 时声明的泛型,并设置与 Provider trait 相同的 impl-side 依赖,限制只为满足依赖的 Context 提供该方案。
  • Params 将实现 Provider trait 所需的其他泛型参数合并到一个元组中,便于泛型代码展开。
impl<Context> IsProviderFor<F1Component, Context> for F1P 
where
    // 设置与 Provider trait 相同的 impl-side 依赖
    Context: Base
{}

上述示例中,通过为 F1P 实现 IsProviderFor,标识该 Provider 可为所有实现了 Base 的 Context 提供 F1Component 功能的方案。对不满足依赖的 Context,不提供该方案。

无论是方案 Provider 还是聚合 Provider,每个 Provider 类型都需要实现 IsProviderFor。为避免遗漏,应将 IsProviderFor 设为每个 Provider trait 的 super trait。例如:

trait F1<Context>: IsProviderFor<F1Component, Context> {
    fn f1(x: Context);
}

这样,类型若要实现 F1 Provider trait,必须同时实现 IsProviderFor,且其 Component 泛型值限定为 F1Component

由于 IsProviderFor 被设为 Provider trait 的 super trait,在方案聚合,通过 blanket impl 自动为 Aggregated Provider 实现 Provider trait 时,必须为 Aggregated Provider 泛型增加对应功能的 IsProviderFor 依赖,否则无法自动聚合。

impl<Context, AggregatedProvider> F1<Context> for AggregatedProvider
where
    // 要求 AggregatedProvider 在实现 F1<Context> 时必须同时实现 IsProviderFor<F1Component, Context>
    AggregatedProvider: DelegateComponent<F1Component> + IsProviderFor<F1Component, Context>,
    AggregatedProvider::Delegate: F1<Context>,
{ /* ... */ }

同理,只有为 Aggregated Provider 实现了 IsProviderFor,才能在方案选择后自动完成聚合。

需要注意的是,不能通过 blanket impl 的方式为所有 Aggregated Provider 自动实现 IsProviderFor,这会干扰编译器的依赖解析,导致错误信息无法准确定位到缺失的具体依赖。必须为每个 Aggregated Provider 手动实现 IsProviderFor,并确保其 DelegateComponent 聚合的方案也属于同一个 Component。

impl<Context> IsProviderFor<F1Component, Context> for CtxAggregatedProvider
where
    // 聚合 Provider 为 Context 提供 F1Component 的方案
    // 前提是聚合的 F1P 也为该 Context 提供 F1Component 方案
    F1P: IsProviderFor<F1Component, Context>,
{}

通过上述改造,IsProviderFor 在聚合的各层 Provider 之间建立了依赖传播链路:外层 Provider 能否为 Context 提供某项功能,取决于内层 Provider 是否为该 Context 提供了该功能。编译器可以从最外层 Provider 向内推导,定位到 Context 缺失的最底层方案依赖。

CanUseComponent trait

pub trait CanUseComponent<Component, Params = ()> {}

CanUseComponent 需要为 Context 类型实现,用于标识该 Context 能够使用某个 Component 功能,便于后续 check trait 校验。

CanUseComponent 非常适合通过 blanket impl 自动实现:只要某个 Context 选择了一个 Provider,并且该 Provider 能为该 Context 提供某个 Component 的方案,就可以自动为该 Context 实现 CanUseComponent,允许其使用此 Component 功能。

impl<Context, Component, Params> CanUseComponent<Component, Params> for Context
where
    Context: HasCgpProvider,
    Context::CgpProvider: IsProviderFor<Component, Context, Params>,
{}

CanUseComponent 的 blanket impl 不会影响依赖解析,因为依赖传播链路已经由 IsProviderFor 建立,而 blanket impl 并未破坏这一链路。CanUseComponent 实际是顶层 IsProviderFor 的另一种表达方式,它省略了 Context 泛型,并为后续 check trait 的实现提供更优雅的语义。

check trait

IsProviderFor 的设计思想是:将【功能依赖检查】转变为【trait 实现检查】。只需检查某个 Provider 是否实现了 IsProviderFor<Comp, Ctx>,就能知道 Ctx 在“选择”这个 Provider 之后,能否实现 Comp 功能。

CanUseComponent 则是对 IsProviderFor 的一种更符合人体工程学的封装,隐藏了“选择”的过程。只需检查 Context 是否实现了 CanUseComponent<Comp>,就能确定该 Context 是否具备 Comp 功能。

如果 Context 没有实现 CanUseComponent<Component>,说明其所选的 Aggregated Provider 未实现 IsProviderFor<Component, Context>,也说明参与聚合的 Provider 同样未实现该 trait。这样层层追溯,就能精确定位到 Context 缺失的底层方案依赖。

要检查一个类型是否实现了某个 trait,最简洁的方法是定义一个标记 trait,并将所有需要检查的 trait 声明为它的 super trait,然后为目标类型实现此标记 trait。这样,如果类型没有实现所有 super trait,编译器就会报错。

这就是 check trait 的原理,通过将 CanUseComponent 设置为 super trait,让编译器主动检查 Context 是否满足底层方案依赖,从而确保 Context 具备所有期望的功能。如果有任何底层依赖未被满足,Context 就无法实现对应的 CanUseComponent,尝试实现 check trait 就无法通过编译。

// check trait - 期望 Ctx 具备 F1Component 功能
trait CanUseCtx: CanUseComponent<F1Component> {}

// 若不具备功能,此行将导致编译失败
impl CanUseCtx for Ctx {} 

通过结合 IsProviderForCanUseComponent 与 check trait,解决了原本使代码难以调试的两大问题。最终完整的代码如下:

pub trait HasCgpProvider {
    type CgpProvider;
}
pub trait DelegateComponent<Name> {
    type Delegate;
}

pub trait IsProviderFor<Component, Context, Params = ()> {}

pub trait CanUseComponent<Component, Params = ()> {}

// 自动为 Context 实现 CanUseComponent
// 标识 Context 具备 Component 功能
impl<Context, Component, Params> CanUseComponent<Component, Params> for Context
where
    Context: HasCgpProvider,
    // 前提是 Context 选择的 Provider 为其提供 Component
    Context::CgpProvider: IsProviderFor<Component, Context, Params>,
{}

//-----------------------------------------

// Dependence
trait Base {}

// Name
pub struct F1Component;

// Provider trait
trait F1<Context>: IsProviderFor<F1Component, Context> {
    fn f1(x: Context);
}

// Consumer trait
trait CanF1 {
    fn f1(self);
}

// 方案 Provider
struct F1P;
impl<Context> F1<Context> for F1P 
where 
    Context: Base
{
    fn f1(_x: Context) {}
}

// 标识 F1P 为 Context 提供 F1Component 功能
impl<Context> IsProviderFor<F1Component, Context> for F1P
where 
    // 前提是 Context 实现 Base(与实现 F1 相同的依赖)
    Context: Base
{}

// 自动为 AggregatedProvider 聚合方案
impl<Context, AggregatedProvider> F1<Context> for AggregatedProvider
where
    // 要求 AggregatedProvider 在实现 F1<Context> 时必须同时实现 IsProviderFor<F1Component, Context>
    AggregatedProvider: DelegateComponent<F1Component> + IsProviderFor<F1Component, Context>,
    AggregatedProvider::Delegate: F1<Context>,
{
    fn f1(x: Context) { AggregatedProvider::Delegate::f1(x) }
}

// 自动为 Context 绑定方案
impl<Context> CanF1 for Context
where
    Context: HasCgpProvider,
    Context::CgpProvider: F1<Context>,
{
    fn f1(self) { Context::CgpProvider::f1(self) }
}

//-----------------------------------------

// Aggregated Provider
struct CtxAggregatedProvider;

// 选择 F1P 作为 F1Component 方案
impl DelegateComponent<F1Component> for CtxAggregatedProvider {
    type Delegate = F1P;
}

// 标识 Aggregated Provider 为 Context 提供 F1Component 功能
impl<Context> IsProviderFor<F1Component, Context> for CtxAggregatedProvider
where 
    // 前提是聚合的 F1P 为该 Context 提供 F1Component 功能
    F1P: IsProviderFor<F1Component, Context>
{}

//-----------------------------------------

// Context
struct Ctx;

// Context 未实现依赖 Base
// impl Base for Ctx {}

impl HasCgpProvider for Ctx {
    type CgpProvider = CtxAggregatedProvider;
}

// check trait - 期望 Ctx 具备 F1Component 功能
trait CanUseCtx: CanUseComponent<F1Component> {}

// Compile error: 未实现 super trait `CanUseComponent<F1Component>`
impl CanUseCtx for Ctx {}

4. 结语

CGP 通过一系列精巧的 Trait 设计,为 Rust 开发者展示了一种构建高度模块化、可扩展且类型安全的系统架构范式。但不可否认,CGP 模式引入了相当的复杂度,包含了一系列需要理解和遵循的样板代码和设计约定。这是一种用前期架构的复杂性换取后期维护的灵活性与扩展性的权衡。因此,它并非适用于所有项目。

cgp crate 提供了一系列 macros 来减少样板代码,但此 crate 目前仍处于早期的发展阶段,其 API 尚未稳定,因此不对其具体的 macro 作过多介绍。本文之意仅在带领读者进行一场思维体操,以期带来更多新颖的编码思路 :)

评论区

写评论

还没有评论

1 共 0 条评论, 1 页