17611538698
webmaster@21cto.com

15条工程师需要知道的Rust编程思想

编程语言 0 13 41分钟前
图片

用 Rust 的方式编写代码,而不仅仅是写出 Rust 代码


不少来到 Rust 世界的开发者都有面向对象编程经验,比如曾经写过Java、C# 或 C++程序。学习完Rust语法以后,很自然地应用熟悉的设计模式,例如单例模式(Singleton)、工厂模式(Factory)或观察者模式(Observer)。

设计模式让代码变得更加灵活、可复用、更容易维护。作为一种共同语言,它们能使开发者快速理解代码的结构和意图。

Rust 同样拥有设计模式,但它们通常呈现不同的形式。Rust 不依赖继承、共享可变状态或运行时多态,而是偏向于使用所有权(ownership)、借用(borrowing)、trait、枚举(enum)、组合(composition)以及类型系统(type system)。与经典设计模式相比,Rust的解决方式更加简单有效,这让经典设计模式变得没有必要。

我们将在本文探索经验丰富的 Rust 工程师所使用的惯用写法(idioms)和模式,编写正确、表达清晰且易于维护的代码。

1. Newtype 模式:让无效状态无法被表示


我们不应该在整个代码库中到处使用像 Stringu32usize这样的基础类型,应该将它们包装成一个专门类型,用来表示我们的业务领域概念。

// 没有使用 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 仍然会把它们视为不同类型,// 从而在编译阶段阻止错误混用。

2. 智能构造器(Smart Constructors):在创建时保证不变量


创建专用类型是一个好的开始,但它不能保证存储的值一定有效。

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()?;

4. RAII:让所有权管理资源(Let Ownership Manage Resources)


在许多编程语言中,开发者必须记住手动释放资源,例如文件、套接字(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;// 锁会在这里自动释放。

5. 组合优于继承(Composition over Inheritance)


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!");    }}

6. Strategy 模式:使用 Trait 实现多态(Use Traits for Polymorphism)


在面向对象语言中,策略模式(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<CCompressor>(compressor: C, data: &[u8]) {    let compressed = compressor.compress(data);    // 存储压缩数据...}// 无需修改 save()函数,就可以使用不同实现:save(Gzip, data);save(Brotli, data);

7. 扩展 Trait(Extension Traits):无需修改类型即可扩展功能


给一个自己不拥有的类型添加新功能,是一种非常常见的需求。在很多语言中,我们可能会使用扩展方法(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());

8. Typestate 模式:将状态编码进类型系统(Encode State in the Type System)


许多对象只有在达到某个特定状态之后,才能执行某些操作。例如:

  • 网络连接必须建立之后,才能发送数据;

  • 文件必须打开之后,才能读取;

  • 用户必须完成身份验证之后,才能访问受保护资源。


// 在许多语言中,这些规则通过运行时检查实现// 如果忘记检查,就可能导致运行时错误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");// 与其依赖运行时检查或者开发者自觉遵守规则,// 不如让编译器直接替我们执行这些约束。


9. 内部可变性(Interior Mutability):通过共享引用进行修改


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 → 当需要多个读取者以及偶尔的写入者时使用。


每一种方式都代表了灵活性和安全性之间的权衡。

10. ?运算符:传递错误,而不是隐藏错误(Propagate Errors, Don't Hide Them)


错误处理是 Rust 的核心组成部分之一,最符合 Rust 风格的特性之一就是 ?运算符。

相比嵌套很深的 match表达式,?可以让我们把错误传递给调用者,同时保持正常执行路径清晰易读。

// 没有使用 ?:fn read_config() -> Result<Stringio::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<Stringio::Error> {    // 如果读取成功,contents 获取值    // 如果失败,错误会立即返回    let contents = std::fs::read_to_string("config.toml")?;    // 返回文件内容    Ok(contents)}

11. impl Traitdyn 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...");}

12. From/Into 生态:设计符合习惯的 API(Design Ergonomic APIs)


Rust 鼓励通过 FromInto trait 在不同类型之间进行显式转换。

相比编写下面的转换方法:

User::from_string(s)  // 手动定义的构造函数形式方法// 或user.to_user()  // 自定义类型转换方法

我们应该实现 Rust 的转换 trait(FromInto)。这样,我们的类型能够自然地融入 Rust 语言生态,并遵循 Rust 惯用方式,而不需要编写类似 to_user()from_string()这样的自定义转换函数。

struct User {    name: String,}// 使用 From 实现标准转换:impl From<Stringfor User {    fn from(name: String) -> Self {        Self { name }    }}// 显式使用 From:let user = User::from(name);// 使用更加方便的 Into:let user: User = name.into();

13. Option 组合器(Option Combinators):转换值,而不是控制流程


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());

这种写法关注的是应该如何处理这个值,而不是应该如何进行分支控制。

常见组合器(Common Combinators)


Rust 提供了一系列处理 Option的方法:

  • map → 如果值存在,则转换这个值。

  • and_then → 链式执行同样返回 Option的操作。

  • filter → 只有满足条件时才保留这个值。

  • unwrap_or → 提供一个默认值。

  • ok_or → 将 Option转换为 Result


14. 有意识地实现标准 Trait(Implement Standard Traits Deliberately)


符合 Rust 惯用方式的一个重要特点是:类型应该表现得像其他 Rust 开发者期待的那样。

与其为每一种操作编写自定义方法,不如实现描述类型行为的标准 trait。这样可以让类型更容易使用、更容易组合,并且更加符合 Rust 代码库的习惯。

例如,不要暴露一个自定义比较方法:

struct UserId(u64);impl UserId {    fn equals(&self, other: &Self) -> bool {        self.0 == other.0    }}

而应该实现 PartialEq

#[derive(PartialEq, Eq)]struct UserId(u64);

这样,我们的类型就可以自然地与 Rust 语言其他部分配合:

if user1 == user2 {    println!("Same user");}
常见需要派生的 Trait(Common Traits to Derive)

  • Debug → 用于打印值,方便调试。

  • Clone → 创建明确的副本。

  • Copy → 复制轻量级值类型。

  • PartialEq / Eq → 比较值是否相等。

  • PartialOrd / Ord → 对值进行排序。

  • Hash → 用作 HashMapHashSet的键。

  • Default → 创建合理的默认值。


15. 零成本抽象(Zero-Cost Abstractions):不要为未使用的东西付出成本(Don’t Pay for What You Don’t Use)


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

评论

我要赞赏作者

请扫描二维码,使用微信支付哦。

分享到微信