我用Rust写代码已经三年了,从最初的2018 edition到现在的2021 edition,踩了无数的坑,也收获了很多。本文是我三年Rust使用经验的总结,包括对所有权和借用的理解、生命周期的心得、错误处理的最佳实践、异步编程的体会、性能优化的经验,以及Rust对我编程思维的改变。如果你正在学习Rust或者准备深入使用,希望这些经验能帮到你。

一、我是怎么开始用Rust的

先说说我是怎么开始用Rust的。

三年前,我主要用C++写后端服务。C++虽然性能好,但是内存安全问题一直让人头疼,空指针、悬垂指针、内存泄漏,这些问题时不时就会冒出来。而且C++的编译速度慢,依赖管理混乱,开发体验不是很好。

后来听说了Rust,说是一门兼顾性能和安全的系统编程语言,没有垃圾回收但是能保证内存安全。我觉得很有意思,就找了本《Rust程序设计语言》开始学。

刚开始学的时候,真的很痛苦。所有权、借用、生命周期,这些概念以前从来没接触过,编译器还特别严格,动不动就报错。写一个简单的链表都要折腾好几天,因为借用检查器总是不通过。

那时候我经常怀疑自己,是不是太笨了,学不会这门语言。好几次都想放弃,但是又不甘心,觉得别人能学会我也能学会。

就这样磕磕绊绊学了大概一个月,突然有一天,我感觉自己开窍了。所有权和借用的概念突然就清晰了,编译器的报错也能看懂了,写代码的时候开始能预判哪些地方会有问题。

从那以后,写Rust就越来越顺了。三年下来,我用Rust写了几个项目,包括一个高性能的网络代理、一个命令行工具、还有一个Web服务。对Rust的理解也越来越深入。

2021 edition发布之后,我们项目也升级了。新的edition带来了一些改进,比如更智能的闭包捕获、更简洁的语法,整体体验更好了。

二、所有权和借用:Rust的核心

所有权和借用是Rust的核心,也是最难理解的部分。用了三年之后,我有一些心得。

所有权不是束缚,而是保障

刚开始学Rust的时候,觉得所有权系统是一种束缚。为什么一个值只能有一个所有者?为什么不能随便复制?为什么移动之后就不能用了?这些规则让写代码变得很麻烦。

但是用久了之后才明白,所有权不是束缚,而是保障。它从根本上杜绝了很多内存安全问题。你不需要担心一个值被意外修改,不需要担心悬垂指针,不需要担心双重释放。因为编译器在编译期就帮你检查了。

我现在写其他语言的时候,反而会不习惯。比如写JavaScript的时候,一个对象传来传去,你不知道谁会修改它,心里总是不踏实。但是在Rust里,所有权是清晰的,谁拥有这个值,谁能修改它,都是明确的。

借用检查器是你的朋友

借用检查器可能是Rust初学者最讨厌的东西了。它总是报错,而且错误信息有时候很难懂。很多人就是因为过不了借用检查器这一关,放弃了Rust。

但是我想说,借用检查器是你的朋友。它报错不是为了刁难你,而是在帮你发现潜在的bug。很多在其他语言中需要运行时才能发现的问题,Rust在编译期就帮你找出来了。

我的经验是,不要和借用检查器对抗,要学会理解它的意图。它报错的时候,仔细看错误信息,想一想为什么不允许这样做。大多数时候,是你的代码设计有问题,而不是编译器太严格。

比如,如果你发现自己经常需要同时持有多个可变引用,那可能是你的数据结构设计有问题,应该考虑重新组织代码。

学会用Copy和Clone

对于简单的类型,实现Copy trait可以让你不用操心所有权的问题,赋值的时候自动复制。比如整数、浮点数、布尔值这些基本类型都是Copy的。

对于复杂一点的类型,如果确实需要多处拥有,可以实现Clone trait,需要的时候显式调用clone()。虽然clone有性能开销,但是在很多场景下,这点开销是可以接受的,换来的是代码的简洁和可读性。

不要一上来就追求零成本抽象,先让代码跑起来,再根据性能瓶颈优化。

三、生命周期:从恐惧到驾驭

生命周期是Rust中另一个让人头疼的概念。

生命周期不是让你标注的,而是让你理解的

刚开始学生命周期的时候,觉得它就是一个语法怪物。为什么要写'a、'b这些东西?为什么编译器不能自动推断?

后来才明白,生命周期标注不是给编译器看的,而是给人看的。它告诉读者,这些引用之间是什么关系,哪个引用的生命周期和哪个相关。编译器其实大部分时候能推断,但是当关系复杂的时候,需要你明确标注出来。

