Rust作为一门系统编程语言,以其安全性、高性能和并发特性,受到了越来越多开发者的喜爱。但Rust的学习曲线比较陡峭,很多人掌握了基础语法之后,就不知道该怎么进阶了。今天,分享一些Rust 1.5+的进阶技巧,包括所有权、生命周期、trait、错误处理、性能优化等方面,这些技巧,可能很多人都不知道,但掌握了之后,能让你的Rust代码,更优雅、更高效、更地道。

先说说我和Rust的故事。我学Rust,是在两年前,那时候,Rust 1.0刚发布不久,社区还比较小,资料也不多。我被Rust的理念吸引了:零成本抽象、内存安全、无数据竞争、高性能,这些特性,对于做系统编程的我来说,太有吸引力了。

于是,我开始学Rust,最开始,被所有权、借用、生命周期这些概念,折磨得死去活来,写代码的时候,编译器总是报错,不是这里不能借用,就是那里生命周期不够,感觉自己像个新手,连最简单的代码都写不出来。

但我没有放弃,坚持学了下来,慢慢地,理解了Rust的设计理念,掌握了所有权和生命周期的规律,写代码也越来越顺手了。现在,Rust已经成为我最喜欢的编程语言之一,很多项目,我都用Rust来写。

在使用Rust的过程中,我积累了很多进阶的技巧和最佳实践,这些技巧,很多是我踩了很多坑,才总结出来的,也有一些,是从社区和优秀的开源项目里学到的。今天,就把这些技巧,分享出来,希望能帮助大家,在Rust的进阶路上,少走弯路。

一、所有权和借用:理解了就不难

所有权和借用,是Rust最核心的概念,也是Rust区别于其他编程语言的关键。很多人学Rust,就是被这两个概念卡住了。但只要理解了它们的本质,就会发现,其实并不难。

1. 理解所有权的三条规则

Rust的所有权,有三条核心规则,理解了这三条规则,就理解了所有权的本质:

  1. Rust中每一个值,都有一个被称为所有者(owner)的变量。
  2. 一个值,在任意时刻,有且只有一个所有者。
  3. 当所有者(变量)离开作用域时,这个值将被丢弃(drop)。

这三条规则,看起来简单,但却是Rust内存安全的基础。因为每个值,只有一个所有者,所以,当所有者离开作用域时,值就会被自动释放,不需要垃圾回收,也不会有内存泄漏。同时,因为只有一个所有者,所以不会有两个指针,同时修改同一块内存,也就不会有数据竞争。

很多人,写Rust代码的时候,总是遇到所有权的错误,就是因为没有遵守这三条规则。比如,把一个值赋值给另一个变量,原来的变量就不能再用了,因为所有权转移了。再比如,函数传参,如果是传值,所有权就转移到函数里了,调用完之后,原来的变量就不能再用了。

理解了这三条规则,写代码的时候,就会有意识地考虑所有权的转移,就不会犯那些低级的错误了。

2. 借用(Borrowing):不用转移所有权

如果每次传参,都要转移所有权,那写代码就太麻烦了,用完之后,还要把所有权传回来。所以,Rust提供了借用(Borrowing)的机制,就是引用一个值,而不获取它的所有权。

借用,有两条规则:

  1. 在任意时刻,要么只能有一个可变引用(&mut T),要么只能有多个不可变引用(&T),两者不能同时存在。
  2. 引用必须总是有效的(不能悬垂引用)。

这两条规则,是Rust内存安全的另一个基础。第一条规则,保证了不会有数据竞争,因为要么只有一个可变引用,可以修改,但不能同时读;要么有多个不可变引用,可以同时读,但不能修改。第二条规则,保证了不会有悬垂引用,引用的对象,一定是有效的。

很多人,写Rust代码的时候,总是遇到借用检查器的错误,就是因为违反了这两条规则。比如,同时有一个可变引用和一个不可变引用,编译器就会报错。再比如,引用了一个已经被释放的值,编译器也会报错。

理解了这两条规则,写代码的时候,就会有意识地管理引用的生命周期,就不会犯那些借用的错误了。

3. 智能指针:灵活管理所有权

