不少来到 Rust 世界的开发者都有面向对象编程经验,比如曾经写过Java、C# 或 C++程序。学习完Rust语法以后,很自然地应用熟悉的设计模式,例如单例模式(Singleton)、工厂模式(Factory)或观察者模式(Observer)。
设计模式让代码变得更加灵活、可复用、更容易维护。作为一种共同语言,它们能使开发者快速理解代码的结构和意图。
Rust 同样拥有设计模式,但它们通常呈现不同的形式。Rust 不依赖继承、共享可变状态或运行时多态,而是偏向于使用所有权(ownership)、借用(borrowing)、trait、枚举(enum)、组合(composition)以及类型系统(type system)。与经典设计模式相比,Rust的解决方式更加简单有效,这让经典设计模式变得没有必要。
我们将在本文探索经验丰富的 Rust 工程师所使用的惯用写法(idioms)和模式,编写正确、表达清晰且易于维护的代码。
我们不应该在整个代码库中到处使用像 String、u32或 usize这样的基础类型,应该将它们包装成一个专门类型,用来表示我们的业务领域概念。
// 没有使用 newtypes:fn create_user(username: String, password: String) {// ...}// 无法阻止调用者意外交换参数顺序:create_user(password, username); // 可以编译通过!
Newtype 会包装一个已有类型,创建一个全新的独立类型。因此,我们无法在编译阶段意外混用它们。在运行时,Newtype 不会带来额外开销,它仍然会被表示为内部所包装的那个基础类型。
struct Username(String);struct Password(String);fn create_user(username: Username, password: Password) {// ...}// 如果这样调用:create_user(password, username);// ❌ 编译错误// 即使 Username 和 Password 内部都包含一个 String,// Rust 仍然会把它们视为不同类型,// 从而在编译阶段阻止错误混用。
创建专用类型是一个好的开始,但它不能保证存储的值一定有效。
struct Email(String);// 没有任何东西阻止有人创建一个无效邮箱:let email = Email("not an email".to_string());
智能构造器确保每一个值在创建时都是有效的。
它强制所有数据经过验证步骤,只有满足规定规则(即不变量 invariants)的情况下,才允许创建该类型。
pub struct Email(String);impl Email {pub fn new(value: String) -> Result<Self, &'static str> {if value.contains('@') {Ok(Self(value))} else {Err("invalid email address")}}}// 现在,每一个 Email 实例都可以保证是有效的:let email = Email::new("alice@example.com".to_string())?;
3. Builder 模式:逐步构建复杂类型
随着类型不断增长,它的构造函数很快变得难以阅读和维护。
struct ServerConfig {host: String,port: u16,workers: usize,tls: bool,}// 构造函数:let config = ServerConfig::new("localhost".into(),8080,4,true,);// 除非我们记得参数顺序,否则很难知道每个值代表什么。
Builder 模式允许我们通过清晰的字段名称,一步一步设置每个字段,使代码更容易阅读,也减少出错概率。
let config = ServerConfig::builder().host("localhost").port(8080).workers(4).tls(true).build()?;
在许多编程语言中,开发者必须记住手动释放资源,例如文件、套接字(socket)、数据库连接或锁。
如果忘记释放这些资源,可能会导致资源泄漏(resource leaks)、死锁(deadlocks)或其他难以发现的问题。
Rust 采用了一种不同的方法,通过 RAII(Resource Acquisition Is Initialization,资源获取即初始化)来管理资源。
当一个拥有资源的值离开作用域(scope)时,该资源会自动释放。
use std::fs::File;fn main() -> std::io::Result<()> {let file = File::open("config.toml")?;// 使用文件...Ok(())} // File 在这里会被自动关闭。
use std::sync::Mutex;let counter = Mutex::new(0);{let mut value = counter.lock().unwrap();*value += 1;} // 锁会在这里自动释放。
Rust 没有类继承机制。Rust 不鼓励建立复杂的继承层级,而是鼓励将多个小型、专注的类型组合成更大的类型。
例如,不是创建一个基础 Animal类,然后让其他类型继承:
Animal├── Dog├── Cat└── Bird
在 Rust 中,我们使用 trait 和组合来表达共享行为。
与其继承一个基类,不如让每个类型直接实现它所需要的行为。
trait Speak {fn speak(&self);}struct Dog;impl Speak for Dog {fn speak(&self) {println!("Woof!");}}
如果需要一个拥有额外能力的类型呢?与其扩展一个基类,不如组合可复用的组件。
trait Speak {fn speak(&self);}trait Fly {fn fly(&self);}struct Bird;// Bird 获得说话能力:impl Speak for Bird {fn speak(&self) {println!("Chirp!");}}Bird 同时获得飞行能力:impl Fly for Bird {fn fly(&self) {println!("Flying!");}}
在面向对象语言中,策略模式(Strategy Pattern)通常通过抽象基类(abstract base class)或者接口(interface)实现。
在 Rust 中,trait 提供了同样的灵活性,并且更加符合 Rust 的设计方式。
trait Compressor {// 压缩输入数据,并返回压缩后的结果fn compress(&self, data: &[u8]) -> Vec<u8>;}// 使用 Gzip 算法的压缩实现:struct Gzip;//使用 Brotli 算法的压缩实现:struct Brotli;// Gzip 专用压缩逻辑:impl Compressor for Gzip {fn compress(&self, data: &[u8]) -> Vec<u8> {// 实际的 gzip 压缩逻辑写在这里vec![]}}// Brotli 专用压缩逻辑:impl Compressor for Brotli {fn compress(&self, data: &[u8]) -> Vec<u8> {// 实际的 brotli 压缩逻辑写在这里vec![]}}// 一个可以接受任何实现了Compressortrait 类型的通用函数:fn save<C: Compressor>(compressor: C, data: &[u8]) {let compressed = compressor.compress(data);// 存储压缩数据...}// 无需修改save()函数,就可以使用不同实现:save(Gzip, data);save(Brotli, data);
给一个自己不拥有的类型添加新功能,是一种非常常见的需求。在很多语言中,我们可能会使用扩展方法(extension methods)或者子类化(subclassing)。
Rust 通过扩展 trait 解决这个问题,它允许我们在不修改原始类型的情况下,为已有类型添加新的方法。
trait UsernameExt {// 给字符串添加一个自定义方法fn is_valid_username(&self) -> bool;}// 为已有的 str 类型实现这个新方法:impl UsernameExt for str {fn is_valid_username(&self) -> bool {self.len() >= 3 && self.chars().all(|c| c.is_alphanumeric())}}// 现在,str 类型拥有了 is_valid_username 方法:assert!("alice123".is_valid_username());assert!(!"a!".is_valid_username());
许多对象只有在达到某个特定状态之后,才能执行某些操作。例如:
网络连接必须建立之后,才能发送数据;
文件必须打开之后,才能读取;
用户必须完成身份验证之后,才能访问受保护资源。
// 在许多语言中,这些规则通过运行时检查实现// 如果忘记检查,就可能导致运行时错误if connection.is_connected() {connection.send(data);}
Typestate 模式将这些规则移到了类型系统。它不再是在运行时检查一个对象是否处于正确状态,而是让编译器确保只有合法的状态转换才能编译通过。这样,在代码真正运行之前,就可以阻止无效使用。
// 定义不同状态:struct Disconnected;struct Connected;// 将连接状态编码到类型中:struct Connection<State> {state: State,}impl Connection<Disconnected> {// 只有断开的连接才能建立连接fn connect(self) -> Connection<Connected> {Connection {state: Connected,}}}impl Connection<Connected> {// 只有已连接的对象才能发送数据fn send(&self, data: &[u8]) {// 发送数据...}}// 初始状态为断开:let conn = Connection {state: Disconnected,};// 在 connect() 之前调用 send() 将无法通过编译。// 转换到连接状态:let conn = conn.connect();// ✅ 允许,因为连接现在已经建立conn.send(b"Hello");// 与其依赖运行时检查或者开发者自觉遵守规则,// 不如让编译器直接替我们执行这些约束。
Rust 通常遵循这样的规则:
可以拥有多个不可变引用;
或者拥有一个可变引用。
这个规则可以防止数据竞争(data races),并保证数据访问安全。
内部可变性(Interior Mutability)允许我们即使通过不可变引用,也能修改某个值,同时持有 Rust 的安全保证。
use std::cell::RefCell;struct Counter {// 使用 RefCell 包装值,使其支持内部可变性value: RefCell, }impl Counter {fn increment(&self) {// 在运行时可变借用这个值let mut value = self.value.borrow_mut();// 修改内部值,即使我们只有 `&self`*value += 1;}fn get(&self) -> u32 {// 在运行时不可变借用这个值*self.value.borrow()}}// 虽然 counter 本身是不可变的:let counter = Counter {value: RefCell::new(0),};// 但仍然可以修改它内部的状态:counter.increment();counter.increment();//读取最终结果:assert_eq!(counter.get(), 2);
选择正确的类型
Rust 根据不同使用场景提供了多种选择:
Cell→ 用于简单的 Copy类型。
RefCell→ 用于单线程环境下的运行时借用检查。
Mutex→ 用于线程安全的共享修改。
RwLock→ 当需要多个读取者以及偶尔的写入者时使用。
每一种方式都代表了灵活性和安全性之间的权衡。
?运算符:传递错误,而不是隐藏错误(Propagate Errors, Don't Hide Them)错误处理是 Rust 的核心组成部分之一,最符合 Rust 风格的特性之一就是 ?运算符。
相比嵌套很深的 match表达式,?可以让我们把错误传递给调用者,同时保持正常执行路径清晰易读。
// 没有使用 ?:fn read_config() -> Result<String, io::Error> {// 尝试读取文件let contents = match std::fs::read_to_string("config.toml") {// 成功时继续Ok(contents) => contents,// 出错时提前返回Err(err) => return Err(err),};// 返回文件内容Ok(contents)}
// 使用 ?:fn read_config() -> Result<String, io::Error> {// 如果读取成功,contents 获取值// 如果失败,错误会立即返回let contents = std::fs::read_to_string("config.toml")?;// 返回文件内容Ok(contents)}
impl Trait与 dyn Trait:选择静态派发还是动态派发(Choose Static or Dynamic Dispatch)Trait 是 Rust 用来表达共享行为的方式,使用 trait 有两种常见方式:
impl Trait(或者泛型)→ 用于静态派发(static dispatch)
dyn Trait→ 用于动态派发(dynamic dispatch)
两者之间的选择会影响性能、灵活性以及 API 设计。
// impl Trait:编译期多态// 类型在编译时确定// 更快,但无法运行时改变类型trait Logger {fn log(&self, message: &str);}fn process_static(logger: impl Logger) {// Rust 在编译阶段知道这里的具体类型logger.log("Processing...");}
// dyn Trait:运行时多态// 类型可以在运行时变化// 更灵活,但使用动态派发trait Logger {fn log(&self, message: &str);}fn process_dynamic(logger: &dyn Logger) {// Rust 在运行时决定调用哪个实现logger.log("Processing...");}
Rust 鼓励通过 From和 Into trait 在不同类型之间进行显式转换。
相比编写下面的转换方法:
User::from_string(s) // 手动定义的构造函数形式方法// 或user.to_user() // 自定义类型转换方法
我们应该实现 Rust 的转换 trait(From和 Into)。这样,我们的类型能够自然地融入 Rust 语言生态,并遵循 Rust 惯用方式,而不需要编写类似 to_user()或 from_string()这样的自定义转换函数。
struct User {name: String,}// 使用 From 实现标准转换:impl From<String> for User {fn from(name: String) -> Self {Self { name }}}// 显式使用 From:let user = User::from(name);// 使用更加方便的 Into:let user: User = name.into();
Rust 新手在处理 Option时,经常首先想到 match。虽然 match功能强大,但在很多情况下,使用组合器(combinators)会让代码更加简单,也更具有表达力。
例如,不使用组合器时,需要完整写出 match:
let username = match user {Some(user) => user.name,None => "Guest".to_string(),};
我们可以使用 map配合 unwrap_or,获得更加简洁、易读的方式:
let username = user// 如果 user 是 Some,提取 name 字段.map(|user| user.name)// 如果 user 是 None,使用 "Guest" 作为默认值.unwrap_or("Guest".to_string());
这种写法关注的是应该如何处理这个值,而不是应该如何进行分支控制。
Rust 提供了一系列处理 Option的方法:
map → 如果值存在,则转换这个值。
and_then → 链式执行同样返回 Option的操作。
filter → 只有满足条件时才保留这个值。
unwrap_or → 提供一个默认值。
ok_or → 将 Option转换为 Result。
符合 Rust 惯用方式的一个重要特点是:类型应该表现得像其他 Rust 开发者期待的那样。
与其为每一种操作编写自定义方法,不如实现描述类型行为的标准 trait。这样可以让类型更容易使用、更容易组合,并且更加符合 Rust 代码库的习惯。
例如,不要暴露一个自定义比较方法:
struct UserId(u64);impl UserId {fn equals(&self, other: &Self) -> bool {self.0 == other.0}}
而应该实现 PartialEq:
struct UserId(u64);
这样,我们的类型就可以自然地与 Rust 语言其他部分配合:
if user1 == user2 {println!("Same user");}
常见需要派生的 Trait(Common Traits to Derive)
Debug → 用于打印值,方便调试。
Clone → 创建明确的副本。
Copy → 复制轻量级值类型。
PartialEq / Eq → 比较值是否相等。
PartialOrd / Ord → 对值进行排序。
Hash → 用作 HashMap和 HashSet的键。
Default → 创建合理的默认值。
Rust 的核心设计原则之一就是零成本抽象(zero-cost abstractions):高层次的代码不应该比手写等价的底层代码产生更大运行时开销。
这使我们能够编写具有良好表达力、易于维护的代码,同时不会牺牲性能。
例如,下面两种写法:
// 版本 1:显式循环let mut sum = 0u64;for i in 0..1_000_000u64 {if i % 2 == 0 {sum += i * i;}}
以及:
// 版本 2:迭代器链let sum: u64 = (0..1_000_000u64).filter(|n| n % 2 == 0).map(|n| n * n).sum();
这两个版本最终会编译成相近的机器代码。迭代器版本并不会更慢,因为 Rust 会通过零成本抽象,将它优化成类似的循环结构。
这意味着我们应该优先考虑代码清晰度,而不是过早优化(premature optimization)。迭代器链通常应该作为默认选择。只有当性能分析(profiling)表明确实存在性能问题时,我们才应该切换到手写循环或者更底层的优化方式。
总结(Final Thoughts)
设计模式通常是区分初学者和经验丰富工程师的重要因素,Rust 也不例外。但是,Rust 真正重要的模式往往有所不同。经验丰富的 Rust 工程师会拥抱 Rust 的惯用方式:
所有权(ownership)
Trait
枚举(enums)
组合(composition)
类型系统(type system)
最终产生的代码更加安全、更具表达力,也更容易维护。
本文探索了一些最重要的 Rust 惯用模式和技术,它们有一个共同理念:使用编译器阻止 bug,而不是等程序运行后才发现 bug。
编辑:场长
参考
https://medium.com/@bektiaw/15-idiomatic-rust-every-engineer-should-know-0dd7c2b59eca
本篇文章为 @ 场长 创作并授权 21CTO 发布,未经许可,请勿转载。
内容授权事宜请您联系 webmaster@21cto.com或关注 21CTO 微信公众号。
该文观点仅代表作者本人,21CTO 平台仅提供信息存储空间服务。
请扫描二维码,使用微信支付哦。