简介

参与

如果你有兴趣为这本书做贡献,请查阅 贡献指南.

设计模式

在开发程序时,我们必须解决许多问题。 一个程序可以被看作是一个问题的解决方案。 它也可以被看作是许多问题的解决方案的集合。 所有这些解决方案一起工作,以解决更大的问题。

Rust中的设计模式

有许多问题具有相同的形式。 由于Rust不是面向对象的,设计模式与其他面向对象的编程语言不同。 虽然细节不同,但由于它们具有相同的形式,因此可以用相同的基本方法来解决:

  • 设计模式是解决编写软件时常见问题的方法。
  • 反面模式是解决这些相同的常见问题的方法。然而,在设计模式给我们带来好处的同时,反面模式却带来了更多的问题。
  • 惯常做法是编码时要遵循的准则。 它们是社区的社会规范。 你可以打破它们,但如果你这样做,你应该有一个好的理由。

TODO:提到为什么Rust有点特别--函数式元素、类型系统、借用检查器

Latest commit 9834f57 on 25 Aug 2021

惯常做法

惯常做法是常用的风格和模式,主要由一个社区商定。它们是准则。 编写习惯性代码可以让其他开发者了解正在发生的事情,因为他们熟悉它的形式。

计算机能够理解由编译器生成的机器代码。 因此,编程语言大多对开发者有利。 所以,既然我们有这个抽象层,为什么不好好利用它,让它变得简单?

记住KISS原则: "保持简单,愚蠢"。"大多数系统如果保持简单而不是变得复杂,那么它们的工作效果最好;因此,简单应该是设计的一个关键目标,应该避免不必要的复杂性"。

代码是给人看的,而不是给电脑看的。

Latest commit 2cd70a5 on 22 Jan 2021

使用借用类型作为参数

描述

当你决定为一个函数参数使用哪种参数类型时,使用解引用强制转换的目标可以增加你代码的灵活性。 通过这种方式,该函数将接受更多的输入类型。

这并不限于可切片或胖指针类型。 事实上,你应该总是倾向于使用借用类型而不是借用所有类型。 例如&str而不是&String,&[T]而不是&Vec<T>,以及&T而不是&Box<T>。

使用借用类型,你可以避免已经提供一层间接性的所有类型上的多层间接。例如,String有一层间接,所以&String会有两层间接。我们可以通过使用&str来避免这种情况,并且让&String在函数被调用时强制变成&str。

例子

在这个例子中,我们将说明使用&String作为函数参数与使用&str的一些区别,但这些想法也适用于使用&Vec<T>与使用&[T]或使用&Box<T>与使用&T。

考虑这样一个例子,我们希望确定一个词是否包含三个连续的元音。我们不需要拥有字符串来确定这一点,所以我们将使用一个引用。

代码可能看起来像这样:

fn three_vowels(word: &String) -> bool {
    let mut vowel_count = 0;
    for c in word.chars() {
        match c {
            'a' | 'e' | 'i' | 'o' | 'u' => {
                vowel_count += 1;
                if vowel_count >= 3 {
                    return true
                }
            }
            _ => vowel_count = 0
        }
    }
    false
}

fn main() {
    let ferris = "Ferris".to_string();
    let curious = "Curious".to_string();
    println!("{}: {}", ferris, three_vowels(&ferris));
    println!("{}: {}", curious, three_vowels(&curious));

    // This works fine, but the following two lines would fail:
    // println!("Ferris: {}", three_vowels("Ferris"));
    // println!("Curious: {}", three_vowels("Curious"));

}

这样做没问题,因为我们传递的是一个&String类型作为参数。 如果我们在最后两行取消注释,这个例子就会失败,因为&str类型不会被强制变成&String类型。我们可以通过简单地修改参数的类型来解决这个问题。

例如,如果我们把我们的函数声明改成:

fn three_vowels(word: &str) -> bool {

那么这两个版本都会编译并打印相同的输出。

Ferris: false
Curious: true

但等等,这还不是全部!这个话题还有更多的内容。 很可能你会对自己说:这并不重要,无论如何我都不会使用&'static str作为输入(就像我们使用"Ferris"时那样)。 即使忽略这个特殊的例子,你仍然会发现使用&str会比使用&String更灵活。

现在我们来举个例子,有人给了我们一个句子,我们想确定句子中的任何一个词是否包含三个连续的元音。我们也许应该利用我们已经定义的函数,简单地输入句子中的每个词。

这个例子可能是这样的:

fn three_vowels(word: &str) -> bool {
    let mut vowel_count = 0;
    for c in word.chars() {
        match c {
            'a' | 'e' | 'i' | 'o' | 'u' => {
                vowel_count += 1;
                if vowel_count >= 3 {
                    return true
                }
            }
            _ => vowel_count = 0
        }
    }
    false
}

fn main() {
    let sentence_string =
        "Once upon a time, there was a friendly curious crab named Ferris".to_string();
    for word in sentence_string.split(' ') {
        if three_vowels(word) {
            println!("{} has three consecutive vowels!", word);
        }
    }
}

使用我们声明的参数类型为&str的函数运行这个例子将产生如下结果

curious has three consecutive vowels!

然而,当我们的函数以参数类型&String声明时,这个例子将无法运行。这是因为字符串切片是一个&str,而不是一个&String,后者需要一次内存分配来转换为&String,这不是隐式的,而从String转换为&str开销很低,而且是隐式的。

参见

Latest commit dca0dfd on Dec 16 2021

用format!串联字符串

描述

可以在可变的String上使用push和push_str方法来建立字符串,或者使用其+操作符。 然而,使用format!往往更方便,特别是在有字面和非字面字符串混合的地方。

例子


#![allow(unused)]
fn main() {
fn say_hello(name: &str) -> String {
    // We could construct the result string manually.
    // let mut result = "Hello ".to_owned();
    // result.push_str(name);
    // result.push('!');
    // result

    // But using format! is better.
    format!("Hello {}!", name)
}
}

优势

使用format!通常是组合字符串的最简洁和可读的方式。

劣势

这通常不是组合字符串的最有效的方法——对一个可变的字符串进行一系列push的操作通常是最有效的(特别是当字符串已经被预先分配到预期的大小时)。

Latest commit 5f1425d on 5 Jan 2021

构造器

描述

Rust没有构造器作为语言构造。 相反,惯例是使用一个关联函数new来创建一个对象:


#![allow(unused)]
fn main() {
/// Time in seconds.
///
/// # Example
///
/// ```
/// let s = Second::new(42);
/// assert_eq!(42, s.value());
/// ```
pub struct Second {
    value: u64
}

impl Second {
    // Constructs a new instance of [`Second`].
    // Note this is an associated function - no self.
    pub fn new(value: u64) -> Self {
        Self { value }
    }

    /// Returns the value in seconds.
    pub fn value(&self) -> u64 {
        self.value
    }
}
}

默认构造器

Rust通过Defaulttrait支持默认构造器:


#![allow(unused)]
fn main() {
/// Time in seconds.
///
/// # Example
///
/// ```
/// let s = Second::default();
/// assert_eq!(0, s.value());
/// ```
pub struct Second {
    value: u64
}

impl Second {
    /// Returns the value in seconds.
    pub fn value(&self) -> u64 {
        self.value
    }
}

impl Default for Second {
    fn default() -> Self {
        Self { value: 0 }
    }
}
}

如果所有类型的所有字段都实现了Default,也可以派生出Default,就像对Second那样:


#![allow(unused)]
fn main() {
/// Time in seconds.
///
/// # Example
///
/// ```
/// let s = Second::default();
/// assert_eq!(0, s.value());
/// ```
#[derive(Default)]
pub struct Second {
    value: u64
}

impl Second {
    /// Returns the value in seconds.
    pub fn value(&self) -> u64 {
        self.value
    }
}
}

**注意:**当为一个类型实现Default时,既不需要也不建议同时提供一个没有参数的相关函数new。

**提示:**实现或派生Default的好处是,你的类型现在可以用于需要实现Default的地方,最突出的是标准库中的任何*or_default函数。

参见

Latest commit fa8e722 on 22 Nov 2021

Default Trait

描述

Rust中的许多类型都有一个构造器。然而,这是类型特殊的;Rust不能抽象出“所有对象都具有new()方法”。 为了允许这一点,我们设想了Defaulttrait,它可以用于容器和其他泛型(例如,见Option::unwrap_or_default())。 值得注意的是,一些容器已经在适用的地方实现了它。

不仅像Cow、Box或Arc这样的单元素容器为所包含的Default类型实现了 Default,人们还可以自动为字段都实现了Default的结构体实现#[derive(Default)],所以越多类型实现Default,它就越有用。

另一方面,构造器可以接受多个参数,而default()方法则不能。 甚至可以有多个名字不同的构造器,但每个类型只能有一个Default的实现。

例子

use std::{path::PathBuf, time::Duration};

// note that we can simply auto-derive Default here.
#[derive(Default, Debug, PartialEq)]
struct MyConfiguration {
    // Option defaults to None
    output: Option<PathBuf>,
    // Vecs default to empty vector
    search_path: Vec<PathBuf>,
    // Duration defaults to zero time
    timeout: Duration,
    // bool defaults to false
    check: bool,
}

impl MyConfiguration {
    // add setters here
}

fn main() {
    // construct a new instance with default values
    let mut conf = MyConfiguration::default();
    // do something with conf here
    conf.check = true;
    println!("conf = {:#?}", conf);
        
    // partial initialization with default values, creates the same instance
    let conf1 = MyConfiguration {
        check: true,
        ..Default::default()
    };
    assert_eq!(conf, conf1);
}

参见

Latest commit 9834f57 on 25 Aug 2021

集合是智能指针

描述

使用Dereftrait将集合视为智能指针,提供拥有 和借用的数据视图。

例子

use std::ops::Deref;

struct Vec<T> {
    data: RawVec<T>,
    //..
}

impl<T> Deref for Vec<T> {
    type Target = [T];

    fn deref(&self) -> &[T] {
        //..
    }
}

一个Vec<T>是一个拥有T的集合,一个切片(&[T])是一个借用T的集合。 为Vec实现Deref允许从&Vec<T>到&[T]的隐式解引用,并在自动解引用搜索中包含这种关系。 你可能期望为Vec实现的大多数方法都是为切片实现的。

参见String和&str。

动机

所有权和借用是Rust语言的关键方面。 数据结构必须正确说明这些语义,以便提供良好的用户体验。 当实现一个拥有其数据的数据结构时,提供该数据的借用视图可以实现更灵活的API。

优势

大多数方法只为借用视图实现,然后它们隐含地对拥有视图可用。

让客户端在借用或拥有数据的所有权之间做出选择。

劣势

只有通过解引用才能使用的方法和trait在边界检查时不被考虑,所以使用这种模式的数据结构的泛型编程会变得很复杂(见Borrow和AsReftrait等)。

讨论

智能指针和集合是类似的:一个智能指针指向一个对象,而一个集合指向许多对象。 从类型系统的角度来看,这两者之间没有什么区别。 如果访问每个数据的唯一途径是通过集合,并且集合负责删除数据(即使在共享所有权的情况下,某种借用视图可能是合适的),那么集合就拥有它的数据。 如果集合拥有它的数据,提供借用数据的视图通常是有用的,这样它就可以被多次引用了。

大多数智能指针(例如,Foo<T>)实现了Deref<Target=T>。 然而,集合通常会解引用到一个自定义的类型。 [T]和str有一些语言支持,但在一般情况下,这是没有必要的。 Foo<T>可以实现Deref<Target=Bar<T>,其中Bar是一个动态大小的类型,&Bar<T>是对Foo<T>中数据的借用视图。

通常,有序集合为Range实现Index,以提供分片语法。目标是借用视图。

参见

反面模式:解引用多态性.

Deref trait 文档.

Latest commit 66d7e6c on 2 Oct 2021

析构器中的最终处理

Description

Rust没有提供与finally块相当的设施——无论函数如何退出都会被执行。 相反,一个对象的析构器可以被用来运行必须在退出前运行的代码。

例子

fn bar() -> Result<(), ()> {
    // These don't need to be defined inside the function.
    struct Foo;

    // Implement a destructor for Foo.
    impl Drop for Foo {
        fn drop(&mut self) {
            println!("exit");
        }
    }

    // The dtor of _exit will run however the function `bar` is exited.
    let _exit = Foo;
    // Implicit return with `?` operator.
    baz()?;
    // Normal return.
    Ok(())
}

动机

如果一个函数有多个返回点,那么在退出时执行代码就会变得困难和重复(从而容易产生错误)。 特别是在由于宏而隐式返回的情况下。 一个常见的情况是?操作符,如果结果是Err就返回,如果是Ok就继续。 ?被用作异常处理机制,但不像Java(有finally),没有办法安排代码在正常和异常情况下运行。 发生Panic也会提前退出函数。

优势

析构器中的代码将(几乎)一直运行——应对panic、提前返回等问题。

劣势

事实上,并没有保证析构器一定会运行。 例如,如果在一个函数中存在一个无限循环,或者如果运行函数在退出前崩溃。 在已经发生panic的线程中,析构器也不会被运行。 因此,在绝对有必要进行最终处理的情况下,不能依靠析构器作为最终处理器。

这种模式引入了一些难以察觉的隐式代码。阅读一个函数时,没有明确指出退出时要运行哪些析构器。 这可能会使调试工作变得棘手。

要求对象和Drop实现若只是为了最终处理,会是很沉重的模板代码。

讨论

关于如何准确地存储作为最终处理器的对象,有一些微妙的问题。 它必须被保存到函数结束,然后必须被销毁。 该对象必须始终是一个值或唯一拥有的指针(例如,Box<Foo>)。 如果使用一个共享的指针(如Rc),那么最终处理器可以在函数的生命周期之外保持生存。 出于类似的原因,最终处理器不应该被移动或返回。

最终处理器必须被分配到一个变量中,否则它将被立即销毁,而不是当它超出作用域时。 如果该变量只作为最终处理器使用,其名称必须以_开头,否则编译器会警告说最终处理器从未被使用。 然而,不要调用没有后缀的变量_——在这种情况下,它将被立即销毁。

在Rust中,当一个对象超出作用域时,会运行析构器。 这发生在我们到达块的末尾,有一个早期返回,或者程序发生panic。 当发生panic时,Rust会展开堆栈,为每个堆栈帧中的每个对象运行析构器。 因此,即使panic发生在被调用的函数中,析构器也会被调用。

如果在展开的过程中一个析构器发生panic,那么就没有好的行动可以采取,所以Rust会立即中止线程,而不再运行其他的析构器。 这意味着析构器不能绝对保证运行。 这也意味着你必须在你的析构器中格外小心,不要panic,因为它可能会让资源处于一个意想不到的状态。

参见

RAII守护对象

Latest commit 9834f57 on 25 Aug 2021

在发生改变的枚举中使用mem::{take(_), replace(_)}来保留所有值

描述

假定我们有一个&mut MyEnum,它有(至少)两个变体, A { name: String, x: u8 }和B { name: String }。 现在我们想如果x为零,把MyEnum::A改成B,同时保持MyEnum::B不变。

我们可以在不克隆name的情况下做到这一点。

例子


#![allow(unused)]
fn main() {
use std::mem;

enum MyEnum {
    A { name: String, x: u8 },
    B { name: String }
}

fn a_to_b(e: &mut MyEnum) {
    if let MyEnum::A { name, x: 0 } = e {
        // this takes out our `name` and put in an empty String instead
        // (note that empty strings don't allocate).
        // Then, construct the new enum variant (which will
        // be assigned to `*e`).
        *e = MyEnum::B { name: mem::take(name) }
    }
}
}

这也适用于更多的变体:


#![allow(unused)]
fn main() {
use std::mem;

enum MultiVariateEnum {
    A { name: String },
    B { name: String },
    C,
    D
}

fn swizzle(e: &mut MultiVariateEnum) {
    use MultiVariateEnum::*;
    *e = match e {
        // Ownership rules do not allow taking `name` by value, but we cannot
        // take the value out of a mutable reference, unless we replace it:
        A { name } => B { name: mem::take(name) },
        B { name } => A { name: mem::take(name) },
        C => D,
        D => C
    }
}
}

动机

在处理枚举时,我们可能想在原地改变一个枚举值,也许是改变成另一个变体。 为了通过借用检查器,这通常分两个阶段进行。 在第一阶段,我们观察现有值,看看它的各个部分,以决定下一步该做什么 在第二阶段,我们可以有条件地改变该值(如上面的例子)。

借用检查器不允许我们取走枚举类型的name(因为something必须存在。) 尽管我们可以.clone()``name然后将克隆值放入MyEnum::B中,但这就是反面模式通过Clone来满足借用检查器 的一个例子了。 无论如何,我们可以通过只用一个可变借用来改变e,进而避免额外的内存分配。

mem::take可以换掉这个值,用它的默认值代替,并返回之前的值。 对于String,默认值是一个空的String,不需要分配内存。 最终,我们得到了原来的name作为一个所有值。然后我们可以把它包在另一个枚举中。

注意: mem::replace非常相似,但允许我们指定用什么来替换值。 mem::take等价于mem::replace(name, String::new()).

但是请注意,如果我们使用一个Option,并想用一个None来替换它的值,Option的take()方法提供了一个更短和更习惯的替代方法。

优势

没有内存分配。

劣势

表达比较啰嗦,经常搞错会让你讨厌借用检查器。 编译器可能无法优化掉双重存储,从而导致性能下降,这与你在不安全语言中的做法是不同的。

此外,你拿走的类型需要实现Defaulttrait。 如果你正在使用的类型没有实现,你可以使用mem::replace代替。

讨论

这种模式只在Rust中才有意义。 在有垃圾回收的语言中,默认取值的引用(GC会跟踪引用),而在其他低级语言如C语言中,可以简单地别名指针,并在以后修复。

然而,在Rust中,我们必须多做一点工作才能做到这一点。一个所有值可能只有一个所有者,所以要把它取出来,我们需要把一些东西放回去。

参见

在特定情况下,可以去除通过Clone来满足借用检查器的反面模式。

Latest commit 9834f57 on 25 Aug 2021

栈上动态分发

描述

我们可以对多个值进行动态分发,然而,要做到这一点,我们需要声明多个变量来绑定不同类型的对象。 为了根据需要延长生命周期,我们可以使用延迟条件初始化,如下所示:

例子

use std::io;
use std::fs;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let arg = "-";

    // These must live longer than `readable`, and thus are declared first:
    let (mut stdin_read, mut file_read);

    // We need to ascribe the type to get dynamic dispatch.
    let readable: &mut dyn io::Read = if arg == "-" {
        stdin_read = io::stdin();
        &mut stdin_read
    } else {
        file_read = fs::File::open(arg)?;
        &mut file_read
    };

    // Read from `readable` here.

    Ok(())
}

