跳转到内容

Rust 设计模式

设计模式(design pattern)是解决反复出现的问题的一种成熟做法。模式不是装上就能用的库,更像是菜谱:“遇到这种问题时,就把代码写成这个样子”。

很多 Java 或 C# 里的模式,到了 Rust 里样子会变,甚至根本用不着。Rust 有自己偏爱的一套模式,它们建立在强大的类型系统、枚举、trait 和所有权之上。其中大多数模式背后的核心思想都一样:让编译器替你抓住错误,不让它们跑到用户那里。下面每一节都先讲问题,再展示解决它的模式。

问题。 一个普通的 f64 可以表示任何东西:米、英尺、秒、欧元。如果一个函数接收以米为单位的 f64,你却传进去英尺,编译器帮不上忙,因为两者都只是 f64。(1999 年,NASA 就是因为这种单位混用,弄丢了火星气候探测者号。)

模式。 把这个值包进一个只有一个字段的小结构体里。这样 Meters 和 Feet 就是不同的类型,Rust 不会让你把它们搞混:

#[derive(Debug, Clone, Copy)]
struct Meters(f64);
#[derive(Debug, Clone, Copy)]
struct Feet(f64);
impl Feet {
fn to_meters(self) -> Meters {
Meters(self.0 * 0.3048)
}
}
fn safe_altitude(height: Meters) -> bool {
height.0 >= 300.0
}
fn main() {
let tower = Meters(330.0);
let plane = Feet(900.0);
println!("塔安全吗?{}", safe_altitude(tower));
println!("900 英尺是 {:.1} 米", plane.to_meters().0);
println!("飞机安全吗?{}", safe_altitude(plane.to_meters()));
}

输出:

塔安全吗?true
900 英尺是 274.3 米
飞机安全吗?false
▶ 动手试一试

如果你忘了转换,写成了 safe_altitude(plane),程序根本编译不过:

error[E0308]: mismatched types
--> src/main.rs:13:34
|
13 | println!("{}", safe_altitude(plane));
| ------------- ^^^^^ expected `Meters`, found `Feet`
| |
| arguments to this function are incorrect

编译器说:类型不匹配,这里期望的是 Meters,拿到的却是 Feet。newtype 在运行时没有任何开销:在内存里,Meters 就是一个 f64。它适合用在 ID(UserId(u32) 和 OrderId(u32))、单位和经过验证的字符串上。它还能帮你绕开孤儿规则(见高级 trait)。

问题。 有些结构体字段很多,而且大多是可选的。像 Email::new("[email protected]", "Hi", "", vec![], false) 这样的构造函数很难读:这个 false 到底是哪个设置?要是地址漏写了或者写错了,又该怎么办?

模式。 用一个单独的 builder(构建器)结构体,通过一个个带名字的小方法收集设置。每个方法都返回 builder 本身,所以调用可以链式写下去。最后由 build() 检查所有内容,并返回一个 Result:

struct Email {
to: String,
subject: String,
body: String,
urgent: bool,
}
#[derive(Default)]
struct EmailBuilder {
to: Option<String>,
subject: Option<String>,
body: String,
urgent: bool,
}
impl EmailBuilder {
fn new() -> Self {
EmailBuilder::default()
}
fn to(mut self, address: &str) -> Self {
self.to = Some(address.to_string());
self
}
fn subject(mut self, subject: &str) -> Self {
self.subject = Some(subject.to_string());
self
}
fn body(mut self, text: &str) -> Self {
self.body = text.to_string();
self
}
fn urgent(mut self) -> Self {
self.urgent = true;
self
}
fn build(self) -> Result<Email, String> {
let to = self.to.ok_or("邮件需要一个收件人")?;
if !to.contains('@') {
return Err(format!("“{}”不是邮箱地址", to));
}
let subject = self.subject.unwrap_or_else(|| String::from("(无主题)"));
Ok(Email { to, subject, body: self.body, urgent: self.urgent })
}
}
fn show(result: Result<Email, String>) {
match result {
Ok(e) => println!("准备就绪:to={} subject={} body={:?} urgent={}", e.to, e.subject, e.body, e.urgent),
Err(msg) => println!("错误:{}", msg),
}
}
fn main() {
let email = EmailBuilder::new()
.subject("一起吃午饭?")
.body("12 点吃披萨?")
.urgent()
.build();
show(email);
show(EmailBuilder::new().to("[email protected]").build());
show(EmailBuilder::new().subject("你好").build());
show(EmailBuilder::new().to("ana.example.com").build());
}

输出:

