Rust 设计模式
设计模式(design pattern)是解决反复出现的问题的一种成熟做法。模式不是装上就能用的库,更像是菜谱:“遇到这种问题时,就把代码写成这个样子”。
很多 Java 或 C# 里的模式,到了 Rust 里样子会变,甚至根本用不着。Rust 有自己偏爱的一套模式,它们建立在强大的类型系统、枚举、trait 和所有权之上。其中大多数模式背后的核心思想都一样:让编译器替你抓住错误,不让它们跑到用户那里。下面每一节都先讲问题,再展示解决它的模式。
Newtype:含义不同,类型就不同
Section titled “Newtype:含义不同,类型就不同”问题。 一个普通的 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()));}输出:
塔安全吗?true900 英尺是 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)。
Builder:选项很多,结果清楚
Section titled “Builder:选项很多,结果清楚”问题。 有些结构体字段很多,而且大多是可选的。像 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().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”这样的调用。
From 和 Into:更友好的函数参数
Section titled “From 和 Into:更友好的函数参数”问题。 你的函数接收 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
- Rust Design Patterns(英文):社区编写的免费在线书,收录了 Rust 的惯用法、设计模式和反模式,包括 newtype、builder 和 RAII 守卫。
- 实现一个面向对象设计模式 — Rust 程序设计语言:先用 trait 对象实现状态模式,再改用把状态编码成类型的方式重新实现。
- Rust API Guidelines(英文):官方给出的建议,教你设计友好、地道的 API(类型转换、builder、命名)。
- RAII — 通过例子学 Rust:所有权和
Drop怎样带来自动清理。 - The Typestate Pattern in Rust — Cliff L. Biffle(英文):一篇更深入、可读性很强的 typestate 指南,有更多示例。