使用Builder模式构建Rust API


这个模式背后的想法非常简单:创建一个可以但不需要持有所有值的对象,当所有需要的字段都存在时,用它来构建我们需要的类型。

为了熟悉Rust中的构建器模式,让我们首先比较一下我们的代码在有和没有构建器的情况下会是什么样子:

// without a builder
let base_time = DateTime::now();
let flux_capacitor = FluxCapacitor::new();
let mut option_load = None;
let mut mr_fusion = None;

if get_energy_level(&plutonium) > 1.21e9 {
    option_load = Some(plutonium.rods());
} else {
   
// need an energy source, can fail
    mr_fusion = obtain_mr_fusion(base_time).ok();
}

TimeMachine::new(
    flux_capacitor,
    base_time,
    option_load,
    mr_fusion,
)
// with a builder
let builder = TimeMachineBuilder::new(base_time);
    .flux_capacitor(FluxCapacitor::new());

if get_energy_level(&plutonium) > 1.21e9 {
    builder.plutonium_rods(plutonium.rods())
} else {
    builder.mr_fusion()
}
.build()

本文中所有的例子都是简单的设计。在实践中,你会对有更多依赖关系的复杂类型使用一个构建器。

构建器的主要功能是将构建我们的实例所需的数据集中在一个地方。只需定义TimeMachineBuilder结构,放入一堆Option<_>字段,添加一个带有new和build方法的impl,以及一些setters,就可以了。就这样,你现在知道了关于构建器的一切。

所有权Owned与可变的可引用的构建器
与一些垃圾收集语言不同,在Rust中,我们将自有值Owned values与借用值区分开来,因此,有多种方法来设置构建器方法:

  • 一种是通过可变引用获取&mut self 。(以下称为可变借用构建器Mutably borrowed builder)
  • 另一种是通过值获取self。(以下称为按值获取构建器by-value builder)

前者有两个子变体,要么返回&mut self用于链chain,要么返回“()”。
允许链的方式稍微常见一些。

我们的例子使用了链,因此使用了一个by-value构建器。new的结果被直接用来调用另一个方法:

可变借用构建器
可变借用构建器(Mutably borrowed builder)的好处是可以在同一个构建器上调用多个方法,同时还允许一些链式调用。然而,这样做的代价是需要对构建器的设置进行绑定。例如,下面的代码在使用&mut self返回方法时会失败:

let builder= ByMutRefBuilder::new()
    .with_favorite_number(42); // this drops the builder while borrowed

然而,做完整的链调用仍然有效:

ByMutRefBuilder::new()
    .with_favorite_number(42)
    .with_favorite_programming_language("Rust")
    .build()


如果我们想重复使用构建器,我们需要绑定new()调用的结果:

let mut builder = ByMutRefBuilder::new();
builder.with_favorite_number(42) // this returns `&mut builder` for further chaining

我们也可以忽略链调用,而是多次调用同一个绑定:

let mut builder = ByMutRefBuilder::new();
builder.with_favorite_number(42);
builder.with_favorite_programming_language("Rust");
builder.build()

按值获取构建器
另一方面,按值获取构建器(by-vaule builder)需要重新绑定,这样才能不会drop丢弃他们的状态:

let builder = ByValueBuilder::new();
builder.with_favorite_number(42); // this consumes the builder :-(

因此,它们通常是链调用的:

ByValueBuilder::new()
    .with_favorite_number(42)
    .with_favorite_programming_language("Rust")
    .build()

因此,对于by-value构建器,我们需要链调用,只要构建器本身被绑定到某个局部变量上。
可变引用的构建器就允许链式运行。此外,可被引用的构建器可以被自由地重复使用,因为它们不被其方法所消费使用。

如果构建器将在有许多分支的复杂代码中经常使用,或者它有可能从中间状态被重复使用,我会倾向于使用可变引用的构建器。否则,我就会使用一个按值构建器。

Into 和 AsRef traits
当然,构建器方法可以做一些基本的转换。最流行的是使用Into trait来绑定输入。

例如,你可以把索引当作任何有Into<usize>实现的东西,或者让构建器通过Into<Cow<'static, str>>参数来减少分配,这使得函数同时接受&'static str和String。对于可以作为引用给出的参数,AsRef特性可以让提供的类型有更多的自由。

还有一些专门的特质,如IntoIterator和ToString,在某些情况下也是有用的。例如,如果我们有一个值的序列,我们可以有add和add_all方法来扩展每个内部的Vec。

impl FriendlyBuilder {
    fn add(&mut self, value: impl Into<String>) -> &mut Self {
        self.values.push(value.into())
        self
    }

    fn add_all(
        &mut self,
        values: impl IntoIterator<Item = impl Into<String>>
    ) -> &mut Self {
        self.values.extend(values.into_iter().map(Into::into));
        self
    }
}

默认值Default
类型通常可以有可行的默认值default。因此,构建者可以预先设置这些默认值,只有在用户要求时才替换它们。在极少数情况下,获取默认值的代价会很高。构建器可以使用一个选项,它有自己的无默认值,或者执行另一个技巧来跟踪哪些字段被设置,我们将在下一节解释。