准备就绪:[email protected] subject=一起吃午饭? body="12 点吃披萨?" urgent=true
准备就绪:[email protected] subject=(无主题) body="" urgent=false
错误:邮件需要一个收件人
错误:“ana.example.com”不是邮箱地址
▶ 动手试一试

每个设置方法都接收 mut self(拿走 builder 的所有权),改一个字段,再把 builder 交回去。链式调用就是这样成立的。调用者只需设置自己关心的内容,方法名说明了每个值的含义,而 build() 是唯一检查规则的地方。在 build() 里,ok_or 把“没有收件人”的 None 变成 Err,? 则提前返回这个错误。很多 crate 里都能见到 builder,比如 std::thread::Builder 和 std::process::Command(见环境变量与进程)。

Default 和结构体更新语法:轻量的选择

Section titled “Default 和结构体更新语法:轻量的选择”

问题。 builder 要写不少代码。如果你的结构体没有需要检查的规则,只有一些合理的初始值,用 builder 就有点小题大做了。

模式。 用合适的初始值实现 Default,再用结构体更新语法 .. 只修改你关心的字段:

#[derive(Debug, Clone)]
struct ServerConfig {
host: String,
port: u16,
workers: u32,
verbose: bool,
}
impl Default for ServerConfig {
fn default() -> Self {
ServerConfig {
host: String::from("127.0.0.1"),
port: 8080,
workers: 4,
verbose: false,
}
}
}
fn main() {
let dev = ServerConfig { verbose: true, ..Default::default() };
let prod = ServerConfig {
host: String::from("0.0.0.0"),
workers: 16,
..ServerConfig::default()
};
// 从另一份配置出发,而不是从默认值出发:
let staging = ServerConfig { port: 9090, ..prod.clone() };
println!("{:?}", dev);
println!("{:?}", prod);
println!("{:?}", staging);
}

输出:

ServerConfig { host: "127.0.0.1", port: 8080, workers: 4, verbose: true }
ServerConfig { host: "0.0.0.0", port: 8080, workers: 16, verbose: false }
ServerConfig { host: "0.0.0.0", port: 9090, workers: 16, verbose: false }
▶ 动手试一试

..something 的意思是“我没列出的字段,全都从 something 里拿”。为什么要写 prod.clone()?因为结构体更新语法会移动剩下的字段,而 host 是 String。不 clone 的话,prod 会被部分移动,之后就没法再打印它了。一个好用的原则:如果字段值怎么组合都没问题,就用 Default + ..;如果有些组合是无效的,就用 builder。

Typestate:在编译时检查的状态机

Section titled “Typestate:在编译时检查的状态机”

问题。 有些事情必须按顺序来。一个网络请求得先有 URL,才能发送。用普通结构体的话,你只能在运行时检查(“URL 设置了吗?没设置就返回错误”),然后指望每个调用者都处理了这个错误。

模式。 让状态成为类型的一部分。Request<NoUrl> 干脆就没有 send 方法。只有设置了 URL,它才会变成拥有 send 方法的 Request<HasUrl>:

// 两种状态。它们不存数据(或者只存这个状态需要的数据)。
struct NoUrl;
struct HasUrl(String);
struct Request<U> {
url: U,
headers: Vec<(String, String)>,
}
impl Request<NoUrl> {
fn new() -> Self {
Request { url: NoUrl, headers: Vec::new() }
}
// 改变类型:Request<NoUrl> -> Request<HasUrl>
fn url(self, url: &str) -> Request<HasUrl> {
Request { url: HasUrl(url.to_string()), headers: self.headers }
}
}
// 任何状态下都能用
impl<U> Request<U> {
fn header(mut self, key: &str, value: &str) -> Self {
self.headers.push((key.to_string(), value.to_string()));
self
}
}
// 只有设置了 URL 之后才能用
impl Request<HasUrl> {
fn send(self) -> String {
format!("GET {},带 {} 个请求头", self.url.0, self.headers.len())
}
}
fn main() {
let response = Request::new()
.header("Accept", "text/html")
.url("https://example.com")
.header("User-Agent", "rust-tutorial")
.send();
println!("{}", response);
}

输出:

GET https://example.com,带 2 个请求头
▶ 动手试一试

现在试试不设置 URL 就发送,写成 Request::new().send(),编译器会拦住你:

error[E0599]: no method named `send` found for struct `Request<NoUrl>` in the current scope
--> src/main.rs:22:35
|
22 | let response = Request::new().send();
| ^^^^ method not found in `Request<NoUrl>`
|
= note: the method was found for `Request<HasUrl>`