除了普通的引用,Rust还提供了智能指针,用来更灵活地管理所有权和内存。常用的智能指针有:

  • Box<T>:在堆上分配内存,把值存在堆上,栈上存一个指针。适合递归类型、大对象、trait对象等场景。
  • Rc<T>:引用计数指针,允许多个所有者,每个所有者,都共享同一个值,当最后一个所有者离开作用域时,值才会被释放。适合单线程下,多个地方共享同一个值的场景。
  • Arc<T>:原子引用计数指针,和Rc类似,但线程安全,可以在多线程下共享。
  • RefCell<T>:内部可变性,允许在有不可变引用的时候,修改内部的值,借用检查,从编译期,移到了运行期。适合需要内部可变性,但编译器的借用检查太严格的场景。
  • Cell<T>:和RefCell类似,但适合Copy类型,通过get和set来访问和修改值。

这些智能指针,各有各的适用场景,合理使用,可以让代码更灵活,更简洁。比如,需要多个所有者的时候,用Rc或者Arc;需要内部可变性的时候,用RefCell或者Cell;需要在堆上分配的时候,用Box。

当然,智能指针,也不是万能的,用的时候,要注意它们的限制和开销。比如,Rc和Arc,有引用计数的开销;RefCell,有运行时借用检查的开销,而且,如果违反了借用规则,会在运行时panic。所以,要根据具体场景,选择合适的智能指针。

二、生命周期:不是洪水猛兽

生命周期,是Rust里,最让人头疼的概念之一,很多人,一看到生命周期标注,就头大。但其实,生命周期,并没有那么可怕,理解了它的本质,就会发现,它只是用来帮助编译器,确认引用有效的工具而已。

1. 理解生命周期的本质

生命周期,本质上,就是引用的有效范围。编译器需要知道,一个引用,在什么范围内是有效的,什么时候会失效,这样,才能保证,不会出现悬垂引用。

大部分时候,编译器可以自己推断出生命周期,不需要我们手动标注。但有些时候,编译器推断不出来,就需要我们手动标注,告诉编译器,这些引用之间的生命周期关系。

比如,函数返回一个引用,这个引用,可能来自参数,也可能来自局部变量。如果来自局部变量,那就是悬垂引用,是错误的。所以,编译器需要知道,返回的引用,和哪个参数的生命周期是一样的,这样,才能保证,返回的引用,是有效的。这时候,就需要我们手动标注生命周期。

所以,生命周期标注,不是给引用指定生命周期,而是告诉编译器,这些引用之间的生命周期关系,让编译器能做更准确的检查。

2. 生命周期省略规则

很多时候,我们不需要手动标注生命周期,因为编译器有生命周期省略规则,可以自动推断。生命周期省略规则,有三条:

  1. 每一个引用参数,都有自己的生命周期参数。
  2. 如果只有一个输入生命周期参数,那么它被赋予所有输出生命周期参数。
  3. 如果方法有多个输入生命周期参数,并且其中一个是&self或者&mut self,那么self的生命周期,被赋予所有输出生命周期参数。

这三条规则,覆盖了大部分常见的场景,所以,很多函数,我们不需要标注生命周期,编译器也能推断出来。只有当这三条规则,都无法推断出输出生命周期的时候,才需要我们手动标注。

理解了这三条省略规则,就知道什么时候需要标注生命周期,什么时候不需要了,也能看懂,为什么有些函数,不需要标注生命周期,也能编译通过。

3. 生命周期标注的常见模式

需要手动标注生命周期的时候,大部分都是一些常见的模式,掌握了这些模式,就不会觉得生命周期难了。

最常见的模式,就是函数的返回引用,和某个参数的生命周期一样:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

这里,标注了'a,告诉编译器,x和y的生命周期,至少要和'a一样长,返回的引用,生命周期也是'a,这样,编译器就知道,返回的引用,是有效的。

另一个常见的模式,是结构体里有引用字段:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

这里,结构体里有一个引用字段,所以,需要给结构体,加上生命周期参数'a,告诉编译器,这个引用的生命周期,至少要和结构体的实例一样长,这样,就不会出现悬垂引用。

还有一个常见的模式,是方法里的生命周期,大部分时候,因为有&self,所以,根据省略规则第三条,返回的引用,生命周期和self一样,不需要手动标注。

掌握了这些常见的模式,写代码的时候,遇到需要标注生命周期的地方,就知道该怎么标注了,不会再一头雾水。

4. 静态生命周期'static

还有一个特殊的生命周期,就是'static,意思是,这个引用,在整个程序运行期间,都是有效的。比如,字符串字面量,就是'static生命周期的,因为它存在程序的二进制文件里,整个程序运行期间,都不会被释放。

很多时候,编译器会提示我们,用'static生命周期,但不要随便用,因为'static意味着,这个引用,要一直存在,不能被释放,如果用错了,可能会导致内存泄漏,或者逻辑错误。只有当这个引用,真的是整个程序运行期间都有效的时候,才用'static。