当然,我们并不拘泥于Default的实现,我们可以设置自己的默认值。例如,我们可以决定越多越好,所以默认的数字是u32::MAX,而不是Default给我们的0。

对于涉及引用计数的更复杂的类型,我们可以有一个静态默认值。对于引用计数的运行时间开销的小代价,它每次都会得到Arc::clone(_)。或者,如果我们允许借用静态实例,我们可以使用Cow<'static, T>作为默认值,避免分配,同时仍然保持简单的构建。

use std::sync::Arc;

static ARCD_BEHEMOTH: Arc<Behemoth> = Arc::new(Behemoth::dummy());
static DEFAULT_NAME: &str = "Fluffy";

impl WithDefaultsBuilder {
    fn new() -> Self {
        Self {
           
// we can simply use `Default`
            some_defaulting_value: Default::default(),
           
// we can of course set our own defaults
            number: 42,
           
// for values not needed for construction
            optional_stuff: None,
           
// for `Cow`s, we can borrow a default
            name: Cow::Borrowed(DEFAULT_NAME),
           
// we can clone a `static`
            goliath: Arc::clone(ARCD_BEHHEMOTH),
        }
    }
}

使用类型状态保持对集合字段的跟踪
追踪被设置的字段只适用于拥有的变体。我们的想法是使用泛型将关于哪些字段已经被设置的信息放入类型。因此,我们可以避免重复设置,事实上,我们甚至可以禁止它们,以及只允许在所有需要的字段都被设置后再建立。

让我们举一个简单的例子,有一个喜欢的数字、编程语言和颜色,其中只有第一个是需要的。我们的类型将是TypeStateBuilder<N, P, C>,其中N表示数字是否被设置,P表示编程语言是否被设置,C表示颜色是否被设置。

然后我们可以创建Unset和Set类型来填充我们的泛型。我们的新函数将返回TypeStateBuilder<Unset, Unset, Unset>,而只有TypeStateBuilder<Set, _, _>有一个.build()方法。

在我们的例子中,我们到处使用默认值,因为使用不安全的代码将无助于理解这个模式。但是,使用这种方案当然可以避免无谓的初始化。

use std::marker::PhantomData;

/// A type denoting a set field
enum Set {}

/// A type denoting an unset field
enum Unset {}

/// Our builder. In this case, I just used the bare types.
struct<N, P, C> TypeStateBuilder<N, P, C> {
    number: u32,
    programming_language: String,
    color: Color,
    typestate: PhantomData<(N, P, C)>,
}

/// The `new` function leaves all fields unset

该接口的工作原理与by-value builder完全相同,但不同的是,用户只能设置一次字段,或者多次,如果为这些情况添加了一个植入。我们甚至可以控制哪些函数以何种顺序被调用。例如,我们可以只允许.with_favorite_programming_language(_)在.with_favorite_number(_)已经被调用之后,typestate编译下来就没有了。

这样做的缺点显然是复杂性;需要有人写代码,而编译器必须解析和优化它。因此,除非typestate被用来实际控制函数调用的顺序或允许优化初始化,否则它可能不是一个好的投资。

Rust构建器模式库
由于构建器遵循如此简单的代码模式,所以在crates.io上有许多crates来自动生成它们。

derive_builder  crate可以用Into参数和来自结构定义的可选默认值来构建我们的标准可引用的构建器。你也可以提供验证函数。这是最流行的自动生成构建器的proc macro crate,它是一个可靠的选择。在写这篇文章的时候,这个crate已经有六年的历史了,所以这是自derives稳定下来后的第一个derive crate之一。

typed-builder  crate处理整个by-value typestate的实现,如上所述,所以你可以忘记你刚才读到的一切。只要键入货物添加typed-builder就可以在你的代码中享受类型安全的构建器。它还具有defaults和option into注解的功能,还有一个 strip_option 注解,允许你有一个setter方法,总是接受任何值并设置Some(value)。

 safe-builder-derive  crate也实现了tyestate,但是,通过为每个set/unset字段的组合生成一个impl,它导致代码成倍增长。对于只有三到四个字段的小型构建器来说,这可能仍然是一个可以接受的选择,否则,编译时间的成本可能不值得。

tidy-builder crate与typed-builder基本相同,但它使用~const bool作为tyestate。buildstructor 箱子也受到 typed-builder 的启发,但它使用注释的构造函数而不是结构。builder-pattern 箱子也使用类型状态模式,并允许你注释懒惰的默认值和验证函数。

无疑,未来还会有更多。如果你想在你的代码中使用自动生成的构建器,我认为它们中的大多数都是不错的选择。一如既往,你的里程可能会有所不同。例如,要求对Into参数进行注解,对某些人来说可能是更糟糕的人机工程学,但对其他人来说却降低了复杂性。一些用例需要验证,而另一些用例则没有这个必要。

总结
构建器很好地弥补了Rust中命名参数和可选参数的不足,甚至在编译时和运行时都进行了自动转换和验证。另外,这种模式对大多数开发者来说都很熟悉,所以你的用户会感到很自在。