我的经验是,先不写生命周期标注,让编译器告诉你需要什么。编译器会提示你缺少哪些标注,按照提示加上就行。写多了之后,你就会慢慢理解生命周期的规律,能预判哪些地方需要标注。

大部分时候不需要生命周期

其实在日常开发中,大部分函数都不需要显式标注生命周期。Rust的生命周期省略规则已经能处理大部分情况。只有当函数有多个引用参数,或者返回引用的时候,才可能需要标注。

如果你发现自己写了很复杂的生命周期标注,那可能是代码设计有问题。考虑一下是不是应该用所有权转移,或者用智能指针(比如Rc、Arc),而不是到处用引用。

生命周期和数据结构

在数据结构中使用生命周期是最容易出问题的。比如在结构体中存放引用,需要给结构体加生命周期参数,这会让整个代码变得复杂。

我的建议是,尽量不要在结构体中存放引用。如果确实需要,考虑用Rc或者Arc来共享所有权,或者用索引(比如usize)来代替引用。这样代码会简单很多。

四、错误处理:Rust的优雅之处

Rust的错误处理是我最喜欢的特性之一。

Result和Option比异常好

Rust用Result和Option来处理错误和空值,而不是用异常和null。刚开始可能觉得麻烦,每个返回Result的函数都要处理错误。但是用久了之后,你会发现这种方式更安全、更清晰。

因为错误是类型的一部分,你不能忽略它。调用一个可能失败的函数,编译器会提醒你处理错误。你不会像在其他语言中那样,忘记处理异常,导致程序在运行时崩溃。

而且,Result和Option是普通的值,可以像其他值一样传递、组合、变换。配合?运算符和各种组合子(map、andthen、unwrapor等),可以写出非常优雅的错误处理代码。

?运算符是神器

?运算符是Rust错误处理的神器。它可以在错误的时候提前返回,把错误传播给调用者。有了?运算符,错误处理代码变得非常简洁。

比如,在其他语言中你可能需要写很多try-catch,或者检查返回值。但是在Rust中,你只需要在可能失败的调用后面加一个?,错误就自动传播了。

不过要注意,?运算符只能用在返回Result或者Option的函数中。如果你的函数返回(),就不能直接用?,需要用match或者if let来处理。

自定义错误类型

对于复杂的项目,建议定义自己的错误类型。可以用thiserror库来定义错误枚举,非常方便。自定义错误类型可以包含更多的上下文信息,也方便上层统一处理。

不要到处用Box<dyn Error>,虽然方便,但是会丢失类型信息,也不利于错误的匹配和处理。

五、异步编程:又爱又恨

Rust的异步编程是我又爱又恨的部分。

async/await让异步变简单了

Rust 1.39引入了async/await语法,让异步编程变得简单了很多。你可以像写同步代码一样写异步代码,不需要到处写回调函数。

用了async/await之后,写高并发的网络服务变得很容易。你可以用tokio或者async-std运行时,轻松写出高性能的异步程序。

但是异步的坑也很多

Rust的异步编程有很多坑。比如Send和'static约束,比如生命周期和异步的冲突,比如运行时的选择和配置。刚开始写异步代码的时候,编译器的报错会让你怀疑人生。

我的经验是,先理解Future的概念,理解async函数返回的是一个状态机,而不是直接执行。然后多写多练,踩的坑多了,自然就理解了。

另外,异步代码中的借用问题比同步代码更复杂。因为Future可能被移动到不同的线程执行,引用的生命周期更难保证。如果遇到复杂的借用问题,可以考虑把数据包在Arc里,用clone来共享。

选择合适的运行时

Rust的异步运行时主要有tokio和async-std。tokio功能更丰富,生态更完善,性能也更好。async-std更简洁,API更接近标准库。

我个人推荐tokio,因为它的生态最完善,大部分异步库都支持tokio。而且tokio的多线程运行时性能很好,适合高并发场景。

六、性能优化:Rust的优势

Rust的性能是它的一大优势,但是要写出高性能的Rust代码,也需要一些经验。

不要过早优化

这是老生常谈了,但是在Rust中尤其重要。Rust默认的性能已经很好了,大部分时候不需要特别优化。先写出正确、清晰的代码,然后用profiler找到瓶颈,再针对性优化。

我见过很多人刚开始写Rust就到处用unsafe,到处避免clone,结果代码变得很复杂,还不一定更快。

clone不一定慢

