用Rust写项目半年了,踩了无数的坑。有些坑浅尝辄止,有些坑让我熬了好几个通宵。

这篇文章记录一下那些让我印象深刻的坑,以及最终是怎么解决的。如果你也在学Rust,希望能帮你少走点弯路。

所有权的坑

第一个大坑当然是所有权。

刚学Rust的时候,所有权的概念看了好几遍才懂。但真到写代码的时候,还是会被编译器虐得怀疑人生。

最经典的错误是移动后使用。比如你把一个String传给了函数,然后还想继续用它,编译器就会报错:value borrowed here after move。刚开始的时候特别不理解,为什么传个值就不能用了?后来才明白,Rust的所有权机制就是这样,一个值只能有一个所有者,传进去就是把所有权交出去了。

解决方案是引用或者克隆。如果不需要修改就用不可变引用,需要修改就用可变引用,或者直接clone一份。但clone有性能开销,要权衡使用。

还有一个坑是Copy和Clone的区别。基本类型默认实现了Copy,传值的时候会自动复制,不会移动所有权。但自定义类型默认不实现Copy,需要手动derive。我之前写了个结构体,传值的时候一直报错,后来才发现忘了加#[derive(Copy, Clone)]。

生命周期的坑

生命周期是Rust里最让人头疼的概念,没有之一。

最开始写代码的时候,遇到需要标注生命周期的地方就瞎写,能编译过就行。后来项目大了,生命周期的问题越来越多,才不得不认真学。

最常见的错误是返回引用的时候没有标注生命周期。比如写一个函数返回两个字符串中较长的那个,编译器会让你标注生命周期,因为它不知道返回的引用和哪个参数有关联。

正确的写法是fn longest<'a>(x: &'a str, y: &'a str) -> &'a str。这个'a表示返回的引用和两个参数的生命周期一样长,只要有一个参数失效了,返回值就不能用了。

还有一个坑是生命周期省略规则。Rust编译器在某些情况下会自动推断生命周期,不需要手动标注。但省略规则有三条,不是所有情况都能省略。我之前有个函数怎么都编译不过,后来发现是省略规则不适用,必须手动标注。

生命周期的坑还有很多,比如结构体里的引用字段需要标注生命周期,trait对象的生命周期,异步函数的生命周期等等。这些都需要在实践中慢慢理解。

借用检查的坑

借用检查器是Rust的核心特性,也是最容易让人抓狂的地方。

最经典的错误是同时存在可变引用和不可变引用。比如你先创建了一个不可变引用,然后又创建了一个可变引用,编译器就会报错。因为不可变引用要求数据不能被修改,而可变引用可以修改数据,两者同时存在就会有数据竞争的风险。

这个规则在简单场景下很好理解,但在复杂场景下就很容易踩坑。比如你调用了一个方法返回了引用,然后又调用另一个方法需要可变引用,编译器就会报错。这时候你需要仔细分析引用的作用域,看看能不能提前结束不可变引用。

还有一个坑是借用的作用域。Rust的引用作用域是从创建到最后一次使用,而不是到代码块结束。这意味着如果你在代码块前面创建了引用,后面不用了,它的作用域就结束了,后面可以再创建可变引用。但如果你在后面又用了一次,作用域就会延长。

NLL(非词法生命周期)引入之后,借用检查智能了很多,但还是有一些情况会报错。这时候需要重构代码,或者用大括号把引用的作用域限制住。

异步的坑

Rust的异步生态这几年发展很快,但坑也不少。

第一个坑是async函数的返回类型。async fn返回的是impl Future,不是直接返回值。如果你在async函数里调用另一个async函数,必须加.await,不然返回的是Future而不是值。我刚开始写异步代码的时候经常忘加.await,编译器报的错又看不懂,折腾了好久。

第二个坑是运行时的选择。Rust的异步需要运行时,最常用的是tokio。但tokio有不同的特性组合,full、rt-multi-thread、macros等等,选错了就编译不过。我之前用了#[tokio::main]但没开macros特性,编译报错,查了半天才发现是特性没开。