三、trait:Rust的灵魂

trait,是Rust里,非常重要的概念,可以说是Rust的灵魂。trait,类似于其他语言里的接口,但比接口更强大,更灵活。掌握了trait,就能写出更抽象、更通用、更优雅的Rust代码。

1. trait的基本用法

trait,定义了一组方法签名,类型可以实现这个trait,提供这些方法的具体实现。比如:

trait Summary {
    fn summarize(&self) -> String;
}

struct Article {
    title: String,
    content: String,
}

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("{}: {}", self.title, &self.content[..50])
    }
}

这里,定义了一个Summary trait,有一个summarize方法,然后,Article类型实现了这个trait,提供了summarize的具体实现。

trait的好处是,可以定义通用的行为,不同的类型,可以实现同一个trait,然后,我们可以写泛型函数,接受任何实现了这个trait的类型,实现多态。

2. trait作为参数:impl Trait和泛型约束

trait可以作为函数参数的类型,有两种写法:impl Trait和泛型约束。

impl Trait的写法,比较简洁:

fn notify(item: impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

这个函数,接受任何实现了Summary trait的类型作为参数。

泛型约束的写法,更灵活,可以有多个约束:

fn notify<T: Summary>(item: T) {
    println!("Breaking news! {}", item.summarize());
}

如果需要多个约束,可以用+号:

fn notify<T: Summary + Display>(item: T) {
    // ...
}

如果约束太多,可以用where子句,让代码更清晰:

fn some_function<T, U>(t: T, u: U) -> i32
where
    T: Display + Clone,
    U: Clone + Debug,
{
    // ...
}

这两种写法,各有适用场景,impl Trait简洁,适合简单的场景;泛型约束灵活,适合复杂的场景,尤其是需要多个参数,类型相同的场景。

3. trait对象:动态分发

上面的泛型,是静态分发,编译的时候,就确定了具体的类型,性能好,没有运行时开销。但有些时候,我们需要在运行时,才能确定类型,这时候,就需要trait对象,实现动态分发。

trait对象的写法,是&dyn Trait或者Box<dyn Trait>:

fn notify(item: &dyn Summary) {
    println!("Breaking news! {}", item.summarize());
}

或者,把trait对象存在Box里,放在堆上:

let items: Vec<Box<dyn Summary>> = vec![
    Box::new(Article { ... }),
    Box::new(Tweet { ... }),
];

trait对象,允许把不同的类型,存在同一个集合里,只要它们都实现了同一个trait。这在一些场景下,非常有用,比如,插件系统、UI组件、事件处理等。

但trait对象,是动态分发,有运行时开销,因为需要在运行时,查找方法表,调用对应的方法。而且,trait对象,有对象安全的限制,不是所有的trait,都能作为trait对象。比如,trait的方法,返回Self类型的,就不能作为trait对象,因为运行时,不知道Self的具体大小。

所以,trait对象,要在需要的时候才用,能用静态分发的,就用静态分发,性能更好。

4. 默认实现和继承

trait的方法,可以有默认实现,这样,类型实现trait的时候,可以只实现需要的方法,其他方法,用默认实现就行:

trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("Read more from {}...", self.summarize_author())
    }
}

这里,summarize有默认实现,类型实现trait的时候,只需要实现summarize_author就行,summarize可以用默认实现,也可以覆盖。

trait之间,也可以有继承关系,一个trait,可以继承另一个trait的方法:

trait Summary {
    fn summarize(&self) -> String;
}

trait ExtendedSummary: Summary {
    fn extended_summarize(&self) -> String {
        format!("Extended: {}", self.summarize())
    }
}

这里,ExtendedSummary继承了Summary,所以,实现ExtendedSummary的类型,也必须实现Summary。

这些特性,让trait非常灵活,可以定义复杂的抽象,写出很优雅的代码。

5. 常用的标准库trait

Rust的标准库,定义了很多常用的trait,掌握这些trait,能让代码更地道。常用的有:

  • Display和Debug:用于格式化输出,Display是给用户看的,Debug是给开发者看的。
  • Clone和Copy:Clone是克隆,Copy是隐式复制,Copy类型,赋值的时候,不会转移所有权,而是复制。
  • PartialEq和Eq:用于相等比较,PartialEq是部分相等,Eq是完全相等。
  • PartialOrd和Ord:用于排序比较。
  • Default:用于默认值。
  • From和Into:用于类型转换。
  • TryFrom和TryInto:用于可能失败的类型转换。
  • Iterator和IntoIterator:用于迭代器。
  • Read和Write:用于IO读写。
  • Deref和DerefMut:用于解引用,智能指针常用。
  • Drop:用于析构,值被释放的时候,调用。