动机

Rust默认会对代码进行单态处理。这意味着每一种类型的代码都会被生成一个副本,并被独立优化。 虽然这允许在热点路径上产生非常快的代码,但它也会在性能不重要的地方使代码变得臃肿,从而耗费编译时间和缓存使用量。

幸运的是,Rust允许我们使用动态分发,但我们必须明确要求它。

优势

我们不需要在堆上分配任何东西。 我们也不需要初始化一些我们以后不会用到的东西,也不需要把下面的整个代码单一化,以便File或Stdin一起工作。

劣势

该代码需要比基于Box的版本有更多的移动语义部分。

// We still need to ascribe the type for dynamic dispatch.
let readable: Box<dyn io::Read> = if arg == "-" {
    Box::new(io::stdin())
} else {
    Box::new(fs::File::open(arg)?)
};
// Read from `readable` here.

讨论

Rust新手通常会了解到,Rust要求所有变量在使用前被初始化,所以很容易忽略这样一个事实,即未使用的变量很可能是未初始化的。 Rust非常努力地确保这一点,而且只有初始化过的值在其作用域的末端被丢弃。

这个例子符合Rust对我们的所有约束:

  • 所有的变量在使用(本例中为借用)之前都被初始化。
  • 每个变量只持有单一类型的值。在我们的例子中,stdin是Stdin类型,file是File类型,readable是&mut dyn Read类型。
  • 每个被借用值的生命周期都比它的所有借用引用要久。

参见

Latest commit a152399 on 21 Apr 2021

FFI 惯常做法

编写FFI代码本身就是一个完整的课程。 然而,这里有几个惯常做法可以作为指导,避免没有经验的用户在unsafeRust中踩坑。

本节包含了在开发FFI时可能有用的惯常做法。

  1. 错误处理的惯常做法——用整数值和哨兵返回值处理错误(如NULL指针)。

  2. 接受字符串,使用最少的不安全代码。

  3. 传递字符串到FFI函数。

Latest commit 606bcff on 26 Feb 2021

FFI中的错误处理

描述

在C语言等外部语言中,错误是由返回码来表示的。 然而,Rust的类型系统允许通过一个完整的类型来捕获和传播更丰富的错误信息。

这个最佳实践展示了不同种类的错误代码,以及如何以一种可用的方式暴露它们:

  1. 简单枚举应该被转换为整数,并作为代码返回。
  2. 结构化的枚举应该被转换为整数代码,并有一个字符串错误消息作为细节。
  3. 自定义错误类型应该变得”透明“,用C表示。

代码示例

简单枚举

enum DatabaseError {
    IsReadOnly = 1, // user attempted a write operation
    IOError = 2, // user should read the C errno() for what it was
    FileCorrupted = 3, // user should run a repair tool to recover it
}

impl From<DatabaseError> for libc::c_int {
    fn from(e: DatabaseError) -> libc::c_int {
        (e as i8).into()
    }
}

结构化枚举

pub mod errors {
    enum DatabaseError {
        IsReadOnly,
        IOError(std::io::Error),
        FileCorrupted(String), // message describing the issue
    }

    impl From<DatabaseError> for libc::c_int {
        fn from(e: DatabaseError) -> libc::c_int {
            match e {
                DatabaseError::IsReadOnly => 1,
                DatabaseError::IOError(_) => 2,
                DatabaseError::FileCorrupted(_) => 3,
            }
        }
    }
}

pub mod c_api {
    use super::errors::DatabaseError;

    #[no_mangle]
    pub extern "C" fn db_error_description(
        e: *const DatabaseError
        ) -> *mut libc::c_char {

        let error: &DatabaseError = unsafe {
            // SAFETY: pointer lifetime is greater than the current stack frame
            &*e
        };

        let error_str: String = match error {
            DatabaseError::IsReadOnly => {
                format!("cannot write to read-only database");
            }
            DatabaseError::IOError(e) => {
                format!("I/O Error: {}", e);
            }
            DatabaseError::FileCorrupted(s) => {
                format!("File corrupted, run repair: {}", &s);
            }
        };

        let c_error = unsafe {
            // SAFETY: copying error_str to an allocated buffer with a NUL
            // character at the end
            let mut malloc: *mut u8 = libc::malloc(error_str.len() + 1) as *mut _;

            if malloc.is_null() {
                return std::ptr::null_mut();
            }

            let src = error_str.as_bytes().as_ptr();

            std::ptr::copy_nonoverlapping(src, malloc, error_str.len());

            std::ptr::write(malloc.add(error_str.len()), 0);

            malloc as *mut libc::c_char
        };

        c_error
    }
}

自定义错误类型

struct ParseError {
    expected: char,
    line: u32,
    ch: u16
}

impl ParseError { /* ... */ }

/* Create a second version which is exposed as a C structure */
#[repr(C)]
pub struct parse_error {
    pub expected: libc::c_char,
    pub line: u32,
    pub ch: u16
}

impl From<ParseError> for parse_error {
    fn from(e: ParseError) -> parse_error {
        let ParseError { expected, line, ch } = e;
        parse_error { expected, line, ch }
    }
}

优势

这就保证了外部语言可以清楚地获得错误信息,同时完全不影响Rust代码的API。

劣势

这是很大的工作量,有些类型可能不容易被转换为C语言中的表示。

Latest commit 606bcff on 26 Feb 2021

接受字符串

描述

当FFI通过指针接受字符串时,应该遵循两个原则:

  1. 保持外部字符串是“借用”的,而不是直接复制它们。
  2. 尽量减少从C风格字符串转换到原生Rust字符串时涉及的复杂性和unsafe代码量。

动机

C语言中使用的字符串与Rust语言中使用的字符串有不同的行为:

  • C语言的字符串是无终止的,而Rust语言的字符串会存储其长度。
  • C语言的字符串可以包含任何任意的非零字节,而Rust的字符串必须是UTF-8。
  • C语言的字符串使用unsafe的指针操作来访问和操作,而与Rust字符串的交互是通过安全方法进行的。

Rust标准库提供了与Rust的String和&str相对应的C语言等价表示,称为CString和&CStr,这使得我们可以避免在C语言字符串和Rust字符串之间转换的复杂性和unsafe代码。

&CStr类型还允许我们使用借用数据,这意味着在Rust和C之间传递字符串是一个零成本的操作。

代码示例

pub mod unsafe_module {

    // other module content

    /// Log a message at the specified level.
    ///
    /// # Safety
    ///
    /// It is the caller's guarantee to ensure `msg`:
    ///
    /// - is not a null pointer
    /// - points to valid, initialized data
    /// - points to memory ending in a null byte
    /// - won't be mutated for the duration of this function call
    #[no_mangle]
    pub unsafe extern "C" fn mylib_log(
        msg: *const libc::c_char,
        level: libc::c_int
    ) {
        let level: crate::LogLevel = match level { /* ... */ };

        // SAFETY: The caller has already guaranteed this is okay (see the
        // `# Safety` section of the doc-comment).
        let msg_str: &str = match std::ffi::CStr::from_ptr(msg).to_str() {
            Ok(s) => s,
            Err(e) => {
                crate::log_error("FFI string conversion failed");
                return;
            }
        };

        crate::log(msg_str, level);
    }
}

优势

这个例子的编写是为了确保:

  1. unsafe块尽可能小。
  2. 具有“未跟踪”的生命周期的指针成为“跟踪”的共享引用。

考虑一个替代方案,即实际复制字符串:

pub mod unsafe_module {

    // other module content

    pub extern "C" fn mylib_log(msg: *const libc::c_char, level: libc::c_int) {
        // DO NOT USE THIS CODE.
        // IT IS UGLY, VERBOSE, AND CONTAINS A SUBTLE BUG.

        let level: crate::LogLevel = match level { /* ... */ };

        let msg_len = unsafe { /* SAFETY: strlen is what it is, I guess? */
            libc::strlen(msg)
        };

        let mut msg_data = Vec::with_capacity(msg_len + 1);

        let msg_cstr: std::ffi::CString = unsafe {
            // SAFETY: copying from a foreign pointer expected to live
            // for the entire stack frame into owned memory
            std::ptr::copy_nonoverlapping(msg, msg_data.as_mut(), msg_len);

            msg_data.set_len(msg_len + 1);

            std::ffi::CString::from_vec_with_nul(msg_data).unwrap()
        }

        let msg_str: String = unsafe {
            match msg_cstr.into_string() {
                Ok(s) => s,
                Err(e) => {
                    crate::log_error("FFI string conversion failed");
                    return;
                }
            }
        };

        crate::log(&msg_str, level);
    }
}

这个版本的代码在两个方面比原版逊色:

  1. 有更多的unsafe代码,更重要的是,它必须坚持更多的不变量。
  2. 由于需要大量的算术,这个版本有一个错误,会导致Rust的undefined behaviour。

这里的错误是一个简单的指针运算错误:字符串所有的msg_len字节被复制了。 但是,结尾的NUL终止符没有被复制。

然后,Vector的大小被设置为zero padded string的长度——而不是调整大小到它,即可能会在最后添加一个零。 结果是,Vector中的最后一个字节是未初始化的内存。 当CString在块的底部被创建时,它对Vector的读取将导致undefined behaviour!