第三个坑是阻塞操作。在异步代码里不能做阻塞操作,比如std::thread::sleep,会阻塞整个线程。要用tokio::time::sleep代替。还有文件IO、网络IO,都要用异步版本的,不能用标准库的阻塞版本。

第四个坑是Send和Sync。异步任务需要跨线程发送,所以里面的变量都必须是Send的。如果用了不是Send的类型,比如Rc、RefCell,编译就会报错。这时候需要换成Arc、Mutex,或者用tokio的spawn_local在单线程上运行。

trait对象的坑

trait对象是Rust里实现动态分发的方式,但用起来也有不少坑。

第一个坑是对象安全。不是所有trait都能做成trait对象,必须满足对象安全的条件。比如trait方法不能有泛型参数,不能返回Self类型,不能有静态方法等等。我之前写了个trait,里面有个泛型方法,想做成trait对象,编译报错,后来才知道泛型方法不满足对象安全。

第二个坑是dyn关键字。Rust 2018 edition之后,trait对象必须用dyn关键字,比如dyn Trait。虽然编译器会提示,但写惯了老版本的人容易忘。

第三个坑是trait对象的性能。动态分发有虚函数调用的开销,比静态分发慢。如果对性能要求高,尽量用泛型加静态分发,只有在需要运行时多态的时候才用trait对象。

第四个坑是trait对象和泛型的区别。泛型是编译期单态化,零成本抽象;trait对象是运行期动态分发,有开销。两者适用场景不同,不要混用。我之前有个地方本来该用泛型,结果用了trait对象,性能差了不少,后来改过来了。

宏的坑

Rust的宏很强大,但也很容易踩坑。

声明宏macro_rules!看起来简单,但写起来很容易出错。最常见的错误是匹配模式写错,或者重复运算符用错。比如$(...)和$(...),的区别,前者是零次或多次不需要分隔符,后者是用逗号分隔。我之前把逗号漏了,宏展开后语法错误,查了半天。

过程宏更复杂,需要单独的crate,还要用syn和quote库。写过程宏的时候最头疼的是调试,因为宏展开后的代码看不到,只能靠编译器报错来推断。后来发现了cargo expand工具,可以展开宏看生成的代码,调试效率高了很多。

还有一个坑是宏的卫生性。Rust的宏是卫生的,宏里面定义的变量不会和外面的冲突。但有时候你就是想让宏引用外面的变量,这时候需要用$crate或者明确的路径。我之前写宏的时候想引用当前crate的函数,直接写函数名不行,必须加$crate::前缀。

其他小坑

除了上面这些大坑,还有一些小坑也让人头疼。

比如字符串处理。Rust的字符串有String和&str两种,转换来转换去很麻烦。还有字符串的索引,Rust不允许直接用s[0]取字符,因为UTF-8编码下一个字符可能占多个字节。要用s.chars().nth(0),或者用字符迭代器。

比如错误处理。Rust的Result和?运算符很优雅,但有时候错误类型不一样,需要用map_err或者Box<dyn Error>。我之前写函数的时候返回不同的错误类型,编译报错,后来统一用anyhow库的Any类型才解决。

比如模块系统。Rust的mod和use有时候让人困惑,特别是文件和模块的对应关系。还有pub(crate)、pub(super)这些可见性修饰符,刚开始的时候经常搞混。

比如整数溢出。Rust在debug模式下会检查整数溢出,release模式下不会。我之前有个算法在debug下正常,release下结果不对,后来发现是整数溢出了,release模式下静默回绕了。

写在最后

Rust是一门很难学的语言,所有权、生命周期、借用检查这些概念都需要时间来理解。但一旦入门了,你会发现这些设计都是有道理的,它们能帮你写出更安全、更高效的代码。

踩坑不可怕,可怕的是踩了坑不总结。每解决一个问题,你对Rust的理解就深了一层。半年下来,我从被编译器虐到能和编译器友好对话,进步还是很大的。

如果你也在学Rust,不要被编译器的报错吓到。仔细读报错信息,Rust的编译器提示是所有语言里最友好的,它不仅告诉你哪里错了,还会告诉你怎么改。

希望这篇文章能帮你少踩点坑。Rust的路很长,我们一起加油。