很多Rust新手会觉得clone很慢,到处想办法避免clone。但是实际上,大部分clone的开销很小,尤其是对于小的数据结构。为了避免clone而把代码搞得很复杂,得不偿失。

只有当clone成为性能瓶颈的时候,才需要考虑优化。比如大的字符串、大的数组,这时候可以考虑用引用或者共享所有权(Rc/Arc)。

合理使用智能指针

Rust提供了多种智能指针,各有用途:

  • Box:堆分配,用于递归类型或者trait对象
  • Rc:单线程引用计数,用于共享只读数据
  • Arc:多线程引用计数,用于跨线程共享只读数据
  • RefCell:单线程内部可变性,配合Rc使用
  • Mutex/RwLock:多线程内部可变性,配合Arc使用

合理选择智能指针,可以在保证安全的前提下,获得好的性能。

unsafe要用在刀刃上

unsafe可以绕过Rust的安全检查,获得更高的性能。但是unsafe也很危险,容易出bug。我的建议是,尽量不用unsafe,如果必须用,要把unsafe封装在小的、经过严格测试的函数中,对外提供安全的API。

而且,用unsafe之前一定要确认,安全的Rust确实无法达到需要的性能。很多时候,你以为需要unsafe,其实换一种写法就能达到同样的性能。

七、Rust对我编程思维的改变

用了三年Rust,最大的收获不是学会了一门语言,而是编程思维的改变。

更注重数据的所有权和生命周期

写Rust之后,我在写其他语言的时候,也会不自觉地思考数据的所有权和生命周期。这个值是谁创建的?谁拥有它?什么时候释放?这些思考让我写出的代码更清晰,bug也更少。

更注重错误处理

Rust的错误处理让我养成了一个习惯:每个可能失败的地方都要考虑错误怎么办。不会像以前那样,假设一切都会成功,然后出了问题再debug。

更注重API设计

Rust的类型系统很强大,也鼓励你设计好的API。用类型来表达约束,用所有权来表达关系,让错误的用法在编译期就被发现。这种思维方式让我在设计API的时候更加深思熟虑。

更注重性能和资源

Rust没有垃圾回收,你需要自己管理内存。这让我对资源的使用更加敏感,会考虑内存分配、拷贝、释放这些细节。这种意识让我写出的代码更高效,即使是在其他语言中。

八、给Rust初学者的建议

如果你正在学习Rust,这里有一些建议。

1. 不要放弃

Rust的学习曲线确实比较陡,前几个月会很痛苦。但是只要坚持过去,就会豁然开朗。不要因为编译器报错多就放弃,那些报错都是在帮你学习。

2. 多写多练

看书和文档是学不会Rust的,一定要多写代码。从小项目开始,比如写一个命令行工具、一个简单的Web服务。在实践中理解所有权和借用。

3. 善用编译器的错误信息

Rust的编译器错误信息是所有语言中最好的之一。它不仅告诉你哪里错了,还会告诉你为什么错了,以及怎么修改。仔细阅读错误信息,按照建议去改,大部分问题都能解决。

4. 不要追求零成本抽象

刚开始写Rust的时候,不要追求零成本抽象,不要到处避免clone和分配。先让代码跑起来,正确比性能重要。等熟悉了之后,再慢慢优化。

5. 利用好生态

Rust的生态已经很丰富了,常用的库都有。不要重复造轮子,善用crates.io上的库。比如serde用于序列化,tokio用于异步,clap用于命令行参数,thiserror用于错误处理。

6. 加入社区

Rust的社区很友好,有问题可以去官方论坛、Reddit的r/rust、或者各种聊天群提问。很多大佬都很乐意帮助新手。

九、写在最后

用了三年Rust,从最初的痛苦到现在的喜爱,这门语言给了我很多。它不仅让我写出了更安全、更高效的代码,也改变了我的编程思维。

Rust不是一门完美的语言,它有陡峭的学习曲线,有复杂的语法,有编译慢的问题。但是它的优点——内存安全、高性能、强大的类型系统、优秀的工具链——让这些缺点都是值得的。

2021 edition带来了一些改进,让Rust变得更好用了。我相信Rust的未来会越来越好,会有越来越多的人使用它。

如果你还在犹豫要不要学Rust,我的建议是:学。它可能不会成为你最常用的语言,但是它会改变你对编程的理解,让你成为一个更好的程序员。

最后用一句话结束本文:"Rust is not a language to learn, it's a language to grow with." 愿每一个学习Rust的人,都能在这门语言中找到成长的快乐。