Rust这几年越来越火,从系统编程到WebAssembly,从后端服务到嵌入式,到处都能看到Rust的身影。我们团队最近也用Rust做了几个项目,从最开始的磕磕绊绊到后来的渐入佳境,踩了不少坑,也积累了一些经验。
这篇文章我想总结一下在Rust 1.85版本下开发遇到的常见坑和实战经验。如果你正在学Rust或者准备在项目中用Rust,希望这些经验能帮你少走弯路。
需要说明的是,Rust更新很快,每个版本都有新特性和改进。本文基于1.85版本,一些内容在未来版本中可能会变化。
坑一:所有权和借用检查器
第一个坑,也是所有Rust新手都会遇到的坑,就是所有权和借用检查器。
Rust的所有权系统是它最核心的特性,也是它能保证内存安全的关键。但对于习惯了其他语言的开发者来说,这个系统非常反直觉。刚开始写Rust的时候,你会发现编译器总是在跟你作对,这里不能借用,那里不能移动,各种报错让你怀疑人生。
我最开始写Rust的时候,一个简单的函数都要写半天,因为总是过不了借用检查器。比如我想在一个函数里同时有一个可变引用和一个不可变引用,编译器就会报错。或者我想把一个值传给函数之后再用它,编译器也会报错,因为值已经被移动了。
解决这个问题没有捷径,就是多写多练,慢慢理解所有权的规则。有几个经验可以分享。
第一,遇到借用错误的时候,不要急着加生命周期标注。先想想是不是数据结构设计有问题。很多时候,借用错误的根源是数据结构设计得不合理,导致引用关系太复杂。重新设计数据结构,比硬加生命周期标注要好得多。
第二,多用clone。在性能不是瓶颈的地方,直接clone就好了,不要为了省一次clone而把代码搞得很复杂。Rust的clone虽然有开销,但对于大部分应用来说,这点开销可以忽略不计。先让代码能编译通过,再考虑优化。
第三,用Rc和RefCell。当你确实需要共享所有权的时候,不要硬扛,用Rc和RefCell。虽然它们有运行时开销,但能让代码简单很多。等性能不够的时候再优化也不迟。
第四,认真读编译器的错误信息。Rust的编译器错误信息非常友好,它不仅告诉你哪里错了,还会告诉你为什么错了,以及怎么改。很多时候,按照编译器的建议改就行了。
坑二:生命周期标注
第二个坑是生命周期标注。
如果说所有权是Rust的入门坎,那生命周期就是进阶坎。很多人过了所有权这一关,到了生命周期这里又卡住了。
生命周期标注看起来很复杂,各种撇号和字母,让人眼花缭乱。但其实理解了之后,生命周期标注并没有那么难。它的本质就是告诉编译器,这些引用的存活时间是多久,确保不会出现悬垂引用。
我踩过的一个坑是,在结构体里放引用。比如我想在结构体里存一个引用,就必须给结构体加生命周期标注。刚开始的时候,我总是搞不清该怎么标注,编译器报各种错。后来我发现,大部分情况下,结构体里不应该放引用,而应该直接存值。如果确实需要引用,那要仔细考虑引用的来源和生命周期。
另一个坑是,函数返回引用的时候,生命周期标注不对。比如一个函数接收两个引用,返回其中一个,这时候需要用生命周期标注告诉编译器返回的是哪一个。刚开始我总是标注错,后来记住了一个规则:返回的引用的生命周期,一定和某个输入参数的生命周期相同。如果不是,那可能就是有问题的。
还有一个经验是,尽量让编译器帮你推导生命周期。Rust的编译器有生命周期省略规则,很多情况下不需要手动标注,编译器能自动推导。只有当编译器推导不出来的时候,才需要手动标注。不要一上来就写一堆生命周期标注,先试试能不能省略。
坑三:错误处理
第三个坑是错误处理。
Rust的错误处理和其他语言很不一样。它没有异常,而是用Result类型来处理错误。函数返回Result,调用者需要处理Ok和Err两种情况。
刚开始写Rust的时候,我很不适应这种错误处理方式。每个调用都要match一下,代码变得很冗长。后来发现了问号操作符,用?来传播错误,代码简洁了很多。
但问号操作符也有坑。比如在main函数里用?,需要把main函数的返回类型改成Result。还有在闭包里用?,需要注意闭包的返回类型。这些细节不注意的话,编译器会报错。
另一个坑是错误类型的定义。Rust中自定义错误类型有几种方式,比如用枚举、用结构体、用anyhow库。刚开始的时候,我每个模块都定义自己的错误类型,结果错误类型之间的转换很麻烦。后来我发现,对于应用层的代码,用anyhow库就够了,它能把各种错误统一起来,处理起来很方便。只有在库的公共API中,才需要仔细定义错误类型。
还有一个经验是,不要用unwrap和expect。这两个方法会在出错的时候panic,对于原型开发可以用,但在生产代码中尽量不要用。因为一旦出错,程序就直接崩溃了,没有任何恢复的机会。应该用?或者match来妥善处理错误。
坑四:trait和泛型
第四个坑是trait和泛型。
Rust的trait类似于其他语言的接口,但更灵活。泛型加上trait约束,可以写出非常通用的代码。但用不好的话,也会遇到很多坑。
我踩过的一个坑是,trait对象的动态分发。当你用dyn Trait的时候,是动态分发,有运行时开销。而且trait对象有很多限制,比如trait必须是对象安全的,不能返回Self类型,不能用泛型方法。刚开始的时候,我总是遇到trait不是对象安全的错误,后来才慢慢理解了对象安全的规则。
另一个坑是,泛型代码的编译时间。Rust的泛型是单态化的,每个具体类型都会生成一份代码。如果泛型用得太多,编译时间会变得很长。我们有一个项目,因为泛型用得太泛滥,编译一次要十几分钟,非常影响开发效率。
经验是,泛型不要滥用。对于确实需要通用的地方,用泛型没问题。但如果只是为了"可能以后会用到"而加泛型,那就没必要了。先写具体的类型,等真的需要通用的时候再重构。Rust的重构很安全,不用担心。
还有一个经验是,用impl Trait简化代码。在函数返回值中用impl Trait,可以避免写复杂的类型,也能减少泛型参数。但要注意,impl Trait返回的是具体类型,只是不写出来而已,不是动态分发。
坑五:异步编程
第五个坑是异步编程。
Rust的异步编程这几年发展很快,1.85版本已经比较成熟了。但异步编程本身就比较复杂,再加上Rust的所有权系统,异步Rust的学习曲线很陡。
我踩过的第一个坑是,async函数中的借用问题。在async函数中,如果你持有一个引用,然后await一个future,编译器可能会报错,因为引用在await期间不能被安全地持有。这是因为future可能会被移动到另一个线程,引用的生命周期不好保证。
解决方法是,尽量减少在async函数中持有的引用,或者把引用改成持有数据本身。还有就是用tokio::spawn的时候,要注意future的'static约束,它不能引用局部变量。
第二个坑是,异步运行时的选择。Rust有多个异步运行时,最流行的是tokio,还有async-std、smol等。不同的运行时之间不兼容,用tokio的库不能在async-std中运行。所以在项目开始的时候就要选好运行时,不要混用。
第三个坑是,阻塞操作。在异步代码中,不能做阻塞操作,比如阻塞的文件IO、阻塞的网络请求、sleep等。这些操作会阻塞整个线程,影响其他任务的执行。要用异步版本的API,或者用spawn_blocking把阻塞操作放到专门的线程池中。
第四个坑是,调试困难。异步代码的调用栈很复杂,出了问题很难调试。因为异步代码会被编译器改写成状态机,原来的调用关系被打乱了。我的经验是,多打日志,用tracing库来记录异步代码的执行流程,比用调试器更有效。
坑六:编译时间
第六个坑是编译时间。
Rust的编译速度一直是被吐槽的点。虽然每个版本都在优化,但对于大型项目来说,编译时间还是很长。我们的项目,全量编译要十几分钟,增量编译也要一两分钟,开发体验不太好。
我总结了几个加快编译的方法。
第一,用cargo check代替cargo build。cargo check只做类型检查,不生成代码,速度快很多。开发的时候,用cargo check来验证代码是否正确,只有在需要运行的时候才cargo build。
第二,开启增量编译。Rust默认开启增量编译,但可以通过配置进一步优化。比如把target目录放到内存盘上,能加快文件读写速度。
第三,减少依赖。每个依赖都会增加编译时间。定期检查依赖,把不需要的依赖删掉。对于只在开发时用的依赖,放到dev-dependencies中。
第四,用sccache做编译缓存。sccache能缓存编译结果,在不同的编译之间共享,能大幅加快重复编译的速度。特别是在CI环境中,效果很明显。
第五,拆分crate。把一个大crate拆成多个小crate,这样修改一个crate只需要重新编译那个crate和依赖它的crate,不用全部重新编译。
第六,用nightly版本的一些实验性优化。nightly版本有一些还在实验中的编译优化选项,比如 Cranelift 后端,编译速度更快,虽然生成的代码性能差一些,但开发阶段用足够了。
坑七:生态和库的选择
第七个坑是生态和库的选择。
Rust的生态这几年发展很快,但和Python、JavaScript这些成熟生态比,还是有差距。特别是一些特定领域的库,可能不够成熟,或者维护得不好。
我踩过的坑是,选了一个看起来不错的库,用了一段时间之后发现维护不活跃,issue没人回,PR没人merge,最后不得不自己fork一份来维护。
经验是,选库的时候要仔细考察。第一,看下载量和star数,下载量大的库通常更可靠。第二,看最近的提交时间,如果半年以上没更新了,要谨慎。第三,看issue的数量和响应速度,issue多且没人处理的库要小心。第四,看文档质量,文档好的库用起来更省心。第五,看是否有替代方案,如果有多个选择,选更成熟的那个。
还有一个经验是,对于核心功能,尽量自己实现或者选最成熟的库。对于非核心功能,可以用一些新的库,即使出问题影响也不大。不要在核心功能上冒险用不成熟的库。
另外,Rust的库很多都是0.x版本,意味着API还不稳定,升级的时候可能有breaking change。在Cargo.toml中指定版本的时候要注意,不要用太宽松的版本约束,避免意外升级导致编译失败。
实战经验
说了这么多坑,再分享一些实战经验。
第一个经验是,先写能跑的代码,再优化。Rust的编译器很严格,很容易让人陷入"一定要写最优雅的代码"的陷阱。但实际上,先让代码能编译通过、能跑起来,比什么都重要。等功能完成了,再回过头来重构和优化。Rust的编译器能保证重构的安全性,不用担心改坏。
第二个经验是,善用cargo的工具。cargo有很多有用的子命令,比如cargo fmt格式化代码,cargo clippy做代码检查,cargo tree查看依赖树,cargo audit检查安全漏洞。这些工具能帮你写出更高质量的代码。
第三个经验是,写测试。Rust的测试框架很方便,单元测试、集成测试、文档测试都支持。而且Rust的类型系统已经帮你保证了很多正确性,测试可以更专注于业务逻辑。写测试不仅能保证代码质量,还能让你在重构的时候更有信心。
第四个经验是,参与社区。Rust的社区很活跃,遇到问题可以在论坛、Discord、GitHub上提问。很多核心开发者都会在社区里回答问题。而且,参与开源项目,贡献代码,是提升Rust水平的好方法。
第五个经验是,不要强求一次写对。Rust的学习曲线确实比较陡,刚开始写得慢、报错多是正常的。不要灰心,多写多练,慢慢就会越来越顺手。等你过了那个坎,就会发现Rust写起来其实很舒服,编译器就像一个严格的老师,帮你写出更健壮的代码。
写在最后
Rust是一门优秀的语言,它的内存安全、高性能、强大的类型系统,让它在很多场景下都有优势。但它也不是银弹,学习曲线陡、编译慢、生态还在发展中,这些都是需要面对的问题。
如果你正在考虑用Rust,我的建议是:先从小项目开始,不要一上来就用Rust重写整个系统。选一个合适的场景,比如一个CLI工具、一个WebAssembly模块、一个性能敏感的库,用Rust来做。积累了经验之后,再逐步扩大使用范围。
踩坑是学习的一部分,每个Rust开发者都是从各种报错中走过来的。不要怕报错,编译器的每一个错误,都是在教你Rust的规则。等你把这些规则内化了,写Rust就会越来越顺。
最后用一句话来结束这篇文章:"Rust的难,是为了让你写出更安全、更健壮的代码。这份难,是值得的。"
愿每一个Rust开发者,都能在踩坑中成长,写出高质量的Rust代码。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录