像许多这样的问题一样,这将是一个很难追踪的问题。 有时它会因为字符串不是UTF-8而panic,有时它会在字符串的末尾放一个奇怪的字符,有时它会完全崩溃。

劣势

没有?

Latest commit 606bcff on 26 Feb 2021

传递字符串

描述

当向FFI函数传递字符串时,应该遵循四个原则:

  1. 使拥有的字符串的生命周期尽可能长。
  2. 在转换过程中尽量减少unsafe代码。
  3. 如果C代码可以修改字符串数据,使用Vec而不是CString。
  4. 除非外部函数API要求,否则字符串的所有权不应该转移给被调用者。

动机

Rust内置了对C风格字符串的支持,有CString和CStr类型。 然而,对于从Rust函数中发送字符串到外部函数调用,我们可以采取不同的方法。

最好的做法很简单:用CString的方式来减少unsafe的代码。 然而,次要的注意事项是,对象必须活得足够长,这意味着生命周期应该最大化。 此外,文档解释说,CString进行"round-tripping"修改是未定义行为,所以在这种情况下需要额外的工作。

代码示例

pub mod unsafe_module {

    // other module content

    extern "C" {
        fn seterr(message: *const libc::c_char);
        fn geterr(buffer: *mut libc::c_char, size: libc::c_int) -> libc::c_int;
    }

    fn report_error_to_ffi<S: Into<String>>(
        err: S
    ) -> Result<(), std::ffi::NulError>{
        let c_err = std::ffi::CString::new(err.into())?;

        unsafe {
            // SAFETY: calling an FFI whose documentation says the pointer is
            // const, so no modification should occur
            seterr(c_err.as_ptr());
        }

        Ok(())
        // The lifetime of c_err continues until here
    }

    fn get_error_from_ffi() -> Result<String, std::ffi::IntoStringError> {
        let mut buffer = vec![0u8; 1024];
        unsafe {
            // SAFETY: calling an FFI whose documentation implies
            // that the input need only live as long as the call
            let written: usize = geterr(buffer.as_mut_ptr(), 1023).into();

            buffer.truncate(written + 1);
        }

        std::ffi::CString::new(buffer).unwrap().into_string()
    }
}

优势

这个例子的编写方式是为了确保:

  1. unsafe块尽可能小。
  2. CString存活得足够久。
  3. 类型转换的错误被尽可能传播。

一个常见的错误(常见到在文档中)是不在第一个块中使用变量:

pub mod unsafe_module {

    // other module content

    fn report_error<S: Into<String>>(err: S) -> Result<(), std::ffi::NulError> {
        unsafe {
            // SAFETY: whoops, this contains a dangling pointer!
            seterr(std::ffi::CString::new(err.into())?.as_ptr());
        }
        Ok(())
    }
}

这段代码将导致一个悬垂指针,因为CString的生命周期并没有因为指针的创建而延长,这与创建引用的情况不同。

另一个经常提出的问题是,初始化1k个零的向量是“慢”的。 然而,最近的Rust版本实际上将这个特殊的宏优化为对zmalloc的调用,这意味着它的速度和操作系统返回零内存的能力一样快(这相当快)。

劣势

没有?

Latest commit 606bcff on 26 Feb 2021

Option的迭代

描述

Option可以被看作是一个包含零或一个元素的容器。 特别是,它实现了IntoIteratortrait,因此可以用于需要这种类型的通用代码。

例子

由于Option实现了IntoIterator,它可以作为.extend()的一个参数:


#![allow(unused)]
fn main() {
let turing = Some("Turing");
let mut logicians = vec!["Curry", "Kleene", "Markov"];

logicians.extend(turing);

// equivalent to
if let Some(turing_inner) = turing {
    logicians.push(turing_inner);
}
}

如果你需要把一个Option粘到现有迭代器的末尾,你可以把它传递给.chain():


#![allow(unused)]
fn main() {
let turing = Some("Turing");
let logicians = vec!["Curry", "Kleene", "Markov"];

for logician in logicians.iter().chain(turing.iter()) {
    println!("{} is a logician", logician);
}
}

注意,如果Option总是Some,那么在元素上使用std::iter::once更常见。

另外,由于Option实现了IntoIterator,所以可以使用for循环对其进行迭代。 这相当于用if let Some(..)来匹配它,在大多数情况下,你应该选择后者。

参见

Latest commit 9834f57 on 25 Aug 2021

传递变量到闭包

描述

默认情况下,闭包通过借用来捕获其环境。或者你可以使用 move-closure 来移动整个环境。 然而,你往往只想把一些变量转移到闭包中,给它一些数据的拷贝,通过引用传递,或者执行一些其他的转换。

为此,在单独的作用域中使用变量重绑定。

例子

使用


#![allow(unused)]
fn main() {
use std::rc::Rc;

let num1 = Rc::new(1);
let num2 = Rc::new(2);
let num3 = Rc::new(3);
let closure = {
    // `num1` is moved
    let num2 = num2.clone();  // `num2` is cloned
    let num3 = num3.as_ref();  // `num3` is borrowed
    move || {
        *num1 + *num2 + *num3;
    }
};
}

而不是


#![allow(unused)]
fn main() {
use std::rc::Rc;

let num1 = Rc::new(1);
let num2 = Rc::new(2);
let num3 = Rc::new(3);

let num2_cloned = num2.clone();
let num3_borrowed = num3.as_ref();
let closure = move || {
    *num1 + *num2_cloned + *num3_borrowed;
};
}

优势

复制的数据和闭包定义在一起,所以它们的目的更明确,而且即使它们没有被闭包消耗,也会被立即丢弃。

无论数据是被复制还是被移动,闭包都使用与周围代码相同的变量名。

劣势

闭包体的额外缩进。

Latest commit 9834f57 on 25 Aug 2021

#[non_exhaustive]和私有字段的可扩展性

描述

在一小部分情况下,库作者可能想在不破坏后向兼容性的情况下,为公共结构体添加公共字段或为枚举添加新的变体。

Rust为这个问题提供了两种解决方案:

  • 在struct,enum和enum变体上使用#[non_exhaustive]。 关于所有可以使用#[non_exhaustive]的地方的详细文档,见文档。

  • 你可以向结构体添加一个私有字段,以防止它被直接实例化或与之匹配(见备选方案)。

例子


#![allow(unused)]
fn main() {
mod a {
    // Public struct.
    #[non_exhaustive]
    pub struct S {
        pub foo: i32,
    }
    
    #[non_exhaustive]
    pub enum AdmitMoreVariants {
        VariantA,
        VariantB,
        #[non_exhaustive]
        VariantC { a: String }
    }
}

fn print_matched_variants(s: a::S) {
    // Because S is `#[non_exhaustive]`, it cannot be named here and
    // we must use `..` in the pattern.
    let a::S { foo: _, ..} = s;
    
    let some_enum = a::AdmitMoreVariants::VariantA;
    match some_enum {
        a::AdmitMoreVariants::VariantA => println!("it's an A"),
        a::AdmitMoreVariants::VariantB => println!("it's a b"),

        // .. required because this variant is non-exhaustive as well
        a::AdmitMoreVariants::VariantC { a, .. } => println!("it's a c"),

        // The wildcard match is required because more variants may be
        // added in the future
        _ => println!("it's a new variant")
    }
}
}

备选方案:结构体的Private fields

#[non_exhaustive]只适用于跨crate边界的情况。 在一个crate内,可以使用私有字段方法。

在结构体中添加字段基本上是一个向后兼容的变化。 然而,如果客户端使用某种模式来解构结构体实例,他们可能会命名结构体中的所有字段,而添加新字段会破坏这种模式。 客户端可以命名一些字段并在模式中使用..,在这种情况下,添加另一个字段是向后兼容的。 将结构体中的至少一个字段设置为私有,迫使客户端使用后一种形式的模式,确保结构体是面向未来的。

这种方法的缺点是,你可能需要在结构体中添加一个原本不需要的字段。 你可以使用()类型,这样就没有运行时的开销,并在字段名前加上_,以避免未使用字段的警告。


#![allow(unused)]
fn main() {
pub struct S {
    pub a: i32,
    // Because `b` is private, you cannot match on `S` without using `..` and `S`
    //  cannot be directly instantiated or matched against
    _b: ()
}
}

讨论

在struct上,#[non_exhaustive]允许以向后兼容的方式添加额外字段。 它也会阻止客户端使用结构体的构造器,即使所有字段都是公开的。 这可能很有帮助,但值得考虑的是,你是否希望额外的字段被客户端发现是一个编译器错误,而不是默默地不被发现。

#[non_exhaustive]也可以应用于枚举的变体。 #[non_exhaustive]变体的行为与#[non_exhaustive]结构体的行为相同。

慎重使用:在添加字段或变体时,增加主版本通常是更好的选择。 #[non_exhaustive]可能适用于这样的情况:你正在为一个可能与你的库不同步变化的外部资源建模,但这不是一个通用工具。

劣势

#[non_exhaustive]会使你的代码使用起来更不符合人体工程学,特别是在被迫处理未知的枚举变体的时候。 它应该只在需要这些改变,却又不需要递增主版本时使用。

当#[non_exhaustive]被应用于enum时,它迫使客户端处理通配符变体。 如果在这种情况下没有采取合理的行动,这可能会导致丑陋的代码和只在极其罕见情况下才会执行的代码路径。 如果客户端在这种情况下决定panic!(),那么在编译时暴露这个错误可能会更好。 事实上,#[non_exhaustive]迫使客户端处理"Something else"的情况;在这种情况下,很少有明智的行动可以采取。

参见

Latest commit 567a1f1 on 1 Sep 2021

简单的文档初始化

描述

如果一个结构体需要花费大量精力来初始化,那么在编写文档时,用一个将结构体作为参数的辅助函数来包装你的例子可能会更快。

动机

有时,一个结构体有多个或复杂的参数和几个方法。 这些方法中的每一个都应该有例子。

例如:

struct Connection {
    name: String,
    stream: TcpStream,
}

impl Connection {
    /// Sends a request over the connection.
    ///
    /// # Example
    /// ```no_run
    /// # // Boilerplate are required to get an example working.
    /// # let stream = TcpStream::connect("127.0.0.1:34254");
    /// # let connection = Connection { name: "foo".to_owned(), stream };
    /// # let request = Request::new("RequestId", RequestType::Get, "payload");
    /// let response = connection.send_request(request);
    /// assert!(response.is_ok());
    /// ```
    fn send_request(&self, request: Request) -> Result<Status, SendErr> {
        // ...
    }

    /// Oh no, all that boilerplate needs to be repeated here!
    fn check_status(&self) -> Status {
        // ...
    }
}

例子

与其输入所有这些模板代码来创建一个Connection和Request,不如直接创建一个将它们作为参数的包装辅助函数:

struct Connection {
    name: String,
    stream: TcpStream,
}

impl Connection {
    /// Sends a request over the connection.
    ///
    /// # Example
    /// ```
    /// # fn call_send(connection: Connection, request: Request) {
    /// let response = connection.send_request(request);
    /// assert!(response.is_ok());
    /// # }
    /// ```
    fn send_request(&self, request: Request) {
        // ...
    }
}

**注意:**在上面的例子中,assert!(response.is_ok());这一行在测试时不会实际运行,因为它是在一个从未被调用的函数中。

优势

更简洁,避免了例子中的重复代码。

劣势

由于例子是在一个函数中,代码将不会被测试。 尽管在运行cargo test时,它仍然会被检查,以确保它能编译。 所以当你需要no_run时,这种模式是最有用的。有了这个,你不需要添加no_run。

讨论

如果不需要断言,这种模式很好用。

如果需要,另一种方法是创建一个公共方法来创建一个帮助器实例,该方法被标注为#[doc(hidden)](这样用户就不会看到它)。 然后这个方法可以在rustdoc内部被调用,因为它是crate公共API的一部分。

Latest commit 9834f57 on 25 Aug 2021

临时可变性

描述

通常在准备和处理一些数据后,数据只是被检查,而不会被修改。 这个意图可以通过重新定义可变变量为不可变的来明确。

这可以通过在嵌套块内处理数据或重新定义变量来实现。

例子

假定向量在使用前必须进行排序。

使用嵌套块:

let data = {
    let mut data = get_vec();
    data.sort();
    data
};

// Here `data` is immutable.

使用变量重绑定:

let mut data = get_vec();
data.sort();
let data = data;

// Here `data` is immutable.

优势

由编译器来确保你不会在某个时间点之后意外地改变数据。

劣势

嵌套块需要额外缩进。 多写一行,从块中返回数据或重新定义变量。

Latest commit 2cd70a5 on 22 Jan 2021

设计模式

设计模式 是“在软件设计的特定背景下,对一个经常发生的问题的一般可重复使用的解决方案”。 设计模式是描述一种编程语言文化的好方法。 设计模式具有很强的语言特异性——在一种语言中属于模式的东西,在另一种语言中可能由于语言特性而不需要,或者由于缺少特性而无法表达。

如果过度使用,设计模式会给程序增加不必要的复杂性。 然而,它们是分享关于一种编程语言的中高级知识的好方法。

Rust中的设计模式

Rust有许多特性。这些特性通过消除整类问题给我们带来了巨大的好处。其中有些也是Rust的独特模式。

YAGNI

如果你不熟悉,YAGNI是一个缩写,代表You Aren't Going to Need It。这是一个重要的软件设计原则,在你写代码时要应用。

我曾经写过的最好的代码是我从未写过的代码。

如果我们将YAGNI应用于设计模式,我们会发现Rust的特性允许我们抛开许多模式。 例如,在Rust中没有必要使用策略模式因为我们有traits。

TODO:加入一些代码来说明这些traits。

Latest commit 9834f57 on 25 Aug 2021

行为型模式

来自Wikipedia:

识别对象间常见通信模式的设计模式。 这样做增加了进行通信的灵活性。

Latest commit 606bcff on 26 Feb 2021

命令

描述

命令模式的基本思想是将行动分离成它自己的对象,并将它们作为参数传递。

动机