这些trait,在标准库里,被广泛使用,很多函数,都是接受实现了这些trait的类型。实现了这些trait的类型,就能和标准库的各种功能,无缝配合。所以,写自定义类型的时候,尽量实现这些常用的trait,能让类型更好用。

四、错误处理:优雅而强大

Rust的错误处理,和其他语言不一样,没有异常,而是用Result和Option类型,来处理可能失败的操作。这种错误处理方式,更显式,更安全,也更优雅。

1. Result和Option:显式处理错误

Rust里,可能失败的函数,返回Result<T, E>,成功的时候,返回Ok(T),失败的时候,返回Err(E)。可能为空的值,用Option<T>,有值的时候,返回Some(T),没有值的时候,返回None。

这样,错误和空值,都是显式的,编译器会强制你处理,不会像其他语言那样,异常被忽略,或者空指针,导致运行时崩溃。

处理Result和Option,最基本的方式,是用match:

match result {
    Ok(value) => println!("Success: {}", value),
    Err(error) => println!("Error: {}", error),
}

或者,用if let,只处理关心的情况:

if let Ok(value) = result {
    println!("Success: {}", value);
}

2. ?运算符:优雅地传播错误

如果一个函数里,有很多可能失败的操作,每个都用match处理,代码就会很冗长。Rust提供了?运算符,可以优雅地传播错误:

fn read_file() -> Result<String, io::Error> {
    let mut file = File::open("file.txt")?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

这里,?运算符的意思是,如果Result是Err,就直接把这个Err,作为函数的返回值,返回出去;如果是Ok,就把里面的值取出来,继续执行。这样,代码就很简洁,不需要每个地方都写match。

?运算符,也可以用在Option上,如果是None,就直接返回None。

需要注意的是,?运算符,只能用在返回Result或者Option的函数里,如果函数返回的是其他类型,就不能用。

3. 自定义错误类型

对于复杂的项目,错误类型可能有很多种,这时候,可以定义自己的错误类型,用enum来表示不同的错误:

enum MyError {
    Io(io::Error),
    Parse(ParseIntError),
    Custom(String),
}

然后,为这个错误类型,实现Display和Debug trait,还有From trait,方便从其他错误类型转换过来:

impl From<io::Error> for MyError {
    fn from(error: io::Error) -> Self {
        MyError::Io(error)
    }
}

这样,在函数里,就可以用?运算符,自动把其他错误类型,转换成自定义的错误类型,非常方便。

对于更复杂的项目,可以用thiserror和anyhow这两个库,来简化错误处理。thiserror,用来定义自定义错误类型,很方便;anyhow,用来处理应用层的错误,提供了Any类型,可以包装任何错误,还能加上下文信息。

4. 不要滥用unwrap和expect

unwrap和expect,是用来快速获取Result和Option里的值的,如果是Err或者None,就会panic。在写示例代码、原型、测试的时候,可以用,因为简单方便。但在生产代码里,不要滥用,因为一旦panic,程序就崩溃了,用户体验很差。

生产代码里,应该尽量用match、if let、?运算符,来显式处理错误,给用户友好的错误提示,而不是直接panic。只有当错误是不可能发生的,或者发生了就没法继续运行的时候,才用unwrap或者expect。

五、性能优化:零成本抽象的力量

Rust的一个核心理念,就是零成本抽象,就是说,高级的抽象,不会带来运行时的性能开销,和手写底层代码,性能一样好。但这并不意味着,写Rust代码,就不需要考虑性能了,还是有一些技巧,可以让代码性能更好。

1. 了解迭代器和闭包的零成本抽象

Rust的迭代器和闭包,是零成本抽象的典型代表。用迭代器和闭包写的代码,和手写循环,性能一样好,甚至更好,因为编译器可以做更多的优化。

比如,用迭代器的map、filter、collect等方法,写出来的代码,很简洁,很优雅,编译之后,和手写的循环,性能是一样的。因为编译器会把迭代器的调用,内联展开,优化成高效的机器码。

所以,写Rust代码的时候,不要担心用迭代器和闭包,会影响性能,放心用,它们是零成本的,而且代码更优雅,更易读。

2. 合理使用String和&str

Rust里,有两种字符串类型:String和&str。String是拥有所有权的,存在堆上,可以修改;&str是借用,是字符串的切片,没有所有权,不能修改。

很多人,写代码的时候,不管什么场景,都用String,这样,会导致很多不必要的内存分配和拷贝,影响性能。正确的做法是,函数参数,尽量用&str,而不是String,因为&str是借用,不需要所有权,也不需要拷贝,调用的时候,可以传&String,也可以传字符串字面量,很灵活。

只有当函数需要拥有字符串的所有权的时候,比如,要把字符串存到结构体里,或者要修改字符串,才用String。而且,用String的时候,可以用clone,只有在真的需要拷贝的时候,才clone,不要随便clone。

另外,拼接字符串的时候,用format!宏,或者用String的push_str方法,不要用+号,因为+号会导致不必要的拷贝。

3. 合理使用Vec和数组

Vec是动态数组,存在堆上,可以增长;数组是固定大小的,存在栈上,性能更好。如果数组的大小,在编译的时候,就知道了,而且不大,就尽量用数组,而不是Vec,因为数组在栈上,没有堆分配的开销,访问也更快。

如果必须用Vec,创建Vec的时候,可以用with_capacity,预先分配足够的容量,避免频繁的扩容和拷贝。因为Vec扩容的时候,会重新分配内存,把原来的数据,拷贝到新的内存里,开销很大。如果知道最终的大小,预先分配好,就能避免这些开销。

另外,访问Vec的时候,用迭代器,比用索引访问,更安全,也可能更快,因为迭代器不需要做边界检查。当然,如果你能保证索引不越界,用索引也可以,但要注意安全。

4. 避免不必要的clone

Rust的所有权机制,导致很多人,写代码的时候,遇到所有权问题,就clone,以为这样就能解决问题。但clone是有开销的,尤其是大的数据结构,clone的时候,要拷贝所有的数据,开销很大。

所以,写代码的时候,要尽量避免不必要的clone。遇到所有权问题的时候,先想想,能不能用借用,能不能用引用,能不能调整一下代码结构,来解决所有权问题,而不是一上来就clone。

只有当真的需要一份独立的拷贝的时候,才clone。而且,clone的时候,要清楚,clone的开销有多大,是不是在可接受的范围内。

5. 合理使用release模式和优化选项

Rust的debug模式,编译快,但运行慢,因为没有优化;release模式,编译慢,但运行快,因为有各种优化。所以,生产环境,一定要用release模式编译,性能会比debug模式,好很多,甚至好几倍。

而且,还可以在Cargo.toml里,配置优化选项,比如,opt-level设置为3,最高优化;lto设置为true,开启链接时优化;codegen-units设置为1,减少代码生成单元,让优化更充分。这些选项,能进一步提升性能,但编译时间会更长。

对于性能敏感的项目,可以开启这些优化选项,让程序跑得更快。

六、写在最后

今天,分享了一些Rust的进阶技巧,包括所有权和借用、生命周期、trait、错误处理、性能优化等方面。这些技巧,都是我在使用Rust的过程中,踩了很多坑,才总结出来的,希望能帮助大家,在Rust的进阶路上,少走弯路。

当然,Rust的进阶技巧,远不止这些,还有很多,比如,宏、并发、异步、unsafe、FFI、元编程等,每个方面,都有很多深入的内容。以后有机会,再继续分享。

Rust,是一门很优秀的编程语言,它的设计理念,它的安全性,它的性能,都让人着迷。虽然学习曲线比较陡峭,但只要坚持下来,理解了它的核心概念,掌握了它的进阶技巧,就会发现,Rust写起来,非常优雅,非常高效,非常有成就感。

最后,给大家几点学习Rust的建议:

  1. 理解核心概念:所有权、借用、生命周期,这些是Rust的核心,一定要理解透彻,不要死记硬背,要理解它们为什么这么设计,解决了什么问题。
  2. 多写多练:Rust是一门实践性很强的语言,光看书是不够的,一定要多写代码,多练习,在实践中,理解和掌握。
  3. 善用编译器:Rust的编译器,非常友好,错误提示,很详细,还会给出修改建议。写代码的时候,遇到编译错误,不要怕,仔细看错误提示,按照提示修改,慢慢地,就会掌握规律。
  4. 多看优秀的代码:Rust社区,有很多优秀的开源项目,比如,ripgrep、fd、exa、actix、tokio等,多看这些项目的代码,学习它们的设计和写法,能提升很快。
  5. 参与社区:Rust社区,非常友好,有什么问题,可以在社区里问,也可以参与开源项目,贡献代码,在参与中,学习和成长。

希望这篇文章,能对大家有所帮助。如果有什么问题或者不同的看法,欢迎在评论区交流。