果然很强:Rust 的 typestate 构建器模式

polarisxu

共 4097字,需浏览 9分钟

 ·

2021-11-23 18:36

今天介绍一个建造者模式升级版。

01 问题

上一篇文章中[1],我已经介绍了构建器模式。这里是 代码片段[2],实现UserBuilderUser结构:

struct User {
    id: String,
    email: String,
    first_name: Option<String>,
    last_name: Option<String>,
}

struct UserBuilder {
    id: String,
    email: String,
    first_name: Option<String>,
    last_name: Option<String>,
}

impl UserBuilder {
    fn new(id: impl Into<String>, email: impl Into<String>) -> Self {
        Self {
            id: id.into(),
            email: email.into(),
            first_name: None,
            last_name: None,
        }
    }

    fn first_name(mut self, first_name: impl Into<String>) -> Self {
        self.first_name = Some(first_name.into());
        self
    }

    fn last_name(mut self, last_name: impl Into<String>) -> Self {
        self.last_name = Some(last_name.into());
        self
    }

    fn build(self) -> User {
        let Self { id, email, first_name, last_name } = self;
        User { id, email, first_name, last_name }
    }
}

impl User {
    fn builder(id: impl Into<String>, email: impl Into<String>) -> UserBuilder {
        UserBuilder::new(id, email)
    }
}

以这种方式使用:

let greyblake = User::builder("13""example@example.com")
    .first_name("Sergey")
    .build();

请注意,该idemail字段是必填的并且没有默认值,因此它们需要被传递给User::builder()函数。不幸的是,这破坏了 builder 的优雅,因为必填字段的名称没有明确绑定到它们的值,并且如果它们是相同类型的,则很容易通过以错误的顺序传递参数,例如:

User::builder("example@example.com""13")

如果我们可以以相同的方式设置所有值,那不是很棒吗?

let greyblake = User::builder()
    .id("13")
    .email("greyblake@example.com")
    .first_name("Sergey")
    .build();

但同时,我们希望保持 API 类型安全,以防万一构建器被滥用来构造无效用户,我们希望看到编译错误。例如,不应允许以下用法,因为缺少 id 字段:

let greyblake = User::builder()
    .email("greyblake@example.com")
    .first_name("Sergey")
    .build();

我们可以实现这种方式吗?答案是肯定的!🦄

注意:这个问题也可以用 newtypes 解决,但今天我们将继续关注构建器。

02 天真(naive)的方法

我们的构建器可能处于以下 4 种状态之一:

  • 新的(缺少 ID 和 email)
  • id 已设置
  • email 已设置
  • 完整的(设置了 id 和 email)

我们可以引入 4 种构建器类型来分别表示每个构建器状态:

  • UserBuilderNew
  • UserBuilderWithEmail
  • UserBuilderWithId
  • UserBuilderComplete

看一个流程的某种状态机:

UserBuilder state machine

将其转换为代码(参见 playgound[3]),它按预期工作:

let greyblake = User::builder()
    .id("13")
    .email("greyblake@example.com")
    .first_name("Sergey")
    .build();

但这个编译不通过:

let greyblake = User::builder()
    .email("greyblake@example.com")   // <-- id is not specified
    .first_name("Sergey")
    .build();

错误信息:

    .build();
     ^^^^^ method not found in UserBuilderWithEmail

UserBuilderWithEmail应该首先通过调用 id() 设置 id 变成 UserBuilderComplete,然后才可以构建用户。

虽然有效,但这种方法不是很好。首先,有很多样板和重复:first_name()last_name() 必须为构建器的每个变体实现 4 次。其次,它不能扩展:如果我们决定添加新的必填字段,样板文件将呈指数增长。

03 泛型构建器

为了消除重复,我们将使构建器通用。特别是,我们将使用一种称为 typestate 的技术。让我在这里引用 Cliffle[4]

Typestates 是一种将状态属性(程序正在处理的动态信息)移动到类型级别(编译器可以提前检查的静态世界)的技术。

我们在这里感兴趣的类型状态的特殊情况是它们可以在编译时强制执行运行时操作顺序的方式。

我们希望,必须调用了 id()email() 之后才能调用 build()

让我们重新定义我们的构建器,使其成为泛型的 IE

struct UserBuilder {
    id: I,
    email: E,
    first_name: Option<String>,
    last_name: Option<String>,
}

IE是类型占位符,将相应地表示 idemail 字段的状态。字段 id 可以设置为字符串或缺失。这同样适用于 email。让我们定义简单的类型来反映这一点:

// types for `id`
struct Id(String);
struct NoId;

// types for `email`
struct Email(String);
struct NoEmail;

所以实际上,我们想要做的是定义一个和以前类似的状态机,但现在使用泛型:

UserBuilder state machine

User::builder()被调用,但 idemail都没有提供时,所以应该返回类型 UserBuilder 的值。