假设我们有一连串的行动或事务被封装为对象。 我们希望这些行动或命令之后在不同的时间以某种顺序被执行或调用。 这些命令也可能因某些事件而被触发。 例如,当用户按下一个按钮,或在一个数据包到达时。 此外,这些命令可能是可撤销的。这可能对编辑器的操作很有用。 我们可能想存储已执行命令的日志,这样,如果系统崩溃,我们可以之后重新应用这些变化。

例子

定义两个数据库操作create table和add field。每一个操作都是一个可撤销的命令,例如,drop table和remove field。 当用户调用数据库迁移操作时,那么每条命令都按照定义的顺序执行,当用户调用回滚操作时,那么整个命令集将以相反的顺序调用。

方法:使用 trait 对象

我们定义了一个共同的trait,用两个操作execute和rollback来封装我们的命令。所有的命令structs必须实现这个trait。

pub trait Migration {
    fn execute(&self) -> &str;
    fn rollback(&self) -> &str;
}

pub struct CreateTable;
impl Migration for CreateTable {
    fn execute(&self) -> &str {
        "create table"
    }
    fn rollback(&self) -> &str {
        "drop table"
    }
}

pub struct AddField;
impl Migration for AddField {
    fn execute(&self) -> &str {
        "add field"
    }
    fn rollback(&self) -> &str {
        "remove field"
    }
}

struct Schema {
    commands: Vec<Box<dyn Migration>>,
}

impl Schema {
    fn new() -> Self {
        Self { commands: vec![] }
    }

    fn add_migration(&mut self, cmd: Box<dyn Migration>) {
        self.commands.push(cmd);
    }

    fn execute(&self) -> Vec<&str> {
        self.commands.iter().map(|cmd| cmd.execute()).collect()
    }
    fn rollback(&self) -> Vec<&str> {
        self.commands
            .iter()
            .rev() // reverse iterator's direction
            .map(|cmd| cmd.rollback())
            .collect()
    }
}

fn main() {
    let mut schema = Schema::new();

    let cmd = Box::new(CreateTable);
    schema.add_migration(cmd);
    let cmd = Box::new(AddField);
    schema.add_migration(cmd);

    assert_eq!(vec!["create table", "add field"], schema.execute());
    assert_eq!(vec!["remove field", "drop table"], schema.rollback());
}

方法:使用函数指针

我们可以遵循另一种方法,将每个单独的命令创建为不同的函数,并存储函数指针,以便以后在不同的时间调用这些函数。 由于函数指针实现了所有三个trait Fn,FnMut和FnOnce,我们也可以传递和存储闭包而不是函数指针。

type FnPtr = fn() -> String;
struct Command {
    execute: FnPtr,
    rollback: FnPtr,
}

struct Schema {
    commands: Vec<Command>,
}

impl Schema {
    fn new() -> Self {
        Self { commands: vec![] }
    }
    fn add_migration(&mut self, execute: FnPtr, rollback: FnPtr) {
        self.commands.push(Command { execute, rollback });
    }
    fn execute(&self) -> Vec<String> {
        self.commands.iter().map(|cmd| (cmd.execute)()).collect()
    }
    fn rollback(&self) -> Vec<String> {
        self.commands
            .iter()
            .rev()
            .map(|cmd| (cmd.rollback)())
            .collect()
    }
}

fn add_field() -> String {
    "add field".to_string()
}

fn remove_field() -> String {
    "remove field".to_string()
}

fn main() {
    let mut schema = Schema::new();
    schema.add_migration(|| "create table".to_string(), || "drop table".to_string());
    schema.add_migration(add_field, remove_field);
    assert_eq!(vec!["create table", "add field"], schema.execute());
    assert_eq!(vec!["remove field", "drop table"], schema.rollback());
}

方法:使用 Fn trait 对象

最后,我们可以将实现Fntrait的每个命令分别存储在向量中,而不是定义一个共同的命令trait。

type Migration<'a> = Box<dyn Fn() -> &'a str>;

struct Schema<'a> {
    executes: Vec<Migration<'a>>,
    rollbacks: Vec<Migration<'a>>,
}

impl<'a> Schema<'a> {
    fn new() -> Self {
        Self {
            executes: vec![],
            rollbacks: vec![],
        }
    }
    fn add_migration<E, R>(&mut self, execute: E, rollback: R)
    where
        E: Fn() -> &'a str + 'static,
        R: Fn() -> &'a str + 'static,
    {
        self.executes.push(Box::new(execute));
        self.rollbacks.push(Box::new(rollback));
    }
    fn execute(&self) -> Vec<&str> {
        self.executes.iter().map(|cmd| cmd()).collect()
    }
    fn rollback(&self) -> Vec<&str> {
        self.rollbacks.iter().rev().map(|cmd| cmd()).collect()
    }
}

fn add_field() -> &'static str {
    "add field"
}

fn remove_field() -> &'static str {
    "remove field"
}

fn main() {
    let mut schema = Schema::new();
    schema.add_migration(|| "create table", || "drop table");
    schema.add_migration(add_field, remove_field);
    assert_eq!(vec!["create table", "add field"], schema.execute());
    assert_eq!(vec!["remove field", "drop table"], schema.rollback());
}

讨论

如果我们的命令很小,并且可以被定义为函数或者作为一个闭包传递,那么使用函数指针可能是更好的,因为它没有利用动态分发。 但如果我们的命令是一个完整的结构体,其中有一堆函数和变量被定义为独立的模块,那么使用trait对象会更合适。 应用案例可以在actix中找到,它在为路由注册处理函数时使用trait对象。 在使用Fntrait对象的情况下,我们可以用与函数指针相同的方式创建和使用命令。

关于性能,在性能和代码的简单性和组织性之间总是有一个权衡。 静态分发可以提供更快的性能,而动态分发在我们构造应用程序时提供了灵活性。

参见

Latest commit 9834f57 on 25 Aug 2021

解释器

描述

如果一个问题经常发生,并且需要长时间重复的步骤来解决,那么问题实例可能用一种简单的语言来表达,一个解释器对象可以通过解释用这种简单语言写的句子来解决这个问题。

基本上,对于我们定义的任何种类的问题:

动机

我们的目标是将简单的数学表达式翻译成后缀表达式(或逆波兰表示法) 为了简单起见,我们的表达式由十个数字0,...,9和两个操作符+,-组成。例如,表达式2 + 4被翻译成2 4 +。

有关我们问题的上下文无关文法

我们的任务是把中缀表达式翻译成后缀表达式。 让我们为0, ..., 9, +, 和-上的一组中缀表达式定义一个上下文无关文法,其中:

  • 终结符: 0, ..., 9
  • 非终结符: exp, term, +, -
  • 起始符是exp
  • 接下来是产生规则
exp -> exp + term
exp -> exp - term
exp -> term
term -> 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

注意: 这个文法应该根据我们要做的事情进行进一步的转化。例如,我们可能需要消除左递归。 详情请查阅Compilers: Principles,Techniques, and Tools(aka Dragon Book).

解法

我们简单地实现了一个递归下降分析器。为了简单起见,当一个表达式在语法上出错时(例如,根据语法定义,2-34或2+5-是错误的),代码会panic。

pub struct Interpreter<'a> {
    it: std::str::Chars<'a>,
}

impl<'a> Interpreter<'a> {

    pub fn new(infix: &'a str) -> Self {
        Self { it: infix.chars() }
    }

    fn next_char(&mut self) -> Option<char> {
        self.it.next()
    }

    pub fn interpret(&mut self, out: &mut String) {
        self.term(out);

        while let Some(op) = self.next_char() {
            if op == '+' || op == '-' {
                self.term(out);
                out.push(op);
            } else {
                panic!("Unexpected symbol '{}'", op);
            }
        }
    }

    fn term(&mut self, out: &mut String) {
        match self.next_char() {
            Some(ch) if ch.is_digit(10) => out.push(ch),
            Some(ch) => panic!("Unexpected symbol '{}'", ch),
            None => panic!("Unexpected end of string"),
        }
    }
}

pub fn main() {
    let mut intr = Interpreter::new("2+3");
    let mut postfix = String::new();
    intr.interpret(&mut postfix);
    assert_eq!(postfix, "23+");

    intr = Interpreter::new("1-2+3-4");
    postfix.clear();
    intr.interpret(&mut postfix);
    assert_eq!(postfix, "12-3+4-");
}

讨论

可能有一种错误的看法,认为解释器设计模式是关于形式语言的文法设计和这些文法的分析器的实现。 事实上,这种模式是以一种更具体的方式来表达问题实例,并实现解决这些问题实例的函数/类/结构体。 Rust语言有macro_rules!,允许定义特殊的语法和如何将这种语法扩展到源代码的规则。

在下面的例子中,我们创建了一个简单的macro_rules!,计算n维向量的欧几里得长度。 写norm!(x,1,2)可能比把x,1,2打包成一个Vec并调用一个计算长度的函数更容易表达和更有效率。

macro_rules! norm {
    ($($element:expr),*) => {
        {
            let mut n = 0.0;
            $(
                n += ($element as f64)*($element as f64);
            )*
            n.sqrt()
        }
    };
}

fn main() {
    let x = -3f64;
    let y = 4f64;

    assert_eq!(3f64, norm!(x));
    assert_eq!(5f64, norm!(x, y));
    assert_eq!(0f64, norm!(0, 0, 0)); 
    assert_eq!(1f64, norm!(0.5, -0.5, 0.5, -0.5));
}

参见

Latest commit 9834f57 on 25 Aug 2021

新类型

如果在某些情况下,我们希望一个类型的行为类似于另一个类型,或者在编译时强制执行一些行为,而仅仅使用类型别名是不够的,怎么办?

例如,如果我们出于安全考虑(如密码),想为String创建一个自定义的Display实现。

对于这种情况,我们可以使用Newtype模式来提供类型安全和封装。

描述

使用单个字段的元组结构体为一个类型做不透明包装。 这将创建一个新的类型,而不是一个类型的别名(type项)。

例子

// Some type, not necessarily in the same module or even crate.
struct Foo {
    //..
}

impl Foo {
    // These functions are not present on Bar.
    //..
}

// The newtype.
pub struct Bar(Foo);

impl Bar {
    // Constructor.
    pub fn new(
        //..
    ) -> Self {

        //..

    }

    //..
}

fn main() {
    let b = Bar::new(...);

    // Foo and Bar are type incompatible, the following do not type check.
    // let f: Foo = b;
    // let b: Bar = Foo { ... };
}

动机

新类型的主要动机是抽象化。它允许你在类型之间共享实现细节,同时精确控制接口。 通过使用新类型而不是将实现类型作为API的一部分公开,它允许你向后兼容地改变实现。

新类型可以用来区分单位,例如,包装f64以获得可区分的Miles和Kms。

优势

被包装的类型和包装后的类型不是类型兼容的(相对于使用type),所以新类型的用户永远不会“混淆“包装前后的类型。

新类型是一个零成本的抽象——没有运行时的开销。

隐私系统确保用户无法访问被包装的类型(如果字段是私有的,默认情况下是私有的)。

劣势

新类型的缺点(尤其是与类型别名相比)是没有特殊的语言支持。这意味着可能会有许多模板代码。 你需要为你想在包装类型上公开的每个方法提供一个”通过“方法,并为你想在包装类型上实现的每个trait提供一个实现。

讨论

新类型在Rust代码中非常常见。抽象或代表单位是最常见的用途,但它们也可以用于其他原因:

  • 限制功能(减少暴露的函数或实现的trait),
  • 使一个具有复制语义的类型具有移动语义,
  • 通过提供一个更具体的类型,从而隐藏内部类型来实现抽象, 例如,
pub struct Foo(Bar<T1, T2>);

这里,Bar可能是一些公共的、通用的类型,T1和T2是一些内部类型。 我们模块的用户不应该知道我们通过使用Bar来实现Foo,但我们在这里真正隐藏的是T1和T2类型,以及它们如何与Bar一起使用。

参见

Latest commit 11a0a13 Dec 14 2021

有守护的RAII

描述

RAII代表"Resource Acquisition is Initialisation",”资源获取即初始化“。 该模式的本质是,资源初始化在对象的构造器中完成,最终化(资源释放)在析构器中完成。 这种模式在Rust中得到了扩展,即使用RAII对象作为某些资源的守护对象,并依靠类型系统来确保访问总是由守护对象来调解。

例子

互斥守护是std库中这种模式的典型例子(这是真正实现的简化版本):

use std::ops::Deref;

struct Foo {}

struct Mutex<T> {
    // We keep a reference to our data: T here.
    //..
}

struct MutexGuard<'a, T: 'a> {
    data: &'a T,
    //..
}

// Locking the mutex is explicit.
impl<T> Mutex<T> {
    fn lock(&self) -> MutexGuard<T> {
        // Lock the underlying OS mutex.
        //..

        // MutexGuard keeps a reference to self
        MutexGuard {
            data: self,
            //..
        }
    }
}

// Destructor for unlocking the mutex.
impl<'a, T> Drop for MutexGuard<'a, T> {
    fn drop(&mut self) {
        // Unlock the underlying OS mutex.
        //..
    }
}

// Implementing Deref means we can treat MutexGuard like a pointer to T.
impl<'a, T> Deref for MutexGuard<'a, T> {
    type Target = T;

    fn deref(&self) -> &T {
        self.data
    }
}

fn baz(x: Mutex<Foo>) {
    let xx = x.lock();
    xx.foo(); // foo is a method on Foo.
    // The borrow checker ensures we can't store a reference to the underlying
    // Foo which will outlive the guard xx.

    // x is unlocked when we exit this function and xx's destructor is executed.
}

动机

如果一个资源在使用后必须进行最终处理,RAII可以用来进行最终处理。 如果在最终处理后访问该资源是一个错误,那么这个模式可以用来防止这种错误。

优势

防止在资源没有最终处理和在最终处理后使用资源时出现错误。

讨论

RAII是一种有用的模式,可以确保资源被适当地取消分配或被最终处理。 我们可以利用Rust中的借用检查器来静态地防止在最终处理完成后使用资源所产生的错误。

