引言
译者注
本项目源自rust-unofficial/patterns,遵守原项目的MPL-2.0协议。 本着学习Rust的过程中也为Rust在国内的推广做一点微小的贡献的想法,来动手翻译该书。我尽量保持在不改专业术语的情况下更口语化些,不希望翻译的太过僵硬,希望读者理解,水平有限,翻译错漏之处还请指出。
参加我们
如果你有兴趣参加这本书的编写,请查看contribution guidelines。
设计模式
在开发程序的时候,我们必须要解决很多问题。 一个程序就相当于一个问题的解决方案。 设计模式就相当于一个用来解决很多不同问题的解决方案的集合。 我们将所有这些解决方案集合在一起去解决更大的问题。
Rust中的设计模式
有很多问题有相同的形式。 因为Rust不是面向对象的语言,所以设计模式与其他面向对象语言的设计模式也有所不同。 虽然细节上有所不同,但他们还是有很多相同之处:
- 设计模式 是编写软件过程中解决常见问题的方法。
- 反模式 是解决常见问题的方法。 然而设计模式带给我们好处,反模式却带来更多的问题。
- 习惯用法 是编码的指导方针。它是社区的共同规范。除非你有明确的理由, 否则就遵守它。
TODO: Mention why Rust is a bit special - functional elements, type system, borrow checker
习惯用法
习惯用法 是被社区广泛接受的风格和模式。它们是指导准则。因为其他开发者熟悉习惯用法,所以编写符合习惯用法的代码有助于其他开发者理解发生了什么状况。
计算机理解编译器生成的机器码。 而语言对开发者来说最有用。 所以,我们有编译器这个抽象层,为什么不善加利用语言呢?
记住KISS 准则:让系统保持简单,愚蠢。 该理论认为大多数系统在简单的时候工作的比复杂的时候更好;因此,简单性应该是设计中的黄金准则,应该避免不必要的复杂性。
代码是给人类看的,不是计算机。
以借用类型为参数
说明
当你为函数选择参数类型时,使用带强制隐式转换的目标会增加你代码的复杂度。在这种情况下,函数将会接受更多的输入参数类型。
使用可切片类型或者胖指针类型没有限制。事实上,你应该总是用借用类型(borrowed type),
而不是自有数据类型的借用(borrowing the owned type)。
例如&str
而非 &String
, &[T]
而非 &Vec<T>
, 或者 &T
而非 &Box<T>
.
当自有数据结构(owned type)的实例已经提供了一个访问数据的间接层时,使用借用类型可以让你避免增加间接层。举例来说,String
类型有一层间接层,所以&String
将有两个间接层。我们可以用&Str
来避免这种情况,无论何时调用函数,强制&String
转换为&Str
。
例子
在这个例子中,我们将说明使用&String
与&Str
作为函数参数的区别。这个思路用于对比&Vec<T>
和 &[T]
、 &T
和&Box<T>
也适用。
考虑一个我们想要确定一个单词是否包含3个连续的元音字母的例子。我们不需要获得字符串的所有权,所以我们将获取一个引用。
代码如下:
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)); // 至此运行正常,但下面两行就会失败: // 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
类型带给你更大的灵活性。
让我们现在考虑一个例子:当给定一个句子,我们需确定句子中是否有单词包含3个连续的元音字母。我们也许应该用刚刚写好的函数来对句子中的每个单词做判断。 An example of this could look like this:
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
是低开销且隐性的。
参阅
- Rust Language Reference on Type Coercions
- For more discussion on how to handle
String
and&str
see this blog series (2015) by Herman J. Radtke III
用format!
连接字符串
说明
对一个可变的String
类型对象使用push
或者push_str
方法,或者用+
操作符可以构建字符串。然而,使用format!
常常会更方便,尤其是结合字面量和非字面量的时候。
例子
#![allow(unused)] fn main() { fn say_hello(name: &str) -> String { // 我们可以手动构建字符串 // let mut result = "Hello ".to_owned(); // result.push_str(name); // result.push('!'); // result // 但是用format! 更好 format!("Hello {}!", name) } }
优点
使用format!
连接字符串通常更加简洁和易于阅读。
缺点
它通常不是最有效的连接字符串的方法。对一个可变的String
类型对象进行一连串的push
操作通常是最有效率的(尤其这个字符串已经预先分配了足够的空间)
构造器
说明
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 } } }
Default Constructors
Rust supports default constructors with the Default
trait:
// A Rust vector, see liballoc/vec.rs
pub struct Vec<T> {
buf: RawVec<T>,
len: usize,
```rust
/// 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<T> Vec<T> {
// Constructs a new, empty `Vec<T>`.
// Note this is a static method - no self.
// This constructor doesn't take any arguments, but some might in order to
// properly initialise an object
pub fn new() -> Vec<T> {
// Create a new Vec with fields properly initialised.
Vec {
// Note that here we are calling RawVec's constructor.
buf: RawVec::new(),
len: 0,
}
impl Default for Second {
fn default() -> Self {
Self { value: 0 }
}
}
Default
can also be derived if all types of all fields implement Default
,
like they do with 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 } } }
Note: When implementing Default
for a type, it is neither required nor
recommended to also provide an associated function new
without arguments.
Hint: The advantage of implementing or deriving Default
is that your type
can now be used where a Default
implementation is required, most prominently,
any of the *or_default
functions in the standard library.
参阅
- default idiom有对
Default
trait更深入的介绍。 - 生成器模式用于有多种构造对象方式的情况。
Default
特性
说明
许多Rust中的类型有一个构造器。然而,构造器是针对特定类型的。Rust不能抽象出一个代表所有带有new()
方法的东西。为了实现这个想法,
一个可被容器和其他泛型使用的Default
特性应运而生(如 [Option::unwrap_or_default()
)。尤其是一些容器已经在适当的情况下实现了它。
单例容器如 Cow
, Box
和 Arc
为Default
类型实现了Default
,
并且可以自动地对每个成员都实现Default
的结构体支持#[derive(Default)]
。所以越多的类型支持 Default
,它就会越有用。
另一方面,构造器能够接受多个参数,而default()
方法不能。你甚至可以定义多个不同的函数做多个构造器,但是你最多只能为一个类型实现一种Default
的实现。
例子
use std::{path::PathBuf, time::Duration}; // 注意我们可以用自动导出 Default. #[derive(Default, Debug)] 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); }
参阅
- The constructor idiom is another way to generate instances that may or may not be "default"
- The
Default
documentation (scroll down for the list of implementors) Option::unwrap_or_default()
derive(new)
将集合视为智能指针
说明
使用集合的Deref
特性使其像智能指针一样,提供数据的借用或者所有权。
例子
use std::ops::Deref;
struct Vec<T> {
data: 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
类型大多数方法也对切片适用。
See also String
and &str
.
出发点
所有权和借用是Rust语言的核心概念。数据结构必须对这些语法的使用负责才能给用户一个良好的体验。当实现一个拥有数据的数据结构时,提供一个数据借用的接口将带来更大的灵活性。
优点
大部分方法可以只针对借用类型实现,这些实现对自有数据的类型可以隐式地适用。 给用户一个获取借用或所有权的选择。
缺点
边界检查时,不考虑仅通过解引用可用的方法和特性,所以对泛型数据结构使用这种模式将会变得复杂。(请看 Borrow
和AsRef
特性)
讨论
智能指针和数据集合有相似之处:一个智能指针指向一个对象,一个集合指向许多个对象。从类型系统的角度来看二者有一点区别。一个数据集合拥有数据所有权,也负责删除数据。(包括共享数据所有权,一些借用可能是占用数据的)。一个数据集合如果拥有数据,那么通常来说会提供一个数据的借用方法以便多方使用数据。
大多数智能指针(如 Foo<T>
)实现了Deref<Target=T>
特性。然而数据集合常常解引用为一个自定义类型。[T]
和str
类型有一些语言支持,
但是通常情况下,这不是必要的。即使Bar
时一个动态大小的类型时,Foo<T>
也可以实现Deref<Target=Bar<T>>
,
并且&Bar<T>
是借用Foo<T>
类型数据。
一般来讲,有序数据集合将会实现Index
和Range
特性来提供切片语法。其将生成借用。
参阅
Deref polymorphism anti-pattern.
Documentation for Deref
trait.
确定性析构
说明
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(())
}
出发点
如果一个函数有多个返回语句,那么在退出时执行析构代码将会是困难且重复的(并且容易产生bug)。使用宏来隐式地退出是一个例外。一个常见的用法是使用?
操作符,
当结果是Ok
的时候继续,当结果是Err
的时候返回。?
操作符是用来处理异常的一个机制,但是并不像Java的finally
,
这里不支持在正常情况和异常情况下都执行的代码。发生恐慌(Panicking)也将提前结束函数。
优点
在析构器里的代码退出前总是会被执行,能应对恐慌(panics),提前返回等等。
缺点
不保证析构器里的代码一定会被执行。举例来说,函数内有一个死循环或者在退出前函数崩溃的情况。在一个已经发生恐慌(panicking)的线程里再次发生恐慌时,析构器也不会执行代码。因此析构器也不能用于必须确定执行的情景。
这种模式介绍了一些难以注意的隐式代码,即函数在结束时没有显式给出析构器执行代码。因此导致debug的时候更加棘手。
为了确定性,申请一个对象和实现Drop
特性增加了很多样板代码。
讨论
下面是一些关于如何用对象做终结器(finaliser)的精妙之处。对象在函数结束前必须保持存活,然后就被销毁。
这个对象必须是一个值或者独占数据的指针(例如:Box<Foo>
)。如果使用一个共享指针(例如Rc
),
那么终结器的生命周期就比函数更长了。类似地,终结器不应该被转移所有权到他处或者被返回。
终结器必须绑定在变量上,否则当退出临时的作用域时它就会被销毁。如果变量仅用作终结器,变量的名字必须用_
开头,
否则编译器就会警告这个变量未使用。然而,不要直接用_
作为变量名称,这样的话将会立刻销毁这个变量。
在Rust中,析构器在对象离开作用域的时候执行。无论是到达代码块的末端、提前返回亦或是函数恐慌(panic)都属于这种情况。当恐慌发生时, Rust对每个栈帧中的每个对象执行析构器代码。所以析构器即使在函数调用内出现恐慌也能顺利执行。
如果一个析构器在析构时出现了恐慌,这就没啥好办法了,所以Rust不再执行析构,果断终止这个线程。这就意味着Rust并不是绝对保证析构器一定会执行,因此可能会导致资源泄露。
参阅
RAII.
用mem::{take(_), replace(_)}
在修改枚举变体时保持值的所有权
说明
假设我们有一个至少有两种变体的枚举&mut MyEnum
,一种是A { name: String, x: u8 }
,
另一种是B { name: String }
。现在我们想要当x=0时,将A变为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) { // we mutably borrow `e` here. This precludes us from changing it directly // as in `*e = ...`, because the borrow checker won't allow it. Therefore // the assignment to `e` must be outside the `if let` clause. *e = if let MyEnum::A { ref mut 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`, because it is the result of the `if let` expression). MyEnum::B { name: mem::take(name) } // In all other cases, we return immediately, thus skipping the assignment } else { return } } }
这种方法对多种枚举变体也适用:
#![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 { ref mut name } => B { name: mem::take(name) }, B { ref mut name } => A { name: mem::take(name) }, C => D, D => C } } }
出发点
当使用枚举的时候,我们可能想要改变枚举变体类型为其他类型。为了通过借用检查器检查,我们将分为两个阶段。在第一阶段,我们查看现有的值然后决定下一步怎么做。第二阶段我们可以修改值。
借用检查器不允许我们拿走name
字段的值(因为那总得有有个东西放在那啊)。我们当然可以用.clone()
克隆一个name
的值,然后把这个克隆的值赋给MyEnum::B
,
不过这样就是一个反模式的实例(为了满足借用检查器就用克隆,增大了开销)。综上,我们可以通过仅仅一个可变借用来改变值,避免多余的空间申请。
mem::take
支持我们交换值,用默认值替换,并且返回原值。对于String
类型,默认值是一个空字符串,无需申请空间。因此,我们获取原来的name
(作为一个拥有值的变量),我们可以把它包装成另一个枚举。
注:mem:replace
非常相似,不过其允许我们指定要替换的值。可以用它实现mem::take
的功能:mem::replace(name,String::new())
。
然而,如果我们要使用Option
的默认值替换掉枚举变体的值,那么用take()
方法还是更习惯和简便的。
优点
看好啦,没有内存申请!同时你在这么做的时候会感觉自己像Indiana Jones。(译者注:没看过夺宝奇兵,没get到梗)
缺点
这会变得有点啰嗦。如果错误地重复这个操作将会让你厌恶借用检查器。编译器将无法对替换操作优化,结果是让你觉得相比其他不安全的语言来说性能更低。
此外,take
操作需要类型实现Default
特性。然而,如果这个类型没有实现Default
特性,你还是可以用 mem::replace
。
讨论
这个模式是只属于Rust的特点。在带GC的语言中,你可以直接用引用来替换。(GC会记录有哪些引用),在像C语言这些低级语言中你可以简单地给指针取个别名然后解决问题。
然而,在Rust中,我们不得不再多做一点工作。一个值只能有一个所有者,所以把值取走后,我们必须再往里面放点东西填充就像印第安纳琼斯一样,用一包沙子替换了宝物。
参阅
这在特定情况下可以消除利用克隆通过借用检查器的反模式。
[Clone to satisfy the borrow checker](TODO: Hinges on PR #23)
栈上动态分发
说明
我们可以动态分发多个值,然而为了实现此功能,需要声明多个变量来绑定不同类型的对象。我们可以使用延迟条件初始化(deferred conditional initialization)来扩展生命周期,如下所示:
例子
use std::io; use std::fs; fn main() -> Result<(), Box<dyn std::error::Error>> { let arg = "-"; // 它们必须活的比 `readable`长, 因此先声明: 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默认是单态的代码。这就意味着对每个类型都要生成相对应的代码并且单独优化。这种模式虽然在热路径(hot path)上执行的很快,但是它空间上将非常臃肿。当性能不是致命关键的时候,我们还是要考虑考虑编译时间和cache的使用。
幸运的是,Rust允许我们使用动态分发,但是我们需要显式的声明。
优点
我们不用在堆上申请任何空间。既不用初始化任何用不上的东西,也不用单态化全部代码,便可同时支持File
和Stdin
。
缺点
这样写代码比使用Box
实现的版本需要更多活动部件(moving parts):
// 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付出大量工作来确保只有初始化过的值在离开作用域时会销毁。
上面这个例子符合我们所有的限制条件:
- 所有的变量都在使用前初始化(这个例子中是借用)
- 每个变量都只有单一类型。在我们的例子中,
stdin
对应Stdin
类型,file
对应File
类型,readable
对应&mut dyn Read
类型 - 每个借用的值的生命周期都比借用他们的长。
参阅
- Finalisation in destructors and RAII guards can benefit from tight control over lifetimes.
- For conditionally filled
Option<&T>
s of (mutable) references, one can initialize anOption<T>
directly and use its.as_ref()
method to get an optional reference.
FFI 习惯用法
编写FFI的代码本身就是一门学问。
不过,这有一些习惯用法可以使其像指针一样操作,并且避免缺少经验的开发者陷入unsafe
Rust的陷阱。
这一章中包括下列能在做FFI时有用的习惯用法:
FFI中的错误处理
说明
在像C语言这种,错误是用返回码表示的。然而,Rust的类型系统支持通过一个完整的类型来提供更加丰富的错误信息。
下面的实践展示了错误代码的不同类型,以及如何在使用层面上去暴露它们:
- 扁平的枚举(译者注:无实际的成员数据)转换成整型并且作为错误码返回。
- 结构体枚举应该被转换为一个整型错误码和一个包含详细错误信息的字符串。
- 自定义错误类型应该被转换为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语言的标准。
接受字符串
说明
当通过FFI的指针接受字符串时,有两条需要遵守的原则:
- 保持对外部字符串的借用,而不是直接复制一份。
- 在转换数据类型时最小化
unsafe
的代码区域。
出发点
Rust有对C语言风格字符串的内置支持,如CString
和CStr
类型。然而,有多种不同途径接受外部传入的字符串。
最佳实现是很简单的:用CStr
最小化unsafe的代码区域,然后创建一个借用的切片。如果需要拥有其所有权的String
,对字符串切片调用to_string()
方法。
代码示例
pub mod unsafe_module {
// other module content
#[no_mangle]
pub extern "C" fn mylib_log(msg: *const libc::c_char, level: libc::c_int) {
let level: crate::LogLevel = match level { /* ... */ };
let msg_str: &str = unsafe {
// SAFETY: accessing raw pointers expected to live for the call,
// and creating a shared reference that does not outlive the current
// stack frame.
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);
}
}
优点
样例能保证下面两点:
unsafe
代码块尽可能的小。- 无法记录生命周期的指针转变为可以记录追踪的共享引用。
考虑另一种实现,也就是字符串被实际拷贝一份的情况:
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);
}
}
这份代码与第一版相比有两个方面缺点:
- 有更多的
unsafe
代码,更加不灵活。 - 由于调用大量的算法,这个版本有一个会导致Rust的未定义行为(
undefined behaviour
)的bug。
这里的bug是一个简单的指针计算的错误:字符串被拷贝走msg_len
个字节。然而没有包括在末尾的NUL
终止符。
向量长度将会被设置为未做填充字符串的长度而不是末尾填一个0的调整后大小。因此,向量内的最后一个字节是没有初始化的内存。当最终创建CString
时,其读取向量将会导致未定义行为!
像很多问题一样,这是很难查到的。有些时候它因为字符串不是UTF-8
编码而产生恐慌,有时它又会在末尾放一个奇怪的字符,有时它会完全崩溃掉。
缺点
或许没有?
传递字符串
说明
当传递字符串给FFI函数时,有以下4点需要遵守的原则:
- 让拥有的字符串生命周期尽可能长。
- 在转换时保持最小化
unsafe
区域代码。 - 如果C语言代码会修改字符串数据,那么使用
Vec
类型而不是CString
。 - 除非外部函数的API需要字符串的所有权,否则不要传给被调用的函数。
出发点
Rust有对C语言风格字符串的内置支持,如CString
和CStr
类型。不过,有多种不同途径从Rust函数传给FFI函数字符串的方法。
最佳实现是很简单的:用CSring
最小化unsafe的代码区域。然而,第二个警告是对象必须生存足够长时间,意味着生命周期应该最大化。此外,在修改后双向传递CStirng
类型的对象是未定义行为,这种情况需要额外的操作来完善。
代码示例
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()
}
}
优点
样例能保证下面三点:
unsafe
代码块尽可能的小。CString
生命周期足够长- 类型转换时发生的错误能够尽早地传播出来。
一个常见(在文档中很常见)的错误是在代码块的开头部分不定义变量。
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
的生命周期并没有因为创建指针而延长,不像创建一个引用那样。
另一个经常提到的问题是初始化一个全0的1K长度的向量很慢。然而,最新的Rust版本针对这种情况提供了一个宏调用zmalloc
,和操作系统能返回全0内存的速度一样快。(真的很快)
缺点
或许没有?
关于 Option
的迭代器
说明
Option
可以被视为一个包含一个0个或者1个元素的容器。特别是它实现了IntoIterator
特性,这样我们就可以用来写泛型代码。
示例
因为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
总是非空的,那么用std::iter::once
更加合适。
此外,因为Option
实现了IntoIterator
特性,它就可以用for
循环来迭代。这等价于用if let Some(..)
,大多数情况下倾向于用后者。
参阅
-
std::iter::once
是一个只产生一个元素的迭代器。这有一个更具可读性的替代品Some(foo).into_iter()
。 -
Iterator::filter_map
是Iterator::flat_map
专注于处理返回值是Option
的map函数版本。 -
ref_slice
包提供将Option
转换为0个或1个元素的切片的函数。
向闭包传递变量
说明
默认情况下,闭包从环境中借用捕获。或者你可以用move
闭包来将环境的所有权全给闭包。然而,一般情况下你是想传递一部分变量到闭包中,如一些数据的拷贝、传引用或者执行一些其他操作。
这种情况应在不同的作用域里进行变量重绑定。
示例
像这样
#![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; }; }
优点
这样在闭包定义的时候就把哪些是复制的数据搞清楚,这样结束时无论闭包有没有消耗掉这些值,都会及早drop掉。
闭包能用与上下文相同的变量名来用那些复制或者move进来的变量。
缺点
增加了闭包内的实现代码行数。
留隐私,为拓展
说明
A small set of scenarios exist where a library author may want to add public fields to a public struct or new variants to an enum without breaking backwards compatibility.
Rust offers two solutions to this problem:
-
Use
#[non_exhaustive]
onstruct
s,enum
s, andenum
variants. For extensive documentation on all the places where#[non_exhaustive]
can be used, see the docs. -
You may add a private field to a struct to prevent it from being directly instantiated or matched against (see Alternative)
示例
#![allow(unused)] fn main() { mod a { // 公开结构体 #[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") } } }
Alternative: Private fields
for structs
#[non_exhaustive]
only works across crate boundaries.
Within a crate, the private field method may be used.
Adding a field to a struct is a mostly backwards compatible change.
However, if a client uses a pattern to deconstruct a struct instance, they
might name all the fields in the struct and adding a new one would break that
pattern.
The client could name some fields and use ..
in the pattern, in which case adding
another field is backwards compatible.
Making at least one of the struct's fields private forces clients to use the latter
form of patterns, ensuring that the struct is future-proof.
The downside of this approach is that you might need to add an otherwise unneeded
field to the struct.
You can use the ()
type so that there is no runtime overhead and prepend _
to
the field name to avoid the unused field warning.
#![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: () } }
Discussion
On struct
s, #[non_exhaustive]
allows adding additional fields in a backwards
compatible way.
It will also prevent clients from using the struct constructor, even if all the
fields are public.
This may be helpful, but it's worth considering if you want an additional field
to be found by clients as a compiler error rather than something that may be silently
undiscovered.
#[non_exhaustive]
can be applied to enum variants as well.
A #[non_exhaustive]
variant behaves in the same way as a #[non_exhaustive]
struct.
Use this deliberately and with caution: incrementing the major version when adding
fields or variants is often a better option.
#[non_exhaustive]
may be appropriate in scenarios where you're modeling an external
resource that may change out-of-sync with your library, but is not a general purpose
tool.
Disadvantages
#[non_exhaustive]
can make your code much less ergonomic to use, especially when
forced to handle unknown enum variants.
It should only be used when these sorts of evolutions are required without
incrementing the major version.
When #[non_exhaustive]
is applied to enum
s, it forces clients to handle a
wildcard variant.
If there is no sensible action to take in this case, this may lead to awkward
code and code paths that are only executed in extremely rare circumstances.
If a client decides to panic!()
in this scenario, it may have been better to
expose this error at compile time.
In fact, #[non_exhaustive]
forces clients to handle the "Something else" case;
there is rarely a sensible action to take in this scenario.
See also
关于初始化的文档
说明
如果一个结构体初始化操作很复杂,当写文档的时候,可以在文档中写一个使用样例的函数。
出发点
有时候结构体有多个或者很复杂的参数和一堆方法。每个方法都应该有相应的例子说明。
举例来说:
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 {
// ...
}
}
示例
不用每次都写初始化的部分,主要写一个以这个结构体为参数的函数的用法即可。
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)]
注释的帮助示例(这样用户就看不见)。因为这是包里的公开API,所以在rustdoc里会显示这个方法。
临时可变性
说明
有的时候我们需要准备和处理一些数据,当处理完之后就只会读取而不修改。这种情况可以变量重绑定将其改为不可变的。
也可以在代码块里将处理过程和重定义写在一起。
示例
要求向量在使用前必须排序。
用代码块:
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.
优点
编译器可以确保你之后不会意外修改数据。
缺点
多增加了一些本不必要的代码,代码结构更复杂。
设计模式
设计模式设计模式(design pattern)是对软件设计中普遍存在(反复出现)的各种问题,所提出的解决方案。设计模式是用来描述一门编程语言文化的好标准。设计模式与编程语言息息相关,一门语言中的模式可能在另一种语言中没什么必要,因为语言可能自身特性就能解决问题。或者可能在另一门语言中由于缺少某些特性,压根就实现不了。
设计模式如果滥用,那将会增加程序不必要的复杂性。不过设计模式倒可以用来分享关于一门语言深层次和进阶水平的知识。
Rust中的设计模式
Rust有很多独特的特性。这些特性消除了大量的问题,给我们极大的帮助。有些还是Rust的独特设计模式。
YAGNI
如果你还不了解这个词,YAGNI是不过早添加功能的缩写(You Aren't Going to Need It
)。这是写代码时的重要原则。
我曾写过的最好的代码是我没写过的代码
如果我们将YAGNI原则应用到设计模式中,我们可以发现Rust的特性能让我们省掉很多不必要的模式。例如,不再需要策略模式。在Rust里可以直接用traits。
TODO: Maybe include some code to illustrate the traits.
命令模式
说明
命令模式的基本概念是,将动作分离为单独的对象,并且作为参数传递它们
出发点
假设我们有一连串的动作或事务被封装为对象。 我们希望这些动作或命令在以后的不同时间以某种顺序执行或调用, 这些命令也可以作为某些事件的结果被触发。例如,当用户按下某个按钮,或某个数据包到达时。 此外,这些命令应该可以撤销。这对于编辑器的操作可能很有用。我们可能想存储命令日志, 这样,如果系统崩溃,我们可以在之后重新应用这些修改。
示例
定义两个数据库操作,建表
和加字段
。每个操作都是一个命令,它知道如何撤销命令。例如,删表
和删字段
。当用户调用数据库迁移操作时,每条命令都会按照定义的顺序执行。而当用户调用回滚操作时,整个命令集会以相反的顺序调用。
使用trait对象
我们定义了一个trait,将我们的命令封装成两个操作,execute
和rollback
。所有命令结构体
必须实现这个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()); }
使用函数指针
我们可以采用另一种方法。将每个单独的命令创建为不同的函数,并存储函数指针,
以便以后在不同的时间调用这些函数。因为函数指针实现了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对象
最后,我们可以在vector中分别存储实现的每个命令,而不是定义一个命令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
,
它在为例程注册handler函数时使用了trait对象。在使用Fn
trait对象时,
我们可以用和函数指针相同的方式创建和使用命令。
说到性能,在性能和代码的简易性、组织性间我们总需要权衡。 静态分发可以提供更好的性能,而动态分发在我们组织应用程序时提供了灵活性。
参见
建造者
说明
通过调用建造者来构造对象。
示例
#![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()
风格代码。
参阅
- Description in the style guide
- derive_builder, a crate for automatically implementing this pattern while avoiding the boilerplate.
- Constructor pattern for when construction is simpler.
- Builder pattern (wikipedia)
- Construction of complex values
分解结构体
说明
有时候一个很大的结构体会在借用的时候产生问题——当有多个可变借用(每个只改变其中一部分字段)的时候会相互冲突。解决方法是将这个大结构体分解成更小的结构体,然后再把这些小结构组装成大结构体,这样结构体中的每个部分都可以单独的借用。
这通常在其他方面带来更好的设计:用这种模式可以展露出更小的功能模块。
示例
下面是一个设计出的借用检查器会阻止我们使用结构体的示例:
#![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 Why and where you should use the pattern
优点
这可以让你挣脱借用检查器的限制,常常会带来更好的设计。
缺点
需要更多的代码。
有时更小的结构体没有明确的抽象意义,最终导致做出坏设计。这种情况是一种“代码气味”(code smell),表明程序需要重构。
讨论
在没有借用检查器的语言里中是不需要这种模式的,所以它是Rust独有的设计模式。不过,将功能分解成更小的单元是很多有名的软件设计原则中都赞同的,这一点与语言无关。
这种模式依赖于Rust的借用检查器能够分清结构体内部的字段。在上面的例子中,借用检查器知道a.b
和a.c
是相互独立的,就不会尝试去借用整个a
。
外部语言接口使用
编写FFI的代码本身就是一门学问。
不过,这有一些习惯用法可以使其像指针一样操作,并且避免缺少经验的开发者陷入unsafe
Rust的陷阱。
这一章中包括下列能在做FFI时有用的设计模式:
基于对象的API
说明
当在Rust中设计暴露给其他语言的接口时,有一些与普通的API设计原则相反的重要原则。
- 所有封装类型的所有权应该在Rust一端,由用户管理,并且不对外透明。
- 所有用来交换的数据类型应该由用户所有,并且对外透明。
- 库的操作应该是针对封装类型的函数。
- 所有操作不应该封装成基于结构体的类型,而是出处/生命周期。
出发点
Rust有内置的FFI与其他语言交互。这种方式为库作者通过不同的ABI提供了兼容C的API方法。(尽管这和我们的做法无关)
设计良好的Rust的FFI遵循C语言API的设计原则,同时尽量减少Rust的设计。下面有三个和任何外部语言API设计的目标:
- 让使用目标语言更简单。
- 尽量避免API破坏Rust端的内部安全性。
- 尽量使内存不安全的部分和Rust的未定义行为的部分越少越好。
Rust代码必须在与外部语言交互的某个层面之上保持安全。然而,unsafe
代码中的每个比特都可能造成bug,或者导致未定义行为。
例如,如果一个指针是错误的,将会导致非法内存访问的错误。但是它如果是任由非安全代码执行的,它将会使堆内存彻底崩溃。
基于对象的API设计设计允许写一些接口代码,来清晰明了地划分safe
和unsafe
代码间的边界,同时保持良好的内存安全特性。
代码示例
POSIX标准定义了访问基于文件的数据库的API,如DBM
以下是一个基于对象的API的绝好示例。
这是一段很容易阅读的涉及FFI的C语言代码。下面的说明将助你把握微妙之处。
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
对象被库所有。库掌握其内部内存,而不是用户。用户仅通过open
和close
来掌控对象的生命周期,以及用其他函数来执行操作。
datum
类型在前文中被称为用来交换的数据类型。它是用来在用户和库之间传递信息的数据类型。
数据库是用来存储非结构数据的,没有预先定义的长度或意义。作为结果,datum
是C中等价于Rust中的切片的类型:一大块字节空间和长度。最大的区别是这里没有类型信息,只有void
指针表示。
记住这个头文件是从库的视角来写的。用户有一些自己知道尺寸的类型。但是库并不关心这一点,而且由于C的类型强制转换,任何类型的指针都可以被转换为void
。
如前所述,这种类型对用户是透明的。而且这个类型归用户所有。因为里面有指针,所以有些微妙的影响。问题是,谁拥有这个指针指向的数据?
对于最佳的内存安全性来说,答案是用户。但是实际取回一个值时,用户并不知道如何申请内存(因为并不知道值有多长)。库代码将会使用用户访问的堆空间,例如C语言中的malloc
和free
函数,然后将所有权传给Rust一端。
这看起来都是推测,但实际上C语言中的指针就是这样。在Rust中相当于“用户定义生命周期”。库的用户需要阅读文档来正确使用它。用户需要阅读文档才能正确使用它。也就是说用户做错某些决定,后果无法确定。使出现这种情况最少的关键点是把透明的对象的所有权交出去。
优点
这样可以让用户为内存安全保证所付出的努力最小化:
- 不要在调用函数的时候使用不是由
dbm_open
返回的指针(将造成非法访问) - 不要调用函数的时候使用已经关闭的指针(释放后再使用)
- 任何
datum
的dptr
必须是空指针或者指向一片合法的内存区域。
此外,这也避免了一系列指针错误问题。为了理解原因,让我们深入考虑另一种情况:键值循环(key iteration)。
Rust的迭代器很有名。当实现一个迭代器时,开发者创造了一个生命周期受所有者限制的独立类型,并且实现Iterator
特性。
下面是在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语言写的例子包含了一个bug,以下将详细说明
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是经典bug。当迭代器返回结束循环的标志时将发生:
- 循环条件设置
l
为0,然后因为0 >= 0
进入循环。 - 长度是递增的,初始化是0。
- if条件是true,所以数据库被关闭。这应该有一个break。
- 循环条件再次执行,导致
next
访问已经被关闭的对象。
这个bug里最坏的部分是什么?如果Rust实现部分比较小心,这段代码在大多数情况下可以使用!如果Dbm
对象的内存没有立刻被重用,内部检查将总是失败,导致迭代器返回-1表示错误。但是其将会偶尔地导致段错误,或者更坏,更离谱的内存错误!
这种问题不是单靠Rust所能避免的。从库的角度来看,它将对象放在堆上,返回指向这些对象的指针,然后放弃对生命周期的控制。C语言的部分必须“做的漂亮点”。
开发者必须阅读和理解API文档。虽然有些人认为C语言出现这些问题是意料之中,但是通过一个好的API设计是可以减轻这种风险的。DBM
的POSIX标准API是将所有权合并到其根节点来实现的:
datum dbm_firstkey(DBM *);
datum dbm_nextkey(DBM *);
像这样,所有的生命周期都被绑在一块了,因此避免了风险。
缺点
不过,这样的设计也有一些也需要考虑到的缺点。
首先,API本身的表达力变得更差了。用POSIX标准的DBM,每个对象只有一个迭代器,并且每次调用改变自身状态。尽管它是安全的,但这比几乎任何语言中的迭代器都要严格得多。或许对于其他相关对象,它们的生命周期没有那么多层次,这时这种限制的成本比安全性收益要更大。
其次,根据API各部分之间的关系,可能会涉及大量的设计工作。许多更简单的设计点都有与之相关的设计模式:
不过,也不是所有API都可以这样设计。具体情况具体分析。
类型合并封装
说明
这个模式是被设计用来在最小化内存不安全代码区域的情况下,支持优雅地处理多种相关类型。
Rust的别名规则的基石之一就是生命周期。其确保了多种在类型间的访问模式是内存安全的,也包括安全的数据竞争。
不过当Rust 的类型导出到其他语言时,通常转换为指针。在Rust中,指针相当于“用户管理指针指向对象的生命周期”。谁使用谁负责避免内存不安全的情况。
因此需要对用户代码有一定程度的信任,特别是在释放内存之后,Rust对此无能为力。不过,一些API设计相比于其他设计来说,对另一种语言编写的代码造成更大的负担。
风险最小的API设计是“合并包装器”,所有可能的互动都合并到一个“包装器类型”中,保持Rust的API干净。
代码示例
为了便于理解,让我们看看一个经典的API导出的例子:在集合中循环访问。
API看起来像这样:
- 迭代器用
first_key
初始化。 - 每次调用
next_key
将会递增迭代器。 - Calls to
next_key
if the iterator is at the end will do nothing. - 当迭代器到尾时,调用
next_key
将什么都不做。 - 像前面所说,迭代器将会被包装进集合中(不像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
}
}
}
因此,包装器实现简单并且不包含任何unsafe
代码。
优点
这使得API使用起来更安全,避免了在类型间交互时的生命周期问题。关于更多的优点和避免的陷阱请看 基于对象的API。
缺点
包装类型常常是困难的,并且有时Rust的API做出妥协将会使事情更容易。
举例来说,想想一个没有高效实现nth()
的迭代器。它肯定需要写特殊的逻辑来保证对象处理循环全在内部,或者单独支持一个不同的访问模式仅用来做外部语言访问。
尝试包装迭代器 (并且失败了)
为了正确地包装类型,包装器将会实现C语言版本的代码要做的事:擦除迭代器的生命周期,手动管理其生命周期。
简单地说,这是离谱的难。
下面仅仅是其中一个陷阱的说明。
MySetWrapper
的第一个版本像下面这样:
struct MySetWrapper {
myset: MySet,
iter_next: usize,
// created from a transmuted Box<KeysIter + 'self>
iterator: Option<NonNull<KeysIter<'static>>>,
}
用transmute
来延长生命周期,然后用一个指针来隐藏它,这就够丑陋的。不过它还有更坏的:
任何其他的操作将会导致Rust的未定义行为
(undefined behavior)。
在包装器内的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的开发者必须付出的代价。
对于那些正在绞尽脑汁的C程序员来说,在这段代码中不需要读取迭代器,因为是未定义行为。排他性规则还支持编译器优化,这可能会导致由于迭代器的共享引用产生不一致的观察结果。(例如栈溢出或者重新排序指令以提高效率)。这些情况将可能在可变引用创建后的任何时间发生。
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,但是每个name都改为foo
。
folder也可以定义为将一个数据结构映射到不同(但基本相似)的数据结构。例如,我们可以把一个AST转换到一个高级中间代码表示树(HIR Tree)。
出发点
通过对数据结构中的每个节点执行一些操作来映射一个数据结构是常见的。对于简单结构上的简单操作,可以用Iterator::map
来实现。对于更复杂的操作,或者前面的节点会影响后面节点的操作,或者数据结构上的循环是非平凡的,用fold模式更为妥帖。
类似访问者模式,fold模式允许我们将数据结构的遍历与对每个节点执行的操作分开。
讨论
采用这种方式映射数据结构在函数式语言中很常见。在面向对象语言中,更常见的是就地修改数据结构。Rust中常见的是"函数式"的方法,主要是因为引用的不可变性。 采用新生成数据结构而不是修改原来的结构,使在大多数情况下对代码推理更容易。
效率和可重用性之间的权衡可以通过改变fold_*
方法对节点的接受方式来调整。
在上面的例子里我们通过Box
指针来操作。因为独占数据,原始的数据结构不能再被使用。另一方面如果一个节点不再修改,重用它将会更高效。
如果我们对借用的引用进行操作,原来的数据结构就能被重用。不过一个节点哪怕没修改也必须克隆才能保证独占。
使用计数指针可以兼得二者——我们既可以重用原始数据结构并且我们不需要克隆没有被改变的节点。不过这不太符合人体工程学并且意味着数据结构不能是可变的。
参阅
迭代器有fold
方法,不过这个fold是将数据结构压缩成一个值而不是产生一个新的数据结构。迭代器的map
更像是这里说的fold模式。
在其他语言中,更常见的是Rust迭代器中的fold形式而不是这里说的fold模式。一些函数式语言中有对数据结构进行复杂转换的支持。
访问者模式和fold高度相关。 它们共享遍历数据结构的概念——在每个节点上执行操作。不过访问者模式不创建新的数据结构也不消耗原来的数据。
解释器
说明
如果一个问题经常出现并且需要很多且重复的步骤来解决,那么问题应该被抽象为一个简单的语言并且一个解释器对象能通过解释这种语言的句子来解决问题。
基本上,对于我们定义的任何类型的问题有如下三点:
- 领域专用语言,
- 这种语言的语法,
- 解决问题实例的解释器
出发点
我们的目标是转换简单的数学表达式为后缀表达式。(逆波兰表达式)。
为简单起见,表达式包含十个数字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
解决方案
我们只需实现一个递归下降解析器。为了简单起见,当表达式语法错误时,代码会恐慌。(例如根据语法定义,2-34
或者2+5-
是错误的)
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!
支持定义特殊语法和如何展开这种语法为源代码的规则。
在下面的例子中我们创建了一个简单的宏来计算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)); }
See also
新类型
如果在某些情况下,我们希望一个类型的行为类似于另一个类型,或者在编译时强制执行某些行为,而仅使用类型别名是不够的呢?
举例来说,如果我们出于安全考虑想要创建一个String
的自定义的Display
实现(例如密码)。
这种情况我们可以用新类型
模式提供类型安全和封装。
说明
用带有单独字段的结构来创建一个类型的不透明包装器。这将创建一个新类型,而不是类型的别名。
代码示例
// 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(
//..
) -> Bar {
//..
}
//..
}
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
。
优点
被包装的类型和包装后的类型是不兼容的,所以新类型的用户永远不会困惑于区分这二者的类型。
新类型是零开销抽象——没有运行时负担。
隐私系统确保用户不能访问包装的类型(如果字段是私有的,默认私有)。
缺点
新类型的缺点(尤其是与类型别名比较),是没有特殊的语言支持。这就意味着会有大量的啰嗦的样板代码。对于要在包装类型上公开的每个方法,都需要一个穿透的方法,还有对包装器类型的实现来支持每一个想要的特性。
讨论
在Rust代码中新类型模式是很常见的。抽象或表达单元是最常见的用法,但他们也可以用于其他原因:
- 限制功能(减少暴露的函数或者特性实现),
- 使具有复制语义的类型具有移动语义
- 通过提供更具体的类型来进行抽象,从而隐藏内部类型,例如
pub struct Foo(Bar<T1, T2>);
在这里Bar
也许是一个公开的泛型,T1
和T2
是一些内部类型。我们模块的用户不应该知道我们通过Bar
来实现Foo
,但是我们真正想隐藏的是类型T1
和T2
,以及他们是如何被Bar
使用的。
参阅
- Advanced Types in the book
- Newtypes in Haskell
- Type aliases
- derive_more, a crate for deriving many builtin traits on newtypes.
- The Newtype Pattern In Rust
RAII 守卫
说明
RAII是个糟糕的名字,代表“资源获取即初始化”。该模式的本质是,资源的初始化在对象的构造函数中完成,以及确定性析构器。通过使用一个RAII对象作为一些资源的守卫,并且依赖类型系统确保访问始终要通过守卫对象,以此在Rust中扩展这种模式。
代码示例
互斥保护是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
方法也一样可以。
参阅
Finalisation in destructors idiom
RAII is a common pattern in C++: cppreference.com, wikipedia.
Style guide entry (currently just a placeholder).
偏爱更小的库
说明
Prefer small crates that do one thing well.
偏向于使用专注于做好一件事的库。
Cargo和crate.io使得使用第三方库更简单,比C和C++在这一点上更强。此外,因为crates.io上的包发布后就不能编辑和撤销,任何发布在未来都要能够工作。我们应该采用这种工具的优点,并且使用更小的,更细粒度的依赖。
优点
- 小的库更容易理解,并且鼓励更加模块化代码。
- 库支持在不同项目间重用代码。举例来说,
url
库是作为Servo浏览器引擎的一部分开发的,但是其也被广泛用于这个项目之外。由于Rust的编译单元是Crate,所以讲一个项目拆分为多个Crate可以允许并行编译更多的代码。
缺点
- 当一个项目依赖多个有矛盾版本的库时,会导致“依赖地狱”。举例来说,
url
库有0.5和1.0两个版本。由于Url
在url:1.0
中和url:0.5
中是不同的类型,一个使用url:0.5
的HTTP客户端不能接受使用url:1.0
的网络爬虫传递的Url
值。 - 在crates.io上的包时没有策划的。一个库可能写的不好,只有没有帮助的文档,或者是彻头彻尾的恶意代码。
- 两个小库可能比一个大的库的优化要更少,因为编译器默认没有开启链接时优化。
示例
ref_slice
库提供转换&T
为&[T]
的函数。
url
库提供处理URL的工具。
num_cpus
库提供一个函数来查询机器上的CPU数量。
See also
策略模式
说明
策略模式是支持关注点分离的一门技术。 它还支持通过 依赖倒置来分离软件模块。
策略模式背后的基本思想是,给定一个解决特定问题的算法,我们仅在抽象层次上定义算法的框架,并将指定的算法实现分成不同的部分。
这样,使用该算法的客户端可以选择特定的实现,而通用的算法工作流可以保持不变。换句话说,类的抽象规范不依赖于派生类的具体实现,而是具体实现必须遵循抽象规范。这就是我们为什么叫它“依赖倒置”。
出发点
想象一下我们正在开发一个需要每个月生成报告的项目。我们需要用不同格式生成报告(不同策略)例如用JSON
或者富文本
。但是事物是在发展的,我们也不知道未来有什么需求。例如,我们也许需要用一种全新的格式生成报告,或者是修改我们已有的一种格式。
代码示例
在这个例子中我们的不变量(或者说抽象)是Context
,Formatter
和Report
,同时Text
和Json
是我们的策略结构体。这些策略都要实现Formatter
特性。
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
的特定实现,尽管输出的实现并不关心数据是如何被预处理、存储和抓取的。它仅仅需要知道上下文和需要实现的特定的特性和方法,就像Formatter
和run
。
缺点
对于每个策略,必须至少实现一个模块,因此模块的数量会随着策略数量增加。如果有很多策略可供选择,那么用户就必须知道策略之间的区别。
讨论
在前面的例子中所有的策略实现都在一个文件中。提供不同策略的方式包括:
- 所有都在一个文件中(如本例所示,类似于被分离为模块)
- 分离成模块,例如
formatter::json
模块、formatter::text
模块 - 使用编译器特性标志,例如
json
特性、text
特性 - 分离成不同的库,例如
json
库、text
库
Serde库是策略模式的一个实践的好例子。Serde通过手动实现Serialize
和Deserialize
特性支持完全定制化序列化的行为。例如,我们可以轻松替换serde_json
为serde_cbor
因为它们暴露相似的方法。有了它,库serde_transcode
更有用和符合人体工程学。
不过,我们在Rust中不需要特性来实现这个模式。
下面这个玩具例子演示了用Rust的闭包
来实现策略模式的思路:
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已经将这个思路用于Option
的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()); }
See also
将不安全置于小模块中
说明
如果你有unsafe
代码,创建尽可能小的模块来支持所需的不变量,从而在不安全的基础上创建最小的安全接口。将其嵌入到只包含安全代码的较大模块中,并且提供一个符合人体工程学的接口。注意,外部模块可以包含直接调用不安全代码中的不安全函数和方法。用户可以此来获取性能提升。
优点
- 限制了必须审核的不安全代码
- 编写外部模块要更容易,因为你可以依靠内部模块的安全保证。
缺点
- 有时,找一个合适的接口是很困难的。
- 抽象可能会降低效率。
示例
toolshed
库将不安全操作放在了子模块中,提供了安全的接口给用户。std
的String
类是利用Vec<u8>
封装加上内容必须是合法的UTF-8编码。String
上的操作确保了这种行为。不过,用户也可以用不安全的方法来创建一个String
,在这种情况下用户有责任保证内容的有效性。
参阅
访问者模式
说明
访问者封装了在不同对象集合上运行的算法。它支持在不修改数据的情况下,支持不同算法。(或者它们的主要行为)
此外,访问者模式允许将对象集合的遍历与对每个对象执行的操作分离开来。
代码示例
// 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_*
通常返回空值(与示例中的相反)。在这种情况下,可以将遍历代码分解出来并在算法之间共享。(并且提供空的默认方法)。在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
方法来履行同样的职责。
参阅
访问者模式是面向对象语言中的一个常见模式。
fold模式与访问者模式很相似,区别在于生成了被访问数据结构的新版本。
反模式
反模式是一种解决经常出现的问题的方案,但其通常是无效的并且风险颇高、适得其反。就像知道如何解决问题一样有价值的是知道不去解决它。反模式为我们提供了与设计模式相关的反例。反模式并不局限与代码。例如,进程也可以是一种反模式.
Clone过借用检查
说明
借用检查阻止了Rust用户开发不安全的代码,以此保证:只存在一个可变引用,或者(许多)不可变引用。如果编写的代码不符合这些条件,而开发者通过克隆变量来解决编译器错误,就会产生这种反模式。
示例
#![allow(unused)] fn main() { // 定义任意变量 let mut x = 5; // 借用 `x`(先clone) let y = &mut (x.clone()); // 由于 x.clone(), x 并未被借用, 这行代码可以运行。 println!("{}", x); // 用这个借用做点什么,防止因Rust优化直接砍掉这个借用 *y += 1; }
出发点
用这种模式来解决借用检查令人困惑的问题是很诱人的,特别是对于初学者来说。然而,这有严重的后果。使用.clone()
会导致数据被复制。两者之间的任何变化都不会同步——因为会有两个完全独立的变量存在。
有种特殊情况—— Rc<T>
被设计为智能处理 clone
。它在内部确切管理着一份数据的副本,clone它只会clone引用。
还有Arc<T>
,它提供堆分配类型T的共享所有权。对Arc
调用.clone()
会得到新的Arc
实例,它指向和源Arc
相同的栈分配,增加引用计数。
一般来说,应该经过深思熟虑,充分了解其后果再clone。如果用clone消除借用检查器报错,很可能你使用了这种反模式。
即使.clone()
是坏模式的预兆,有时编写低效率的代码是可以的,比如这些情况时:
- 开发者不大懂所有权
- 代码没有什么速度或内存限制(如黑客马拉松项目或原型)。
- 借用检查器太复杂了,而你更愿意优化可读性,而非性能
如果你怀疑做了不必要的clone,在评估是否需要clone之前,先去弄懂《Rust Book》的所有权章节。
此外要保证一直给你的项目跑cargo clippy
,它可以判断一些.clone()
调用不必要的情况,比如甲,乙,丙或者丁.
参见
#![deny(warnings)]
说明
一个善意的库作者想要确保他们的代码在编译时不会产生警告。因此他们在库里标注以下内容:
示例
#![allow(unused)] #![deny(warnings)] fn main() { // 一切安好 }
优点
它很短,如果有什么错误就停止编译。
缺点
通过禁用编译器生成警告,库的作者放弃了Rust的稳定性。有时新的特性或者旧的不合格的特性需要被更改,因此,将会在一段宽限期内给出警告,之后变成禁用。
举例来说,一个类型可以有两个具有相同方法的实现。这被认为是一个坏主意,但是为了顺利过渡,引入 overlapping-inherent-impls
提示来警告那些在将来版本中出现严重错误的人。
而且有时API会被弃用,所以使用它们会发出警告。
所有的这些在改变时都可能破坏编译过程。
此外,除非这个删除注释,否则不能再使用提供额外警告的库。(例如rust-clippy)这可以通过--cap-lints缓解。--cap-lints=warn
命令行参数将所有的deny
提示的错误转换为警告。
替代方案
解决这个问题有两种方法:第一种,我们可以将编译设置与代码解耦;第二种,我们可以显式地命名要拒绝的警告。
下面这个命令行参数将会带着所有关闭的警告进行编译:
RUSTFLAGS="-D warnings" cargo build
任何独立开发者都可以这样做(或者设置到持续集成工具,如Travis,但是记住当某些内容发生变化时,可能会破坏编译)。
或者,我们可以指定我们想要在代码中关闭的警告。下面是警告提示列表(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)]
此外,下面的提示是推荐关闭的:
#[deny(missing-debug-implementations,
missing-docs,
trivial-casts,
trivial-numeric-casts,
unused-extern-crates,
unused-import-braces,
unused-qualifications,
unused-results)]
有时可能需要增加missing-copy-implementations
到清单中。
请注意,我们没有关闭deprecated
提示,因为可以肯定的是,将来会有更多不推荐的API。
参阅
- deprecate attribute documentation
- Type
rustc -W help
for a list of lints on your system. Also typerustc --help
for a general list of options - rust-clippy is a collection of lints for better Rust code
Deref
多态
说明
滥用Deref
特性,模拟结构体之间的继承,从而重用方法。
代码示例
有时我们想要从诸如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
对象。这是非常诡异的,解引用通常是通过一个类型的引用获取这个类型的值,然而这里却是两种不相关的类型。不过,因为点运算符是隐式的解引用,所以方法调用时也将搜索Foo
类型的方法。
优点
节省了一些样板代码,例如:
impl Bar {
fn m(&self) {
self.f.m()
}
}
缺点
最重要的是这是一个令人惊讶的习惯用法——未来的程序员在阅读这些代码时不会期望发生这种情况。这是因为我们滥用了Deref
特性,而不是按预期的那样去使用。同时也是因为这里的机制是完全隐式的。
这种模式并没有实现像Java或者C++里的继承。此外,对Foo
实现的特性也不会自动地适用于Boo
,所以这种模式对于边界检查和泛型编程来说非常差。
使用这种模式,就self
而言,给出了与大多数面向对象语言截然不同的语义。通常它仍是子类型的引用,在这种模式下它将是定义方法的“类”。
最后,这种模式仅支持单继承,并且没有接口的概念、基于类的隐私性或者其他的与继承相关的特性。因此,对于习惯于Java那种继承的程序员来说,它提供了一种“惊喜”。
讨论
这没有好的替代方案。根据具体情况,最好用特性重新实现,或者手动编写分发给Foo
的方法。我们确实打算为Rust添加一种像这样的继承机制,
但是可能需要一段时间才能进入稳定版本的Rust。看这些 博客、
文章
和这个RFC issue 来了解更多细节。
Deref
特性是被设计用来实现自定义指针类型的。它的用处是将T
的引用转变为T
的值,而不是在类型间转换。遗憾的是,这不是(或者说无法)靠特性定义来强制执行。
Rust尝试在显式和隐式机制之间做出权衡,更偏向于类型间进行显式转换。点运算符自动解引用是出于符合人体工程学的角度做的隐式设计,其目的仅限于有限的间接程度,而不是任意类型之间做隐式转换。
参阅
- Collections are smart pointers idiom.
- Delegation crates for less boilerplate like delegate or ambassador
- Documentation for
Deref
trait.
Rust中函数式用法
Rust是一种命令式语言,但是它也遵循很多函数式语言的范式。
在计算机科学中,函数式编程是一种通过应用和组合函数来编程的一种范式。它是一种声明式编程范式,其中函数的定义是每个表达式返回一个值的表达式树,而不是一系列改变程序状态的命令语句。
编程范式
当出于一个命令式的背景时,理解函数式程序最大的障碍之一就是思维的转变。命令式程序说明了如何做,然而声明式程序说明做了什么。让我们用对1到10求和的例子来说明这一点。
命令式
#![allow(unused)] fn main() { let mut sum = 0; for i in 1..11 { sum += i; } println!("{}", sum); }
在命令式程序中,我们必须用编译器来查看发生了什么。这里sum
起始为0,然后我们在1到10范围内循环,每次循环中我们加上对应的值,最后输出。
i | sum |
---|---|
1 | 1 |
2 | 3 |
3 | 6 |
4 | 10 |
5 | 15 |
6 | 21 |
7 | 28 |
8 | 36 |
9 | 45 |
10 | 55 |
这就是我们大多数人开始编程的方式。我们了解到程序是一些操作步骤的集合。
声明式
#![allow(unused)] fn main() { println!("{}", (1..11).fold(0, |a, b| a + b)); }
哇哦!这真是不一样!这里发生了啥?记住声明式程序说明了做了什么,而不是如何去做。fold
是一个 组合函数的函数。这个名字来自于Haskell。
这里,我们组合了在1到10范围内的加法函数(闭包|a,b| a + b
)。0
是起始点,所以a
最开始是0
,b
是范围的第一个元素1
。结果是
0 + 1 = 1
。所以现在我们再次fold
,a = 1
、b = 2
下一个结果是1 + 2 = 3
。这个过程一直持续到范围内最后一个元素10
。
a | b | result |
---|---|---|
0 | 1 | 1 |
1 | 2 | 3 |
3 | 3 | 6 |
6 | 4 | 10 |
10 | 5 | 15 |
15 | 6 | 21 |
21 | 7 | 28 |
28 | 8 | 36 |
36 | 9 | 45 |
45 | 10 | 55 |
泛型作为类型类
说明
Rust的类型系统设计的更像函数式语言(比如Haskell),而非指令式语言如Java和C++。因此,Rust可以将许多编程问题转换成“静态类型”问题。这是选择函数式语言时最大的亮点之一,对于Rust的许多编译时保证来说是至关重要的。
这个概念的一个关键部分正是泛型的工作方式。在C++与Java中,举个例子,泛型是编译器的一种元编程结构。C++的vector<int>
和vector<char>
只是vector
类型(叫模板
)的同一模板代码的两个不同副本,其中填充了两种不同的类型。
在Rust中,泛型参数如同函数式语言中的“类型类约束”,而最终用户填写的每个不同的参数实际上都会改变类型。换句话说,Vec<isize>
和Vec<char>
是两个不同的类型,它们被类型系统识别为不同的类型。
这被称作单态化,不同类型以多态代码创建。这种特殊行为需要用impl
块指定泛型参数:泛型的不同值会导致不同的类型,而不同的类型可以有不同的impl
块。
在面向对象语言中,类可以从父类那里继承行为。实际上,这不仅允许将额外的行为附加到类型类的特定成员上,还允许附加额外的行为。
最接近的是Javascript和Python中的运行时多态性,新的成员可以被任何构造函数随意添加到对象中。然而,与这些语言不同,Rust的所有额外方法在使用时都可以进行类型检查,因为它们的泛型是静态定义的。这使得它们在保持安全的同时更具有实用性。
示例
想象你正在为实验室机器集群设计存储服务器。因为涉及的软件,有两个不同的协议需要你支持。BOOTP(用于PXE网络启动),和NFS(用于远程安装存储)。
你的目标是一个用Rust编写的程序,它可以处理这两种请求。它将有协议handler,监听两种请求。此外,主应用逻辑要允许实验室管理员配置实际文件的存储和安全控制。
不管来自什么协议,实验室机器对文件的请求都包含相同的基本信息:一个认证方法,和一个要检索的文件名。一个直接的实现会是这样的:
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 {
// ... 其他方法 ...
/// 如果有NFS请求,获取一个NFS挂载点。
/// 否则返回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会话管理给省了 } mod bootp { pub(crate) struct AuthInfo(); // 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(); // 没有附加元数据 impl ProtoKind for Bootp { type AuthInfo = bootp::AuthInfo; fn auth_info(&self) -> Self::AuthInfo { bootp::AuthInfo() } } } use proto_trait::ProtoKind; // 保持内部,以防止 impl pub use proto_trait::{Nfs, Bootp}; // 重导出,这样调用者能看到它们 struct FileDownloadRequest<P: ProtoKind> { file_name: PathBuf, protocol: P, } // 把所有共同的API部分放进一个泛型实现块 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() { // 你代码扔这儿 }
对于这个方法,如果用户搞错了,使用了错误的类型:
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"),
_ => {} // 继续下去...
}
// 剩余代码部分放这里
}
}
会得到一个类型错误。类型FileDownloadRequest<Bootp>
没实现mount_point()
,只有类型FileDownloadRequest<Nfs>
实现了。而且说到底,那是NFS模块创建的,不是BOOTP!
优点
首先,它可以去重多个状态下共有的字段。通过使非共享字段成为泛型字段,它们只需要实现一次。
其次,它使impl
块更容易阅读,因为它们是按状态分解的。所有状态下通用的方法都在一个块中输入一次,而某个状态下特有的方法则在一个单独的块中。
这两种情况都意味着代码行数更少,而且更有条理。
缺点
目前这将增加二进制文件大小,这是编译器实现单态化的方式造成的。希望这种实现方式在未来能够得到改善。
替代
参见
这种模式在整个标准库中都有应用。
Vec<u8>
can be cast from a String, unlike every other type ofVec<T>
.1- They can also be cast into a binary heap, but only if they contain a type that implements the
Ord
trait.2 - The
to_string
method was specialized forCow
only of typestr
.3
它也被一些流行的crate使用,用以改进API灵活性:
-
The
embedded-hal
ecosystem used for embedded devices makes extensive use of this pattern. For example, it allows statically verifying the configuration of device registers used to control embedded pins. When a pin is put into a mode, it returns aPin<MODE>
struct, whose generic determines the functions usable in that mode, which are not on thePin
itself. ^4 -
hyper
HTTP客户端库用它为不同可插拔请求导出富API。Clients with different connectors have different methods on them as well as different trait implementations, while a core set of methods apply to any connector. ^5 -
The "type state" pattern -- where an object gains and loses API based on an internal state or invariant -- is implemented in Rust using the same basic concept, and a slightly different technique. ^6
https://docs.rs/stm32f30x-hal/0.1.0/stm32f30x_hal/gpio/gpioa/struct.PA0.html
https://docs.rs/hyper/0.14.5/hyper/client/struct.Client.html
The Case for the Type State Pattern and Rusty Typestate Series (an extensive thesis)
补充材料
补充有用内容的集合
演讲
- Design Patterns in Rust by Nicholas Cameron at the PDRust (2016)
- Writing Idiomatic Libraries in Rust by Pascal Hertleif at RustFest (2017)
- Rust Programming Techniques by Nicholas Cameron at LinuxConfAu (2018)
书(在线)
设计原则
常见设计原则概述
SOLID
- 单一权责原则Single Responsibility Principle (SRP): 一个类只应有一种责任,只有对软件中特定的一部分修改时才会影响到类。
- 开闭原则Open/Closed Principle (OCP): 软件应该对扩展开放,但是对修改封闭。
- 里氏替换原则Liskov Substitution Principle (LSP): 子类可以扩展父类的功能,但不能改变父类原有的功能
- 接口隔离原则Interface Segregation Principle (ISP): 多个专一功能的接口比一个泛用的接口要好。
- 依赖倒置原则Dependency Inversion Principle (DIP): 应该依赖抽象而不是依赖于细节。
DRY (Don’t Repeat Yourself)
在一个系统中,每一个知识都必须有一个单一、明确、权威的表示。
KISS原则KISS principle
绝大多数系统简单时比复杂时工作的要好。因此简单性是设计中的关键目标,并且应该避免不必要的复杂性。
迪米特法则Law of Demeter (LoD)
一个实体应该尽可能少的与任何其他的结构或者特性(包括子组件)发生关系,符合“信息隐藏”的原则。
契约式设计Design by contract (DbC)
软件设计者应该为软件组件定义规范、准确和可验证的接口,扩展了抽象数据类型的平凡定义,包括前置条件、后置条件和不变量。
封装Encapsulation
将数据与对该数据进行操作的方法捆绑在一起,或者限制对对象某些组件的直接访问。封装用于隐藏类中结构体对象的值或状态,防止未经授权地直接访问它们。
命令查询分离原则Command-Query-Separation(CQS)
函数不应该产生抽象的副作用,只允许命令(过程)产生副作用——Bertrand Meyer:《面向对象软件构造》
最小惊奇原则Principle of least astonishment (POLA)
系统的组件应该像人们期望的那样工作,而不应该给用户一个惊奇。
语言模块单元Linguistic-Modular-Units
模块必须与使用的语言单元相符合——Bertrand Meyer:《面向对象软件构造》
自文档Self-Documentation
一个模块的设计者应该努力使所有关于该模块的信息成为模块本身的一部分——Bertrand Meyer:《面向对象软件构造》
统一访问原则Uniform-Access
一个模块提供的所有服务都应该通过一个统一的符号来提供,而这个符号并不表明它们是通过存储还是通过计算来实现的。——Bertrand Meyer:《面向对象软件构造》
单一选择Single-Choice
每当软件系统必须支持一组备选方案时,系统中应该只有一个模块知道它们的底细。——Bertrand Meyer:《面向对象软件构造》
存储闭包Persistence-Closure
当存储一个对象时,必须将其所依赖的部分一起存储。每当检索机制检索以前存储的对象时,它还必须检索该对象的尚未检索到的所有依赖项。——Bertrand Meyer:《面向对象软件构造》