报错的意思是:Request<NoUrl> 上找不到 send 方法,这个方法只存在于 Request<HasUrl> 上。只要程序能编译通过,这个 bug 就不可能存在。没有运行时检查,也没有额外开销:NoUrl 占 0 个字节。因为 url() 按值接收 self,旧的 Request<NoUrl> 会被用掉,没人能不小心继续使用“之前”的状态。这是对泛型的一种巧妙用法,值得在 Playground 里改一改代码,亲手感受一下它的工作方式。

RAII 和 Drop 守卫:忘不掉的清理工作

Section titled “RAII 和 Drop 守卫:忘不掉的清理工作”

问题。 资源用完需要清理:关闭文件、释放锁、删除临时文件夹、打印“完成”。如果清理是一个你必须记得调用的函数,总会有人忘记,尤其是在提前 return 或出错的时候。

模式。 把清理工作放进 Drop。当一个值离开作用域时,Rust 会自动调用 drop,而且离开的每一条路径都会调用:正常结束、提前 return、?,甚至 panic。这叫作 RAII(“Resource Acquisition Is Initialization”,资源获取即初始化:获得资源和拥有一个值是同一步)。一个唯一职责就是清理的值,叫作守卫(guard):

struct Guard {
name: String,
}
impl Guard {
fn new(name: &str) -> Guard {
println!("开始:{}", name);
Guard { name: name.to_string() }
}
}
impl Drop for Guard {
fn drop(&mut self) {
println!("清理:{}", self.name);
}
}
fn risky(fail: bool) -> Result<(), String> {
let _guard = Guard::new("冒险任务");
if fail {
return Err(String::from("出了点问题")); // 守卫照样会清理
}
println!("任务完成");
Ok(())
}
fn main() {
{
let _block = Guard::new("代码块");
println!("在代码块里");
} // _block 在这里被丢弃
println!("{:?}", risky(false));
println!("{:?}", risky(true));
}

输出:

开始:代码块
在代码块里
清理:代码块
开始:冒险任务
任务完成
清理:冒险任务
Ok(())
开始:冒险任务
清理:冒险任务
Err("出了点问题")
▶ 动手试一试

其实你一直在用这个模式。File 被丢弃时会自己关闭。MutexGuard(来自 mutex.lock(),见并发)被丢弃时会解锁互斥器。Box 被丢弃时会释放它的内存(见智能指针)。所以 Rust 代码里几乎看不到“close”或“free”这样的调用。

问题。 你的函数接收 String,于是手里只有 &str 的调用者每次都得写 String::from("Ana") 或 "Ana".to_string()。又或者你的函数接收 UserId,而调用者手里只有一个普通的 u32。事情不大,但到处都是这种噪音。

模式。 为你的类型实现 From,并在函数里接收 impl Into<T>。这样调用者传入任何能转换过来的值都行:

#[derive(Debug)]
struct UserId(u32);
impl From<u32> for UserId {
fn from(n: u32) -> Self {
UserId(n)
}
}
#[derive(Debug)]
struct User {
id: UserId,
name: String,
}
impl User {
fn new(id: impl Into<UserId>, name: impl Into<String>) -> Self {
User { id: id.into(), name: name.into() }
}
}
fn main() {
let ana = User::new(1, "Ana"); // u32 和 &str
let ben = User::new(UserId(2), String::from("Ben")); // 本来就是正确的类型
println!("{:?}", ana);
println!("{:?}", ben);
}

输出:

User { id: UserId(1), name: "Ana" }
User { id: UserId(2), name: "Ben" }
▶ 动手试一试

要实现的是 From,永远不要直接实现 Into:实现了 From,Into 就白送给你了。错误处理靠的也是同一个技巧:? 会对错误调用 From::from,所以实现了 From<io::Error> for MyError,? 就能替你转换错误(见自定义错误)。这种写法适合不会失败的转换。如果转换可能失败,就用 TryFrom,它会返回一个 Result。

用枚举实现状态机,杜绝非法状态

Section titled “用枚举实现状态机,杜绝非法状态”

问题。 一个订单要从“在购物车里”变成“已支付”,再变成“已发货”。第一次尝试往往是这样写的:

struct Order {
paid: bool,
shipped: bool,
tracking_number: Option<String>,
}

可这样会允许一些荒唐的组合:shipped: true 却 paid: false,或者还没发货的订单却有运单号。每个函数都得检查这些本不该出现的组合。

模式。 让非法状态无法被表示出来。 用一个枚举,每种状态对应一个变体,并且每个变体只带上这个状态真正拥有的数据。状态的转换写成方法,接收旧状态,返回新状态:

#[derive(Debug)]
enum Order {
Cart { items: Vec<String> },
Paid { items: Vec<String>, amount: u32 },
Shipped { tracking: String },
Cancelled,
}
impl Order {
fn pay(self, amount: u32) -> Order {
match self {
Order::Cart { items } => Order::Paid { items, amount },
other => other, // 只有购物车能付款;其他状态保持不变
}
}
fn ship(self, tracking: &str) -> Order {
match self {
Order::Paid { .. } => Order::Shipped { tracking: tracking.to_string() },
other => other,
}
}
fn cancel(self) -> Order {
match self {
Order::Shipped { .. } => self, // 已经来不及取消了
_ => Order::Cancelled,
}
}
fn status(&self) -> String {
match self {
Order::Cart { items } => format!("购物车里有 {} 件商品", items.len()),
Order::Paid { items, amount } => format!("已支付 {},共 {} 件商品", amount, items.len()),
Order::Shipped { tracking } => format!("已发货,运单号 {}", tracking),
Order::Cancelled => String::from("已取消"),
}
}
}
fn main() {
let order = Order::Cart { items: vec![String::from("书"), String::from("笔")] };
println!("{}", order.status());
let order = order.ship("XYZ"); // 还没付款:什么也不会发生
println!("{}", order.status());
let order = order.pay(25).ship("TRACK-42");
println!("{}", order.status());
let order = order.cancel(); // 已经发货了
println!("{}", order.status());
let other = Order::Cart { items: vec![] }.cancel();
println!("{}", other.status());
}

输出:

购物车里有 2 件商品
购物车里有 2 件商品
已发货,运单号 TRACK-42
已发货,运单号 TRACK-42
已取消
▶ 动手试一试

已发货的订单不可能没有运单号,因为 Shipped 变体总是带着一个。购物车不可能有运单号,因为根本没有放它的字段。而且 match 必须覆盖所有变体,所以当你新增一个状态(比如 Refunded)时,编译器会把每个需要更新的地方都指出来。

用枚举还是 trait 对象? 面向对象的语言常常用一个“State”接口,每种状态写一个类。在 Rust 里你也可以用 trait 对象(Box<dyn State>)这么做,Rust 程序设计语言那本书就演示了这种写法。可以这样选择:

  • 枚举:你事先就知道所有状态。你能得到 match 的穷尽检查,所有逻辑也都在一个地方。这是 Rust 里最常见的选择。
  • trait 对象:别人(或者其他 crate)需要在不改你代码的情况下添加新状态。
  • Typestate(前面讲过的):你希望调用顺序错误直接成为编译错误,而不只是在运行时被忽略。它更严格,但状态必须在编译时就确定。

把 let _guard = guard 写成了 let _ = guard。 两者看起来几乎一样,行为却不同。以下划线开头的名字(比如 _guard)会让值一直活到作用域结束。而单独一个 _ 根本不是变量,所以值会被立即丢弃:

struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
println!("guard 被丢弃了");
}
}
fn main() {
let _ = Guard;
println!("正在工作:`let _`");
let _g = Guard;
println!("正在工作:`let _g`");
}

输出:

guard 被丢弃了
正在工作:`let _`
正在工作:`let _g`
guard 被丢弃了
▶ 动手试一试

用了 let _,清理工作就跑到了工作之前,这对锁或计时器来说恰恰是错的。对守卫来说,一定要给变量起个名字。

只因为有这个模式就去用它。 给只有两个字段的结构体写 builder,或者给一个从来不会被误用的东西加上 typestate,只会多出代码,并不会让任何东西更安全。先从简单的写法开始。等你真正感受到某个模式要解决的问题时,再去用它。

  • Newtype(struct Meters(f64))给同样的数据不同的类型,让编译器抓住混用的错误,而且运行时没有任何开销。
  • Builder 提供可读的链式设置,再用一个 build() 做验证并返回 Result。如果没什么需要检查的,Default + .. 是更轻量的选择。
  • Typestate 把状态放进类型里(Request<NoUrl> 和 Request<HasUrl>),调用顺序错了就编译不过。
  • RAII / Drop 守卫在离开作用域的每一条路径上都会自动执行清理。
  • From/Into 让函数通过 impl Into<T> 接受多种输入类型。
  • 枚举适合用来建模状态机,并且能让非法状态无法被表示;只有在别人必须能添加新状态时,才用 trait 对象。

小测验

1. 和普通的 f64 相比,newtype struct Meters(f64) 给你带来了什么?

2. 在本页的 builder 模式里,build() 返回什么?

3. 在 typestate 的例子里,为什么不能对 Request::new() 调用 send()?

4. Drop 守卫的清理工作在什么时候执行?

5. let _ = Guard; 会怎么样?

得分: 0 / 5