借用检查器的核心目的是确保对数据的引用不会超过该数据的生命周期。 RAII守护模式之所以有效,是因为守护对象包含了对底层资源的引用,并且只暴露了这种引用。 Rust确保守护对象不能超过底层资源的生命周期,并且守护对象所调解资源的引用不能超过守护对象的生命周期。 为了解这一点,检查一下没有生命周期标注的deref的签名是有帮助的。

fn deref<'a>(&'a self) -> &'a T {
    //..
}

返回的资源引用与self具有相同的生命周期('a)。 因此,借用检查器确保对T的引用的生命周期短于(不超过)self的生命周期。

请注意,实现Deref并不是这个模式的核心部分,它只是让使用守护对象更符合人体工程学。 在守护对象上实现一个get方法也同样有效。

参见

惯常做法:析构器中的最终处理

RAII是C++中的一种常见模式:cppreference.com, wikipedia.

风格指南条目 (目前仅是占位符)。

Latest commit b809265 on 22 Apr 2021

策略(也称 政策)

描述

策略设计模式是一种实现关注点分离的技术。它还允许通过依赖反转来解耦软件模块。

策略模式的基本思想是,给定一个解决特定问题的算法,我们只在抽象层面上定义算法的骨架,并将具体的算法实现分成不同的部分。

这样,使用该算法的客户可以选择一个具体的实现,而一般的算法工作流程保持不变。 换句话说,类的抽象规范并不取决于派生类的具体实现,但具体实现必须遵守抽象规范。 这就是为什么我们称之为“依赖反转”。

动机

想象一下,我们正在做一个每月都会生成报告的项目。 我们需要以不同的格式(策略)生成报告,例如,以JSON或Plain Text格式。 但事情随着时间的推移而变化,我们不知道未来可能得到什么样的要求。 例如,我们可能需要以一种全新的格式生成我们的报告,或者只是修改现有的一种格式。

例子

在这个例子中,我们的不变量(或抽象)是Context、Formatter和Report,而Text和Json是我们的策略结构体。 这些策略必须实现Formatter的trait。

use std::collections::HashMap;

type Data = HashMap<String, u32>;

trait Formatter {
    fn format(&self, data: &Data, buf: &mut String);
}

struct Report;

impl Report {
    // Write should be used but we kept it as String to ignore error handling
    fn generate<T: Formatter>(g: T, s: &mut String) {
        // backend operations...
        let mut data = HashMap::new();
        data.insert("one".to_string(), 1);
        data.insert("two".to_string(), 2);
        // generate report
        g.format(&data, s);
    }
}

struct Text;
impl Formatter for Text {
    fn format(&self, data: &Data, buf: &mut String) {
        for (k, v) in data {
            let entry = format!("{} {}\n", k, v);
            buf.push_str(&entry);
        }
    }
}

struct Json;
impl Formatter for Json {
    fn format(&self, data: &Data, buf: &mut String) {
        buf.push('[');
        for (k, v) in data.into_iter() {
            let entry = format!(r#"{{"{}":"{}"}}"#, k, v);
            buf.push_str(&entry);
            buf.push(',');
        }
        buf.pop(); // remove extra , at the end
        buf.push(']');
    }
}

fn main() {
    let mut s = String::from("");
    Report::generate(Text, &mut s);
    assert!(s.contains("one 1"));
    assert!(s.contains("two 2"));

    s.clear(); // reuse the same buffer
    Report::generate(Json, &mut s);
    assert!(s.contains(r#"{"one":"1"}"#));
    assert!(s.contains(r#"{"two":"2"}"#));
}

优势

主要优势是关注点分离。 例如,在这种情况下,Report对Json和Text的具体实现一无所知,而输出实现则不关心数据如何被预处理、存储和获取。 他们唯一需要知道的是上下文和要实现的特定trait和方法,即Formatter和format。

劣势

每个策略必须至少有一个模块,所以模块的数量随着策略的数量而增加。 如果有许多策略可供选择,那么用户就必须知道策略之间有什么不同。

讨论

在前面的例子中,所有策略都在一个文件中实现。 提供不同策略的方法包括:

  • 都在一个文件中(如本例所示,类似于作为模块分离的情况)
  • 作为模块分开,例如,formatter::json模块,formatter::text模块
  • 使用编译器特性标记,例如json特征,text特征
  • 作为crate分开,例如:jsoncrate,textcrate

Serde crate是策略模式在实践中的一个好例子。 Serde允许通过为我们的类型手动实现Serialize和Deserializetrait来对序列化行为进行完全定制。 例如,我们可以很容易地将serde_json与serde_cbor交换,因为它们暴露了类似的方法。 有了这一点,使得助手crateserde_transcode更加有用和符合人体工程学。

然而,我们不需要使用traits就可以在Rust中设计这种模式。

下面的玩具例子演示了使用Rustclosures策略模式的想法:

struct Adder;
impl Adder {
    pub fn add<F>(x: u8, y: u8, f: F) -> u8
    where
        F: Fn(u8, u8) -> u8,
    {
        f(x, y)
    }
}

fn main() {
    let arith_adder = |x, y| x + y;
    let bool_adder = |x, y| {
        if x == 1 || y == 1 {
            1
        } else {
            0
        }
    };
    let custom_adder = |x, y| 2 * x + y;

    assert_eq!(9, Adder::add(4, 5, arith_adder));
    assert_eq!(0, Adder::add(0, 0, bool_adder));
    assert_eq!(5, Adder::add(1, 3, custom_adder));
}

事实上,Rust已经在Options的map方法中使用了这个想法:

fn main() {
    let val = Some("Rust");

    let len_strategy = |s: &str| s.len();
    assert_eq!(4, val.map(len_strategy).unwrap());

    let first_byte_strategy = |s: &str| s.bytes().next().unwrap();
    assert_eq!(82, val.map(first_byte_strategy).unwrap());
}

参见

Latest commit 9834f57 on 25 Aug 2021

访问器

描述

访问器封装了一种在对象的异质集合上操作的算法。 它允许在同一数据上写入多种不同的算法,而不必修改数据(或其主要行为)。

此外,访问器模式允许将对象集合的遍历与对每个对象进行的操作分开。

例子

// The data we will visit
mod ast {
    pub enum Stmt {
        Expr(Expr),
        Let(Name, Expr),
    }

    pub struct Name {
        value: String,
    }

    pub enum Expr {
        IntLit(i64),
        Add(Box<Expr>, Box<Expr>),
        Sub(Box<Expr>, Box<Expr>),
    }
}

// The abstract visitor
mod visit {
    use ast::*;

    pub trait Visitor<T> {
        fn visit_name(&mut self, n: &Name) -> T;
        fn visit_stmt(&mut self, s: &Stmt) -> T;
        fn visit_expr(&mut self, e: &Expr) -> T;
    }
}

use visit::*;
use ast::*;

// An example concrete implementation - walks the AST interpreting it as code.
struct Interpreter;
impl Visitor<i64> for Interpreter {
    fn visit_name(&mut self, n: &Name) -> i64 { panic!() }
    fn visit_stmt(&mut self, s: &Stmt) -> i64 {
        match *s {
            Stmt::Expr(ref e) => self.visit_expr(e),
            Stmt::Let(..) => unimplemented!(),
        }
    }

    fn visit_expr(&mut self, e: &Expr) -> i64 {
        match *e {
            Expr::IntLit(n) => n,
            Expr::Add(ref lhs, ref rhs) => self.visit_expr(lhs) + self.visit_expr(rhs),
            Expr::Sub(ref lhs, ref rhs) => self.visit_expr(lhs) - self.visit_expr(rhs),
        }
    }
}

人们可以实现更多的访问器,例如类型检查器,而不需要修改AST数据。

动机

访问器模式在任何你想将算法应用于异质数据的地方都很有用。 如果数据是同质的,你可以使用一个类似迭代器的模式。 使用访问器对象(而不是功能化的方法)允许访问器是有状态的,从而在节点之间交流信息。

讨论

visit_*方法通常会返回void(与例子中不同)。 在这种情况下,有可能将遍历代码抽取出来,并在算法之间共享(也可以提供noop默认方法)。 在Rust中,常见的方法是为每个数据点提供walk_*函数。 例如,

pub fn walk_expr(visitor: &mut Visitor, e: &Expr) {
    match *e {
        Expr::IntLit(_) => {},
        Expr::Add(ref lhs, ref rhs) => {
            visitor.visit_expr(lhs);
            visitor.visit_expr(rhs);
        }
        Expr::Sub(ref lhs, ref rhs) => {
            visitor.visit_expr(lhs);
            visitor.visit_expr(rhs);
        }
    }
}

在其他语言中(例如Java),数据通常有一个accept方法,担任同样的职责。

参见

访问器模式是大多数OO语言中的一种常见模式。

访问器模式

fold模式与visitor类似,但产生一个新版本的被访数据结构。

Latest commit b809265 on 22 Apr 2021

创建型模式

来自Wikipedia:

处理对象创建机制的设计模式,试图以适合情况的方式创建对象。 对象创建的基本形式可能导致设计问题或增加设计的复杂性。 创建型设计模式通过某种方式控制这种对象的创建来解决这个问题。

Latest commit 606bcff on 26 Feb 2021

生成器

描述

通过对生成器助手的调用构造一个对象。

例子


#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq)]
pub struct Foo {
    // Lots of complicated fields.
    bar: String,
}

impl Foo {
    // This method will help users to discover the builder
    pub fn builder() -> FooBuilder {
        FooBuilder::default()
    }
}

#[derive(Default)]
pub struct FooBuilder {
    // Probably lots of optional fields.
    bar: String,
}

impl FooBuilder {
    pub fn new(/* ... */) -> FooBuilder {
        // Set the minimally required fields of Foo.
        FooBuilder {
            bar: String::from("X"),
        }
    }

    pub fn name(mut self, bar: String) -> FooBuilder {
        // Set the name on the builder itself, and return the builder by value.
        self.bar = bar;
        self
    }

    // If we can get away with not consuming the Builder here, that is an
    // advantage. It means we can use the FooBuilder as a template for constructing
    // many Foos.
    pub fn build(self) -> Foo {
        // Create a Foo from the FooBuilder, applying all settings in FooBuilder
        // to Foo.
        Foo { bar: self.bar }
    }
}

#[test]
fn builder_test() {
    let foo = Foo {
        bar: String::from("Y"),
    };
    let foo_from_builder: Foo = FooBuilder::new().name(String::from("Y")).build();
    assert_eq!(foo, foo_from_builder);
}
}

动机

当你需要许多构造器或构造有副作用时,这很有用。

优势

将构建的方法与其他方法分开。

防止构造器的泛滥。

可用于单行的初始化,也可用于更复杂的构造。

劣势

比直接创建一个结构体对象,或一个简单的构造器更复杂。

讨论

这种模式在Rust中比其他许多语言更频繁地出现(对于更简单的对象),因为Rust缺乏重载。 因为你只能有一个给定名称的单一方法,所以在Rust中拥有多个构造器就不如在C++、Java或其他语言中那么好。

这种模式通常用于生成器对象本身就很有用,而不仅仅是一个生成器。 例如,std::process::Command是Child(一个进程)的生成器。 在这些情况下,不使用T'和TBuilder'的命名模式。

这个例子通过值传递的方式获取并返回生成器。 通常情况下,将生成器作为一个可变引用来获取和返回,更符合人体工程学(也更高效)。 借用检查器使这一工作自然进行。 这种方法的好处是,人们可以写出像这样的代码:

let mut fb = FooBuilder::new();
fb.a();
fb.b();
let f = fb.build();

以及FooBuilder::new().a().b().build()风格。

参见

Latest commit 9834f57 on 25 Aug 2021

Fold

描述

在数据集的每一项上运行一个算法,以创建一个新的项,从而创建一个全新的集合。

我不清楚这里的词源。 Rust编译器中使用了fold和folder这两个术语,尽管在我看来,它更像map,而不是通常意义上的fold。 更多细节见下面的讨论。

例子

// The data we will fold, a simple AST.
mod ast {
    pub enum Stmt {
        Expr(Box<Expr>),
        Let(Box<Name>, Box<Expr>),
    }

    pub struct Name {
        value: String,
    }

    pub enum Expr {
        IntLit(i64),
        Add(Box<Expr>, Box<Expr>),
        Sub(Box<Expr>, Box<Expr>),
    }
}

// The abstract folder
mod fold {
    use ast::*;

    pub trait Folder {
        // A leaf node just returns the node itself. In some cases, we can do this
        // to inner nodes too.
        fn fold_name(&mut self, n: Box<Name>) -> Box<Name> { n }
        // Create a new inner node by folding its children.
        fn fold_stmt(&mut self, s: Box<Stmt>) -> Box<Stmt> {
            match *s {
                Stmt::Expr(e) => Box::new(Stmt::Expr(self.fold_expr(e))),
                Stmt::Let(n, e) => Box::new(Stmt::Let(self.fold_name(n), self.fold_expr(e))),
            }
        }
        fn fold_expr(&mut self, e: Box<Expr>) -> Box<Expr> { ... }
    }
}

use fold::*;
use ast::*;

// An example concrete implementation - renames every name to 'foo'.
struct Renamer;
impl Folder for Renamer {
    fn fold_name(&mut self, n: Box<Name>) -> Box<Name> {
        Box::new(Name { value: "foo".to_owned() })
    }
    // Use the default methods for the other nodes.
}

在AST上运行Renamer的结果是一个与旧AST相同的新AST,但每个名字都改为foo。 现实生活中的folder可能会在结构本身的节点之间保留一些状态。

也可以定义一个folder,将一个数据结构映射到一个不同的(但通常是类似的)数据结构。 例如,我们可以将ASTfold成HIR树(HIR代表high-level intermediate representation,高级中间表示法)。

动机

通过对结构中的每个节点进行一些操作来映射一个数据结构是很常见的。 对于简单数据结构的简单操作,可以使用Iterator::map来完成。 对于更复杂的操作,也许前面的节点会影响后面节点的操作,或者在数据结构上的迭代不是简单的,使用fold模式更合适。

与访问器模式一样,fold模式允许我们将数据结构的遍历与对每个节点进行的操作分开。

讨论

以这种方式映射数据结构在函数式语言中是很常见的。 在OO语言中,更常见的是在原地改变数据结构。 “函数式”方法在Rust中很常见,主要是由于对不可变性的偏好。 使用新的数据结构,而不是改变旧的数据结构,在大多数情况下使代码推理更容易。

通过改变fold_*方法接受节点的方式,可以对效率和可重用性之间的权衡进行调整。

在上面的例子中,我们对Box指针进行操作。由于这些指针排他地拥有其数据,数据结构的原始副本不能被重新使用。 另一方面,如果一个节点没有改变,重新使用它是非常有效的。

如果我们对借来的引用进行操作,原来的数据结构可以被重用;但是,一个节点即使没有变化,也必须被克隆,这可能很昂贵。

使用引用计数指针可以获得两全其美的效果——我们可以重用原来的数据结构,而且我们不需要克隆未改变的节点。 然而,它们在使用上不太符合人体工程学,而且意味着数据结构不能被改变。

参见

迭代器有一个fold方法,但是这个方法将一个数据结构fold成一个值,而不是fold成一个新的数据结构。迭代器的map更像是这种fold模式。

在其他语言中,fold通常是在Rust迭代器的意义上使用,而不是这种模式。 一些函数式语言拥有强大的结构,可以对数据结构进行灵活的映射。

访问器模式与fold密切相关。 它们的共同概念是在一个数据结构上遍历,对每个节点进行操作。 然而,访问器模式并不创建一个新的数据结构,也不消耗旧的数据结构。

Latest commit 9834f57 on 25 Aug 2021

结构型模式

来自Wikipedia:

通过确定一种实现实体间关系的简单方法来简化设计的设计模式

Latest commit 606bcff on 26 Feb 2021

将结构体组合在一起以获得更好的借用

TODO - 这不是一个简洁的名字

描述

有时一个大的结构体会给借用检查器带来问题——虽然字段可以被独立借用,但有时整个结构体最终会被一次性使用,从而妨碍其他用途。 一个解决方案可能是将该结构体分解为几个较小的结构体。 然后将这些结构体组合为原始结构体。 然后每个结构体都可以被单独借用,并具有更灵活的行为。

这往往会在其他方面带来更好的设计:应用这种设计模式往往能发现更小的功能单元。

例子

下面是一个精心设计的例子,说明借用检查器挫败了我们使用结构体的计划:


#![allow(unused)]
fn main() {
struct A {
    f1: u32,
    f2: u32,
    f3: u32,
}

fn foo(a: &mut A) -> &u32 { &a.f2 }
fn bar(a: &mut A) -> u32 { a.f1 + a.f3 }

fn baz(a: &mut A) {
    // The later usage of x causes a to be borrowed for the rest of the function.
    let x = foo(a);
    // Borrow checker error:
    // let y = bar(a); // ~ ERROR: cannot borrow `*a` as mutable more than once
                       //          at a time
    println!("{}", x);
}
}

我们可以应用这种设计模式,将A重构为两个较小的结构体,从而解决借用检查问题:


#![allow(unused)]
fn main() {
// A is now composed of two structs - B and C.
struct A {
    b: B,
    c: C,
}
struct B {
    f2: u32,
}
struct C {
    f1: u32,
    f3: u32,
}

// These functions take a B or C, rather than A.
fn foo(b: &mut B) -> &u32 { &b.f2 }
fn bar(c: &mut C) -> u32 { c.f1 + c.f3 }

fn baz(a: &mut A) {
    let x = foo(&mut a.b);
    // Now it's OK!
    let y = bar(&mut a.c);
    println!("{}", x);
}
}

动机

TODO 为什么以及在哪里应该使用该模式。

优势

让你可以绕过借用检查器的限制。

通常会产生一个更好的设计。

劣势

导致更多冗长的代码。

有时,较小的结构体并不是很好的抽象,所以我们最终得到了一个更糟糕的设计。 这可能是一种“代码气味”,表明该程序应该以某种方式进行重构。

讨论

这种模式在没有借用检查器的语言中是不需要的,所以从这个意义上说是Rust独有的。 然而,将功能单元做得更小,往往能使代码更简洁:这是软件工程中公认的原则,与语言无关。

这个模式依赖于Rust的借用检查器能够独立借用字段。 在这个例子中,借用检查器知道a.b和a.c是不同的,可以独立借用,它不会试图借用a的全部,这将使这个模式毫无用处。

Latest commit 606bcff on 26 Feb 2021

倾向于较小的crates

描述

倾向于选择能做好一件事的较小的crates。

Cargo和crates.io使得添加第三方库变得很容易,比C或C++等语言要容易得多。 此外,由于crates.io上的包在发布后不能被编辑或删除,任何现在能工作的构建在未来也应该继续工作。 我们应该利用这种工具的优势,使用更小、更细的依赖关系。

优势

  • 小的crates更容易理解,并鼓励更多的模块化代码。
  • Crates允许在项目之间重用代码。 例如,urlcrate是作为Servo浏览器引擎的一部分而开发的,但后来在该项目之外被广泛使用。
  • 由于Rust的编译单元是crate,将一个项目分割成多个crate可以使更多的代码被并行构建。

劣势

  • 当一个项目同时依赖一个crate的多个冲突版本时,这可能导致“依赖地狱”。例如,urlcrate有1.0和0.5两个版本。由于url:1.0的Url和url:0.5的Url是不同的类型,使用url:0.5的HTTP客户端将不接受来自使用url:1.0的Web爬虫的Url值。
  • crates.io上的软件包没有经过组织。一个crate可能写得很差,有无用的文档,或直接是恶意的。
  • 两个小crate的优化程度可能低于一个大crate,因为编译器默认不执行链接时优化(link-time optimization,LTO)。

例子

ref_slice 提供将&T转换为&[T]函数的crate。

url提供处理URLs工具的crate。

num_cpus提供一个函数来查询机器上的CPU数量的crate。

参见

Latest commit 881f51f on 8 Mar 2021

把不安全因素放在小模块中

描述

如果你有unsafe的代码,创建一个尽可能小的模块,它可以坚持在不安全的基础上建立一个最小的安全接口所需的不变量。 将其嵌入到一个更大的模块中,该模块只包含安全代码,并提供一个符合人体工程学的接口。 注意,外部模块可以包含直接调用不安全代码的不安全函数和方法。用户可以用它来获得速度上的好处。

优势

  • 限制必须被审计的不安全代码。
  • 编写外部模块要容易得多,因为你可以依靠内部模块的保证。

劣势

  • 有时,可能很难找到一个合适的接口。
  • 抽象可能会带来效率低下的问题。

例子

  • toolshedcrate在子模块中包含其不安全的操作,为用户提供了一个安全的接口。
  • std的String类是对Vec<u8>的封装,增加了内容必须是有效UTF-8的不变量。对String的操作确保了这种行为。 然而,用户可以选择使用一个unsafe的方法来创建一个String,在这种情况下,他们有责任保证内容的有效性。

参见

Latest commit 044d365 on 25 Nov 2021

FFI 模式

编写FFI代码本身就是一个完整的课程。 尽管如此,这还是有几个惯常做法可以作为指导,避免对unsafe Rust缺乏经验的用户踩坑。

本节包含在开发FFI时可能有用的设计模式。

  1. 基于对象的API是具有良好内存安全特性的设计,对代码安全与否有明确界限。

  2. 类型合并——将多个Rust类型组合成一个不透明的“对象”。

Latest commit 606bcff on 26 Feb 2021

基于对象的API

描述

当在Rust中设计暴露于其他语言的API时,有一些重要的设计原则与正常的Rust API设计相反:

  1. 所有的封装类型都应该被Rust拥有,由用户管理,并且不透明。
  2. 所有的事务性数据类型都应该由用户拥有,并且是透明的。
  3. 所有的库行为应该是作用于封装类型的函数。
  4. 所有的库行为都应该被封装成类型,且不是基于结构,而是基于出处/生命周期。

动机

Rust有对其他语言的内置FFI支持。 它为crate作者提供一种方法,通过不同的ABI(尽管这对这种做法并不重要)提供与C兼容的API。

设计良好的Rust FFI遵循了C语言API的设计原则,同时在Rust中尽可能地减少设计的妥协。任何外部API都有三个目标:

  1. 使其易于在目标语言中使用。
  2. 尽可能避免API在Rust侧控制内部不安全性。
  3. 尽可能地减少内存不安全性和Rustundefined behaviour的可能性。

Rust代码必须在一定程度上相信外部语言的内存安全性。 然而,Rust侧每一点unsafe的代码都是产生错误的机会,或者加剧了undefined behaviour。

例如,如果一个指针的出处是错误的,这可能是由于无效的内存访问造成的段错误。 同时,如果它被不安全的代码所操纵,它就可能成为全面的堆损坏。

基于对象的API设计允许编写具有良好内存安全特性的垫片代码,拥有明确的安全界限。

代码示例

POSIX标准定义了访问文件式数据库的API,被称为DBM。 它是一个“基于对象”的API的优秀例子。

下面是C语言的定义,对参与FFI的人来说应该很容易读懂。 下面的评论应该有助于解释细微差别。

struct DBM;
typedef struct { void *dptr, size_t dsize } datum;

int     dbm_clearerr(DBM *);
void    dbm_close(DBM *);
int     dbm_delete(DBM *, datum);
int     dbm_error(DBM *);
datum   dbm_fetch(DBM *, datum);
datum   dbm_firstkey(DBM *);
datum   dbm_nextkey(DBM *);
DBM    *dbm_open(const char *, int, mode_t);
int     dbm_store(DBM *, datum, datum, int);

这个API定义了两种类型:DBM和datum。

DBM类型即上文所称的“封装类型”。 它被设计为包含内部状态,并作为库行为的入口。

它对用户是完全不透明的,用户不能自己创建一个DBM,因为他们不知道它的大小和布局。 相反,他们必须调用dbm_open,而这只能给他们一个指向DBM的指针。

这意味着所有的DBM在Rust意义上是由库“拥有”的。 未知大小的内部状态被保存在由库控制的内存中,而不是用户。 用户只能通过open和close来管理它的生命周期,并通过其他函数对它进行操作。

datum类型即上文所称的“事务性数据类型”。 它被设计用来促进库和用户之间的信息交流。

该数据库被设计用来存储“非结构化数据”,没有预先定义的长度或意义。 因此,datum相当于C语言中的Rust slice:一串字节,以及有多少个字节的计数。主要的区别是没有类型信息,也就是void所表示的。

请记住,这个头文件是从库的角度来写的。 用户可能有一些他们正在使用的类型,这些类型有已知的大小。 但是库并不关心,根据C语言的转换规则,指针后面的任何类型都可以被转换为void。

如前所述,这种类型对用户来说是透明的,同时这个类型也是由用户拥有的。 由于其内部指针,这有微妙的影响。 问题是,谁拥有这个指针所指向的内存?

对于最佳的内存安全性来说,答案是“用户”。 但是在诸如检索一个值的情况下,用户不知道如何正确地分配它(因为他们不知道这个值有多长)。 在这种情况下,库的代码应该使用用户可以访问的堆——比如C库的malloc和free——然后在Rust意义上转移所有权。

这似乎都是猜测,但这就是C语言中指针的含义。 它和Rust的意思是一样的:“用户定义的生命周期”。 库的用户需要阅读文档,以便正确使用它。 也就是说,有一些决定,如果用户做错了,会产生或大或小的后果。 尽量减少这些是这个最佳实践的目的,关键是要转移一切透明事务的所有权。

优势

这使用户必须坚持的内存安全保证的数量降到相对较少:

  1. 不要用不是由dbm_open返回的指针调用任何函数(无效访问或损坏)。
  2. 关闭之后,不要在指针上调用任何函数(在free后使用)。
  3. 任何datum上的dptr必须是NULL,或者指向一个有效的内存片,其长度为所声明的长度。

此外,它还避免了很多指针出处的问题。 为了理解原因,让我们深入考虑一个替代方案:键的迭代。

Rust的迭代器是众所周知的。 当实现一个迭代器时,程序员会给它的所有者做一个单独的类型,有一定的生命周期,并实现Iteratortrait。

下面是在Rust中对DBM进行迭代的方法:

struct Dbm { ... }

impl Dbm {
    /* ... */
    pub fn keys<'it>(&'it self) -> DbmKeysIter<'it> { ... }
    /* ... */
}

struct DbmKeysIter<'it> {
    owner: &'it Dbm,
}

impl<'it> Iterator for DbmKeysIter<'it> { ... }

由于Rust的保证,这样做是干净的、习惯性的,而且是安全的。 然而,考虑一下一个直接的API翻译会是什么样子:

#[no_mangle]
pub extern "C" fn dbm_iter_new(owner: *const Dbm) -> *mut DbmKeysIter {
    // THIS API IS A BAD IDEA! For real applications, use object-based design instead.
}
#[no_mangle]
pub extern "C" fn dbm_iter_next(
    iter: *mut DbmKeysIter,
    key_out: *const datum
) -> libc::c_int {
    // THIS API IS A BAD IDEA! For real applications, use object-based design instead.
}
#[no_mangle]
pub extern "C" fn dbm_iter_del(*mut DbmKeysIter) {
    // THIS API IS A BAD IDEA! For real applications, use object-based design instead.
}

这个API丢失了一个关键信息:迭代器的生命周期不能超过拥有它的Dbm对象的生命周期。 库的用户可以使用它,使迭代器的生命周期超过它所迭代的数据,从而导致读取未初始化的内存。

这个用C语言编写的例子包含一个错误,将在后面解释:

int count_key_sizes(DBM *db) {
    // DO NOT USE THIS FUNCTION. IT HAS A SUBTLE BUT SERIOUS BUG!
    datum key;
    int len = 0;

    if (!dbm_iter_new(db)) {
        dbm_close(db);
        return -1;
    }

    int l;
    while ((l = dbm_iter_next(owner, &key)) >= 0) { // an error is indicated by -1
        free(key.dptr);
        len += key.dsize;
        if (l == 0) { // end of the iterator
            dbm_close(owner);
        }
    }
    if l >= 0 {
        return -1;
    } else {
        return len;
    }
}

这是一个经典bug。下面是迭代器返回迭代结束标记时的情况:

  1. 循环条件将l设置为0,并进入循环,因为0 >= 0。
  2. 长度递增,但在此情况下为0。
  3. if语句为真,所以数据库被关闭。这里应该有一个break语句。
  4. 循环条件再次执行,引起对已关闭对象的next调用。

这个错误最糟糕的地方是什么? 如果Rust的实现很小心的话,这段代码在大多数时候都能正常工作! 如果Dbm对象的内存没有被立即重用,内部检查几乎肯定会失败,导致迭代器返回一个-1表示错误。 但偶尔也会造成段错误,甚至更糟糕的是,会造成无意义的内存损坏!

这些都不是Rust所能避免的。 从它的角度来看,它把这些对象放在了它的堆上,返回了它们的指针,并放弃了对它们生命周期的控制。 C语言的代码只是必须“玩得好”。

程序员必须阅读和理解API文档。 虽然有些人认为这在C语言中是理所当然的,但一个好的API设计可以减轻这种风险。 DBM的POSIX API通过将迭代器的所有权与它的父级合并来做到这一点。

datum   dbm_firstkey(DBM *);
datum   dbm_nextkey(DBM *);

因此,所有的生命周期都被捆绑在一起,避免了不安全因素。

劣势

然而,这种设计选择也有一些缺点,也应予以考虑。

首先,API本身变得不那么具有表达性。 在POSIX DBM中,每个对象只有一个迭代器,而且每次调用都会改变其状态。 这比几乎所有语言中的迭代器都要限制得多,尽管它是安全的。 也许对于其他相关的对象,其生命周期没有那么多层次,这种限制比安全性更有代价。

其次,根据API各部分的关系,可能会涉及大量的设计工作。 许多比较容易的设计点都有其他模式与之相关:

  • 类型合并将多个Rust类型组合成一个不透明的“对象”。

  • FFI 错误传递解释了用整数值和哨兵返回值(如NULL指针)的错误处理。

  • [接受外部字符串]](../../idioms/ffi/accepting-strings.md)允许以最小的不安全代码接受字符串,并且比向FFI传递字符串更容易做对。

然而,并不是每个API都可以这样做。 至于谁是他们的受众,则取决于程序员的最佳判断。

Latest commit 9834f57 on 25 Aug 2021

类型合并

描述

这种模式的设计是为了允许优雅地处理多个相关类型,同时最大限度地减少内存不安全的表面积。

Rust的别名规则的基石之一是生命周期。 这确保了类型之间的许多访问模式都是内存安全的,包括数据竞争安全。

然而,当Rust类型被输出到其他语言时,它们通常被转化为指针。 在Rust中,指针意味着“用户管理着被指向者的生命周期”。 避免内存不安全是他们的责任。

因此需要对用户的代码有一定程度的信任,特别是在Rust无能为力的释放后使用方面。 然而,有些API设计对另一种语言编写的代码造成的负担比其他设计更重。

风险最低的API是“综合封装”,即与一个对象的所有可能的交互都被放入一个“封装器类型”中,保持着Rust API的整洁。

代码示例

为了理解这一点,让我们看一下导出API的一个经典例子:通过一个集合进行迭代。

该API看起来像这样:

  1. 迭代器被初始化为first_key。
  2. 每次调用`next_key'将推进迭代器。
  3. 如果迭代器在最后,调用next_key将不做任何事情。
  4. 如上所述,迭代器被“包裹”在集合中(与原始Rust API不同)。

如果迭代器高效地实现了nth(),那么就有可能使它对每个函数的调用都是短暂的:

struct MySetWrapper {
    myset: MySet,
    iter_next: usize,
}

impl MySetWrapper {
    pub fn first_key(&mut self) -> Option<&Key> {
        self.iter_next = 0;
        self.next_key()
    }
    pub fn next_key(&mut self) -> Option<&Key> {
        if let Some(next) = self.myset.keys().nth(self.iter_next) {
            self.iter_next += 1;
            Some(next)
        } else {
            None
        }
    }
}

这个封装器很简单,不包含任何不安全的代码。

优势

这使得API的使用更加安全,避免了类型之间的生命周期问题。 参见基于对象的API以了解更多关于这样做的好处和避免的陷阱。

劣势

通常情况下,封装类型是相当困难的,有时Rust API的妥协会使事情变得更容易。

举个例子,考虑一个迭代器,它不能高效地实现nth()。 这绝对值得放入特殊的逻辑,使对象在内部处理迭代,或者高效地支持不同的只有外部函数API才会使用的访问模式。

尝试封装迭代器(失败)

为了将任何类型的迭代器正确地封装到API中,封装器需要做C版本的代码会做的事情:擦除迭代器的生命周期,并手动管理它。

可以说,这是相当难的事情。

这里只是说明了一个陷阱。

MySetWrapper的第一个版本看起来像这样:

struct MySetWrapper {
    myset: MySet,
    iter_next: usize,
    // created from a transmuted Box<KeysIter + 'self>
    iterator: Option<NonNull<KeysIter<'static>>>,
}

用transmute来延长生命周期,用指针来隐藏它,这已经很难看了。 但它变得更加糟糕:任何其他操作都会导致Rust的“未定义行为”。

考虑到在迭代过程中,封装器中的MySet可以被其他函数操作,比如为它所迭代的键存储一个新的值。 API并不鼓励这样做,但事实上,一些类似的C库期望这样做。

myset_store的一个简单实现:

pub mod unsafe_module {

    // other module content

    pub fn myset_store(
        myset: *mut MySetWrapper,
        key: datum,
        value: datum) -> libc::c_int {

        // DO NOT USE THIS CODE. IT IS UNSAFE TO DEMONSTRATE A PROLBEM.

        let myset: &mut MySet = unsafe { // SAFETY: whoops, UB occurs in here!
            &mut (*myset).myset
        };

        /* ...check and cast key and value data... */

        match myset.store(casted_key, casted_value) {
            Ok(_) => 0,
            Err(e) => e.into()
        }
    }
}

如果这个迭代器在这个函数被调用时存在,我们就违反了Rust的别名规则之一。 根据Rust的规定,这个块中的可变引用必须对该对象有排他性的访问。 如果迭代器仅仅存在,它就不是排他性的,所以我们有“未定义的行为”!1

为了避免这种情况,我们必须有一种方法来确保可变引用真的是独占的。 这基本上意味着在迭代器的共享引用存在时将其清除,然后再重建它。 在大多数情况下,这仍然会比C版本的效率低。

有些人可能会问:C语言怎么能更有效地做到这一点? 答案是,它作弊了。Rust的别名规则是问题所在,而C只是简单地为了它的指针忽略这些问题。 作为交换,我们经常可以看到在手册中声明在某些或所有情况下“非线程安全”的代码。 事实上,GNU C library有一整个词库专门讨论并发行为!

Rust宁愿让所有的内存都是安全的,既为了安全,也为了优化,这是C代码无法达到的。 被拒绝使用某些捷径是Rust程序员需要付出的代价。

1

对于那些迷惑不解的C程序员来说,迭代器不需要在这段引起未定义行为的代码中被读取。排他性规则也使编译器优化可能导致迭代器的共享引用出现不一致的观察(例如堆栈溢出或为提高效率而重新排序的指令)。 这些观察可能发生在可变引用创建后的任何时间。

Latest commit 606bcff on 26 Feb 2021

反面模式

反面模式是一种解决反复出现的问题的方法,通常是无效的,并有可能产生很大的反面作用。与知道如何解决一个问题一样有价值的是知道不能这样解决这个问题。相对于设计模式,反面模式给我们提供了很好的反例来考虑。反面模式并不局限于代码。例如,一个流程也可以是一个反面模式。

Latest commit 2cd70a5 on 22 Jan 2021

通过Clone来满足借用检查器

描述

借用检查器通过确保以下两种情况来防止Rust用户开发不安全的代码:只存在一个可变引用,或者可能存在多个但都是不可变引用。 如果编写的代码不符合这些条件,当开发者通过克隆变量来解决编译器错误时,就会出现这种反面模式。

例子


#![allow(unused)]
fn main() {
// define any variable
let mut x = 5;

// Borrow `x` -- but clone it first
let y = &mut (x.clone());

// perform some action on the borrow to prevent rust from optimizing this
//out of existence
*y += 1;

// without the x.clone() two lines prior, this line would fail on compile as
// x has been borrowed
// thanks to x.clone(), x was never borrowed, and this line will run.
println!("{}", x);
}

动机

特别是对初学者来说,用这种模式来解决借用检查器的迷惑问题是很诱人的。 然而,这有严重的后果。使用.clone()会导致数据被复制。 两者之间的任何变化都是不同步的——就像存在两个完全独立的变量一样。

有一些特殊情况——Rc<T>被设计用来智能地处理克隆。 它在内部精确地管理着一份数据的副本,克隆它只会克隆引用。

还有Arc<T>,它提供了在堆中分配的T类型的值的共享所有权。 对Arc调用.clone()会产生一个新的Arc实例,它指向与源Arc相同的堆上的分配,同时增加一个引用计数。

一般来说,克隆应该是深思熟虑的,并充分了解后果。 如果克隆被用来使借用检查器的错误消失,那就很可能说明这种反面模式可能在使用。

尽管.clone()暗示着不好的模式,但有时写低效的代码也是可以的,例如在以下情况下:

  • 开发者仍然对所有权不熟悉
  • 代码没有很大的速度或内存限制(如黑客马拉松项目或原型)
  • 满足借用检查器相当复杂,而你更愿意优化可读性而不是性能

如果怀疑有不必要的克隆,在评估是否需要克隆之前,应该充分理解Rust Book的所有权章节

此外,请确保在你的项目中始终运行cargo clippy,它将检测到一些不需要.clone()的情况,例如1, 2, 3或4.

参见

Latest commit 9834f57 on 25 Aug 2021

#![deny(warnings)]

描述

一个善意的crate作者想确保他们的代码在构建时不会出现警告。所以他用以下内容来注释其crate根。

例子


#![allow(unused)]
#![deny(warnings)]

fn main() {
// All is well.
}

优势

注释很短,如果出现错误,会停止构建。

劣势

通过不允许编译器产生构建警告,crate作者失去了Rust引以为傲的稳定性。 有时,新特性或旧的错误特性需要改变处理逻辑,因此,在转为deny之前,会有warn的lint,并有一定的缓冲期。

例如,人们发现一个类型可以有两个具有相同方法的impl块。 这被认为是一个坏主意,但为了使过渡顺利,overlapping-inherent-impls lint被引入,给那些偶然发现这个事实的人一个警告,即使它在未来的版本中将成为一个硬编码错误。

另外,有时API会被废弃,所以在它们消失前使用会发出警告。

当某些事情发生改变,所有这些都有潜在的破坏构建的可能性。

此外,提供额外lint的crate(例如rust-clippy)不能再被使用,除非注释被删除。这可以通过[-cap-lints]来缓解。 命令行参数--cap-lints=warn可将所有denylint错误变成警告。

替代方案

有两种方法可以解决这个问题:第一,我们可以将构建设置与代码解耦;第二,我们可以指明我们想要显式拒绝的lint。

下面的命令行参数在所有警告设置为deny的情况下构建:

RUSTFLAGS="-D warnings" cargo build

这可以由任何个人开发者完成(或者在Travis这样的CI工具中设置,但请记住,当有变化时,这可能会破坏构建),而不需要对代码进行修改。

另外,我们可以在代码中指定我们想要deny的lint。 下面是一个(希望)可以安全拒绝的警告lint的列表(截至Rustc 1.48.0):

#[deny(bad-style,
       const-err,
       dead-code,
       improper-ctypes,
       non-shorthand-field-patterns,
       no-mangle-generic-items,
       overflowing-literals,
       path-statements ,
       patterns-in-fns-without-body,
       private-in-public,
       unconditional-recursion,
       unused,
       unused-allocation,
       unused-comparisons,
       unused-parens,
       while-true)]

此外,以下allowlint可能是一个deny的好主意。

#[deny(missing-debug-implementations,
       missing-docs,
       trivial-casts,
       trivial-numeric-casts,
       unused-extern-crates,
       unused-import-braces,
       unused-qualifications,
       unused-results)]

有些人可能还想在他们的列表中加入missing-copy-implementationslint。

请注意,我们没有明确添加deprecated的lint,因为可以肯定的是,未来会有更多被废弃的API。

参见

Latest commit 39a2f36 on 18 Oct 2021

Deref 多态性

描述

滥用Deref trait来模拟结构体间的继承,从而重用方法。

例子

有时我们想模仿以下来自OO语言(如Java)的常见模式:

class Foo {
    void m() { ... }
}

class Bar extends Foo {}

public static void main(String[] args) {
    Bar b = new Bar();
    b.m();
}

我们可以使用deref多态性的反面模式来做到这一点:

use std::ops::Deref;

struct Foo {}

impl Foo {
    fn m(&self) {
        //..
    }
}

struct Bar {
    f: Foo,
}

impl Deref for Bar {
    type Target = Foo;
    fn deref(&self) -> &Foo {
        &self.f
    }
}

fn main() {
    let b = Bar { f: Foo {} };
    b.m();
}

Rust中没有结构体的继承。相反,我们使用组合,并在Bar中包含一个Foo的实例(因为字段是一个值,它被内联存储,所以如果有字段,它们在内存中的布局与Java版本相同(可能,如果你想确定,你应该使用#[repr(C)]))。

为了使方法调用生效,我们为Bar实现了Deref,以Foo为目标(返回嵌入的Foo字段)。这意味着当我们解除对Bar的引用时(例如,使用*),我们将得到一个Foo。 这很奇怪。解引用通常从对T的引用中得到一个T,这里我们有两个不相关的类型。 然而,由于点运算符做了隐式解引用,这意味着方法调用将搜索Foo和Bar的方法。

优势

你可以节省一点模板代码,例如:

impl Bar {
    fn m(&self) {
        self.f.m()
    }
}

劣势

最重要的是这是一个令人惊讶的惯常做法--未来的程序员在代码中读到这句话时,不会想到会发生这种情况。 这既因为我们在滥用Deref trait,而不是按照预期(文档等)使用它。 也因为这里的机制是完全隐含的。

这种模式没有像Java或C++中的继承那样在Foo和Bar之间引入子类型。此外,由Foo实现的特性不会自动为Bar实现,所以这种模式与边界检查以及泛型编程的互动性很差。

使用这种模式,在self的语义上与大多数OO语言有细微的不同。 通常情况下,它仍然是对子类的引用,在这种模式下,它将是定义方法的"类"。

最后,这种模式只支持单继承,没有接口的概念,没有基于类的隐私,也没有其他与继承有关的特性。 所以,它给人的体验会让习惯了Java继承等的程序员感到微妙的惊讶。

讨论

我们没有一个好的替代方案。根据具体的情况,使用traits重新实现或者手动写出派发给Foo的facade方法可能更好。 我们确实打算在Rust中加入与此类似的继承机制,但要达到稳定的Rust,可能还需要一些时间。详情可见: blog posts and this RFC issue

Deref trait 是为实现自定义指针类型而设计的。 目的是让它通过指向T的指针到达T,而不是在不同类型之间转换。 遗憾的是,这一点并没有(也许不能)由trait定义强制执行。

Rust试图在显式和隐式机制之间取得谨慎的平衡,倾向于类型之间的显式转换。 点运算符中的自动解引用是一个人机工程学强烈支持隐式机制的情况,但其目的是将其限制在间接程度上,而不是在任意类型之间的转换。

参见

Latest commit fb57f21 on 10 Mar 2021

Rust的函数式用法

Rust是一种命令式语言,但它遵循许多函数式编程的范式。

在计算机科学中,函数式编程是通过应用和组合函数构建程序的一种编程范式。 它是一种声明式编程范式,其中函数定义是表达式树,每个表达式返回一个值,而不是改变程序状态的命令式语句序列。

Latest commit c41be87 on 20 Jan 2021

编程范式

理解函数式程序的最大障碍之一是命令式程序背景下的思维转变。 命令式程序描述的是如何做某事,而声明式程序描述的是什么做什么。 让我们把1到10的数字相加来说明这一点。

命令式


#![allow(unused)]
fn main() {
let mut sum = 0;
for i in 1..11 {
    sum += i;
}
println!("{}", sum);
}

对于命令式程序,我们必须模拟编译器来看看发生了什么。 在这里,我们从一个值为0的sum开始。 接下来,我们在1到10的范围内进行迭代。 循环的每一次,我们在范围内加上相应的值。 最后我们把它打印出来。

isum
11
23
36
410
515
621
728
836
945
1055

这就是我们大多数人开始编程的方式。 我们知道,一个程序就是一个步骤的集合。

声明式


#![allow(unused)]
fn main() {
println!("{}", (1..11).fold(0, |a, b| a + b));
}

哇! 这真的很不一样! 这里发生了什么? 请记住,在声明性程序中,我们描述的是做什么,而不是如何做它。 fold是一个可以组合函数的函数。 这个名字是来自Haskell的一个惯例。

在这里,我们正在组成加法的函数(闭包:|a, b| a + b),范围是从1到10。 0是起点,所以a一开始就是0。 b是范围的第一个元素,1。0 + 1 = 1是结果。 所以现在我们再次fold,a = 1,b = 2,所以1 + 2 = 3是下一个结果。 这个过程一直持续到我们得到范围内的最后一个元素,10。

abresult
011
123
336
6410
10515
15621
21728
28836
36945
451055

Latest commit 2cd70a5 on 22 Jan 2021

作为类型类的泛型

描述

Rust的类型系统设计得更像函数式语言(如Haskell)而不是命令式语言(如Java和C++)。 因此,Rust可以把许多类型的编程问题变成“静态类型”问题。 这是选择函数式语言的最大优势之一,对Rust的许多编译时保证至关重要。

这个想法的一个关键部分是泛型的工作方式。例如,在C++和Java中,泛型是编译器的一个元编程结构。 C++中的vector<int>和vector<char>只是一个vector类型(被称为template)的相同模板代码的两个不同副本,其中填入了两种不同类型。

在Rust中,泛型参数创建了函数式语言中所谓的“类型类约束”,用户填写的每个不同的参数实际上都会改变类型。 换句话说,Vec<isize>和Vec<char>是两种不同的类型,被类型系统的所有部分识别为不同的类型。

这被称为单态化,不同的类型由多态的代码创建。 这种特殊的行为需要impl块来指定泛型参数:泛型的不同值会导致不同的类型,而不同的类型可以有不同的impl块。

在面向对象的语言中,类可以从其父辈那里继承行为。 然而,这不仅允许将额外的行为附加到类型类的特定成员上,而且还允许附加到额外的行为上。

最接近的是Javascript和Python中的运行时多态性,在那里,新的成员可以被任意构造器随意地添加到对象中。 然而,与这些语言不同的是,Rust的所有额外方法在使用时都可以被类型检查,因为它们的泛型是静态定义的。 这使得它们在保持安全的同时更具有实用性。

例子

假设你正在为一系列的实验室机器设计一个存储服务器。 由于涉及到软件,有两个不同的协议需要你支持。BOOTP(用于PXE网络启动),和NFS(用于远程挂载存储)。

你的目标是有一个用Rust编写的程序,可以处理这两个协议。 它将有协议处理器,并监听两种请求。 然后,主要的应用逻辑将允许实验室管理员为实际文件配置存储和安全控制。

实验室里的机器对文件的请求包含相同的基本信息,无论它们来自什么协议:一个认证方法,和一个要检索的文件名。 一个直接的实现会是这样的:


enum AuthInfo {
    Nfs(crate::nfs::AuthInfo),
    Bootp(crate::bootp::AuthInfo),
}

struct FileDownloadRequest {
    file_name: PathBuf,
    authentication: AuthInfo,
}

这种设计可能工作得足够好。 但现在假设你需要支持添加协议特定的元数据。 例如,对于NFS,你想确定他们的挂载点是什么,以便强制执行额外的安全规则。

当前结构体的设计方式将协议决定权留给了运行时。 这意味着任何适用于一种协议而不适用于另一种协议的方法都需要程序员在进行运行时检查。

以下是获得NFS挂载点的代码:

struct FileDownloadRequest {
    file_name: PathBuf,
    authentication: AuthInfo,
    mount_point: Option<PathBuf>,
}

impl FileDownloadRequest {
    // ... other methods ...

    /// Gets an NFS mount point if this is an NFS request. Otherwise,
    /// return None.
    pub fn mount_point(&self) -> Option<&Path> {
        self.mount_point.as_ref()
    }
}

mount_point()的每个调用者都必须检查None并编写代码来处理它。 即使他们知道在给定的代码路径中只有NFS请求会被使用。

如果不同的请求类型被混淆,产生编译时错误会更理想。 毕竟,用户的整个代码路径,包括他们使用库中的哪些函数,都会知道一个请求是NFS请求还是BOOTP请求。

在Rust中,这其实是可以做到的! 解决办法是添加一个泛型,以便分割API。

下面是它的代码:

use std::path::{Path, PathBuf};

mod nfs {
    #[derive(Clone)]
    pub(crate) struct AuthInfo(String); // NFS session management omitted
}

mod bootp {
    pub(crate) struct AuthInfo(); // no authentication in bootp
}

// private module, lest outside users invent their own protocol kinds!
mod proto_trait {
    use std::path::{Path, PathBuf};
    use super::{bootp, nfs};

    pub(crate) trait ProtoKind {
        type AuthInfo;
        fn auth_info(&self) -> Self::AuthInfo;
    }

    pub struct Nfs {
        auth: nfs::AuthInfo,
        mount_point: PathBuf,
    }

    impl Nfs {
        pub(crate) fn mount_point(&self) -> &Path {
            &self.mount_point
        }
    }

    impl ProtoKind for Nfs {
        type AuthInfo = nfs::AuthInfo;
        fn auth_info(&self) -> Self::AuthInfo {
            self.auth.clone()
        }
    }

    pub struct Bootp(); // no additional metadata

    impl ProtoKind for Bootp {
        type AuthInfo = bootp::AuthInfo;
        fn auth_info(&self) -> Self::AuthInfo {
            bootp::AuthInfo()
        }
    }
}

use proto_trait::ProtoKind; // keep internal to prevent impls
pub use proto_trait::{Nfs, Bootp}; // re-export so callers can see them

struct FileDownloadRequest<P: ProtoKind> {
    file_name: PathBuf,
    protocol: P,
}

// all common API parts go into a generic impl block
impl<P: ProtoKind> FileDownloadRequest<P> {
    fn file_path(&self) -> &Path {
        &self.file_name
    }

    fn auth_info(&self) -> P::AuthInfo {
        self.protocol.auth_info()
    }
}

// all protocol-specific impls go into their own block
impl FileDownloadRequest<Nfs> {
    fn mount_point(&self) -> &Path {
        self.protocol.mount_point()
    }
}

fn main() {
    // your code here
}

采用这种方法,如果用户使用了错误的类型:

fn main() {
    let mut socket = crate::bootp::listen()?;
    while let Some(request) = socket.next_request()? {
        match request.mount_point().as_ref()
            "/secure" => socket.send("Access denied"),
            _ => {} // continue on...
        }
        // Rest of the code here
    }
}

他们会得到一个语法错误。 FileDownloadRequest<Bootp>类型没有实现mount_point(),只有FileDownloadRequest<Nfs>类型实现。 而这是由NFS模块创建的,当然不是BOOTP模块!

优势

首先,它允许在多个状态下共有的字段被去掉重复。 通过使共享字段泛型化,保证其只被实现一次。

其次,它使impl块更容易阅读,因为它们是按状态分解的。 所有状态下通用的方法只在一个块中出现,而一个状态下特有的方法则在单独的块中出现。

这两点都意味着代码行数更少,而且组织得更好。

劣势

目前这增加了二进制文件的大小,这是由于编译器中实现单态化的方式造成的。 希望这种实现方式在未来能够得到改善。

替代方案

  • 如果一个类型由于构造或部分初始化而似乎需要一个“分开的API”,可以考虑用生成器模式来代替。

  • 如果类型之间的API不发生变化——只有行为发生变化——那么最好使用策略模式代替。

参见

这种模式在整个标准库中都被使用:

  • Vec<u8>可以从一个字符串中转换出,与其他类型的Vec<T>不同。1
  • 当它们只包含一个实现了Ordtrait的类型时,它们也可以被转换到二叉堆中。2
  • to_string方法是只针对str类型的Cow。3

它也被几个流行的crate使用,以允许API的灵活性:

  • 用于嵌入式设备的embedded-hal生态系统广泛使用了这种模式。 例如,它允许静态地验证用于控制嵌入式引脚的设备寄存器的配置。 当一个引脚进入一个模式时,它返回一个Pin<MODE>结构体,其泛型决定了在该模式下可用的功能,这些功能不在Pin本身上。4

  • hyperHTTP客户端库利用这一点为不同的可插拔请求提供了丰富的API。 不同连接器的客户端有不同的方法以及不同的trait实现,而一组核心方法适用于任何连接器。5

  • “类型状态”模式——对象根据内部状态或不变量获得和失去API——在Rust中使用相同的基本概念和稍微不同的技术来实现。6

Latest commit 7e96169 on 15 Sep 2021

额外资源

补充性的有帮助内容的集合。

讲座

书籍(在线)

Latest commit 2cd70a5 on 22 Jan 2021

设计原则

常见设计原则的简要概述


SOLID

DRY (Don’t Repeat Yourself)

“每一项知识都必须在一个系统内有一个单一的、明确的、权威的表述。”

KISS原则

大多数系统在保持简单而不是复杂的情况下运行效果最好;因此,简单性应该是设计的一个关键目标,应该避免不必要的复杂性。

得墨忒耳定律(Law of Demeter, LoD)

根据“信息隐藏”的原则,一个特定的对象应该尽可能少地假设其他事物(包括其子组件)的结构或属性。

契约式设计(Design by contract, DbC)

软件设计者应该为软件组件定义正式的、精确的和可验证的接口规范,这些规范使用前置条件、后置条件和不变量扩展了抽象数据类型的普通定义。

封装

将数据与操作该数据的方法捆绑在一起,或限制对对象某些组件的直接访问。 封装是用来将结构化数据对象的值或状态隐藏在一个类中,防止未经授权的各方直接访问它们。

命令查询分离原则(Command-Query-Separation, CQS)

“函数不应产生抽象的副作用......只有命令(程序)才允许产生副作用。” - Bertrand Meyer: Object-Oriented Software Construction

最小惊讶原则(Principle of least astonishment, POLA)

系统中的一个组件的行为应该是大多数用户所期望的行为。 这种行为不应该使用户感到惊讶或意外。

Linguistic-Modular-Units

“模块必须与所使用的语言中的语法单位相对应。” - Bertrand Meyer: Object-Oriented Software Construction

Self-Documentation

“模块的设计者应努力使所有关于模块的信息成为模块本身的一部分。” - Bertrand Meyer: Object-Oriented Software Construction

Uniform-Access

“一个模块所提供的所有服务都应该通过一个统一的符号来提供,这并不违背它们的实现方式,即不管是通过存储还是通过计算来实现的。” - Bertrand Meyer: Object-Oriented Software Construction

Single-Choice

“每当一个软件系统必须支持一组备选方案时,系统中有且仅有一个模块知道它们的详尽清单。” - Bertrand Meyer: Object-Oriented Software Construction

Persistence-Closure

“每当一个存储机制存储一个对象时,它必须同时存储该对象的附属物。每当一个检索机制检索一个先前存储的对象时,它也必须检索该对象尚未被检索的任何附属物。” - Bertrand Meyer: Object-Oriented Software Construction

Latest commit 9834f57 on 25 Aug 2021