impl User {
    fn builder() -> UserBuilder {
        UserBuilder::new()
    }
}

impl UserBuilder {
    fn new() -> Self {
        Self {
            id: NoId,
            email: NoEmail,
            first_name: None,
            last_name: None,
        }
    }
}

.id()被调用时,无论 email 是什么,我们只设置id字段并保留电子邮件的类型和值而不做任何更改:

//                   +-------- Pay attention ----------+
//                   |                                 |
//                   v                                 |
impl UserBuilder {  //                     v
    fn id(self, id: impl Into<String>) -> UserBuilder {
        let Self { email, first_name, last_name, .. } = self;
        UserBuilder {
            id: Id(id.into()),
            email,
            first_name,
            last_name
        }
    }
}

多亏了泛型,这个实现保证了 2 个潜在的转换:

  • UserBuilder -> UserBuilder
  • UserBuilder -> UserBuilder

我们同样的方式定义.email()

impl UserBuilder {
    fn email(self, email: impl Into) -> UserBuilder {
        let Self { id, first_name, last_name, .. } = self;
        UserBuilder {
            id,
            email: Email(email.into()),
            first_name,
            last_name
        }
    }
}

我们还必须为所有 4 种可能的变体定义.first_name().last_name(),所以我们使用泛型即可:

impl UserBuilder {
    fn first_name(mut self, first_name: impl Into<String>) -> Self {
        self.first_name = Some(first_name.into());
        self
    }

    fn last_name(mut self, last_name: impl Into<String>) -> Self {
        self.last_name = Some(last_name.into());
        self
    }
}

最后剩下的就是定义.build(),当然,我们希望当 id 和 email 都设置时,才为 typeUserBuilder定义它:

impl UserBuilder {
    fn build(self) -> User {
        let Self { id, email, first_name, last_name } = self;
        User {
            id: id.0,
            email: email.0,
            first_name,
            last_name,
        }
    }
}

让我们来测试一下。以下代码段会按预期编译:

let greyblake = User::builder()
    .id("13")
    .email("greyblake@example.com")
    .first_name("Sergey")
    .build();

但这个会报错:

let greyblake = User::builder()
    .id("13")      // <-- email is missing
    .first_name("Sergey")
    .build();

错误:

15 | struct UserBuilder {
   | ------------------------ method `build` not found for this
...
93 |         .build();
   |          ^^^^^ method not found in `UserBuilder`
   |
   = note: the method was found for
           - `UserBuilder`

我们使用了描述性类型名称(NoEmailEmail),生成的错误消息必须足以理解错误原因并帮助找出如何修复它(对于此示例,需要设置 email 值)。

在 Rust Playgroupd 查看 完整代码 [5]

已经完成。但这种方法也不是没有缺点:

  • 添加新的必填字段将需要在所有地方扩展泛型构建器类型。
  • 如果使用了错误的命名,错误消息可能会很模糊。

因此,请明智地进行权衡。

04 类型生成器

在实践中,你可能应该考虑使用 typed-builder [6] crate 而不是手动写构建器:

use typed_builder::TypedBuilder;

#[derive(Debug, TypedBuilder)]
struct User {
    id: String,
    email: String,
    #[builder(default)]
    first_name: Option<String>,
    #[builder(default)]
    last_name: Option<String>,
}

fn main() {
    let greyblake = User::builder()
        .id("13".into())
        .email("greyblake@example.com".into())
        .first_name(Some("Sergey".into()))
        .build();
    dbg!(greyblake);
}

05 总结

在本文中,展示了如何将 typestate构建器模式一起使用,以强调正确使用第二个模式。

typestate 的一些常见用例:

  • 强制执行函数调用顺序
  • 禁止一个函数被调用两次
  • 互斥函数调用
  • 要求一个函数总是被调用

原文链接:https://www.greyblake.com/blog/2021-10-25-builder-with-typestate-in-rust/

参考资料

[1]

上一篇文章中: https://www.greyblake.com/blog/2021-10-19-builder-pattern-in-rust/

[2]

代码片段: https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=0ed8857ae64336eff5d886935eb20f30

[3]

参见 playgound: https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=4f9fce1509b9ef2e71b1229f60e87bc3

[4]

Cliffle: http://cliffle.com/blog/rust-typestate/#what-are-typestates

[5]

完整代码 : https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=b831f1158f1eebb9cfbaf7bc13fde65f

[6]

typed-builder : https://crates.io/crates/typed-builder




往期推荐


我是 polarisxu,北大硕士毕业,曾在 360 等知名互联网公司工作,10多年技术研发与架构经验!2012 年接触 Go 语言并创建了 Go 语言中文网!著有《Go语言编程之旅》、开源图书《Go语言标准库》等。


坚持输出技术(包括 Go、Rust 等技术)、职场心得和创业感悟!欢迎关注「polarisxu」一起成长!也欢迎加我微信好友交流:gopherstudio


浏览 47
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报