用Rust开发已经有一段时间了,从最初的版本到现在的1.5+,踩了不少坑。Rust是一门很优秀的语言,性能接近C/C++,同时又有内存安全和线程安全的保证,但它的学习曲线确实比较陡峭,尤其是所有权、借用、生命周期这些概念,很容易让人困惑。今天分享一下我在Rust实战中踩过的那些坑,以及对应的解决方案,希望能帮助大家少走弯路。
先说说我和Rust的故事。我是从Rust 1.0的时候开始关注这门语言的,那时候它刚发布稳定版,社区还比较小,文档和库也不够完善。但我被它的设计理念吸引了:零成本抽象、内存安全、线程安全、没有垃圾回收、性能接近C/C++。这些特性,对于做系统编程和高性能开发的人来说,太有吸引力了。
于是,我开始学习Rust,从最基础的语法开始,到所有权、借用、生命周期,再到trait、泛型、宏、异步编程,一步一步地学。学的过程中,踩了很多坑,尤其是所有权和借用,经常被编译器报错,搞得很崩溃。但慢慢地,我理解了这些概念,也越来越体会到Rust的好处。
现在,我已经用Rust做了几个项目,包括一个命令行工具、一个网络服务、一个数据库驱动,还有一些小工具。在这些项目中,我又踩了很多坑,也积累了不少经验。今天,就把这些坑和经验分享出来,希望能帮助正在学习Rust或者准备用Rust做项目的朋友,少走弯路。
一、所有权和借用:最容易踩的坑
所有权(Ownership)和借用(Borrowing),是Rust最核心的概念,也是最容易踩坑的地方。很多人学Rust,就是被这两个概念劝退的。我自己也在这上面踩了很多坑。
坑1:移动语义,值被移动之后不能再使用
Rust中,大部分类型都是移动语义,也就是说,当你把一个值赋给另一个变量,或者作为参数传给函数的时候,这个值的所有权就被移动了,原来的变量就不能再使用了。
比如:
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1的所有权移动给了s2
println!("{}", s1); // 报错!s1已经不能使用了
}这个报错,是很多初学者遇到的第一个坑。在C++、Java、Python等语言中,这样写是没问题的,但在Rust中,因为所有权的移动,s1已经无效了。
解决方案:
- 如果需要复制一份,可以使用
clone()方法:let s2 = s1.clone(); - 如果只是想读取值,可以使用引用(借用):
let s2 = &s1; - 对于实现了
Copytrait的类型(比如整数、浮点数、布尔值等),不会移动,而是自动复制,所以可以正常使用。
我自己踩这个坑,是在写一个函数的时候,把一个String作为参数传进去,然后在函数外面又想用这个String,结果报错了。后来才明白,传进去的时候,所有权已经移动了,外面的变量已经无效了。解决方案是,传引用进去,或者在函数里面返回这个String,把所有权还回来。
坑2:可变引用和不可变引用不能同时存在
Rust的借用规则:在同一个作用域中,要么只能有一个可变引用,要么只能有多个不可变引用,不能同时有可变引用和不可变引用。
比如:
fn main() {
let mut s = String::from("hello");
let r1 = &s; // 不可变引用
let r2 = &s; // 不可变引用,没问题,可以有多个不可变引用
let r3 = &mut s; // 报错!已经有不可变引用了,不能再有可变引用
println!("{} {} {}", r1, r2, r3);
}这个规则,是为了保证内存安全,避免数据竞争。但在实际开发中,很容易违反这个规则,尤其是在比较复杂的代码中。
我自己踩这个坑,是在写一个数据结构的时候,先获取了一个不可变引用,用来读取数据,然后又想获取一个可变引用,用来修改数据,结果报错了。
解决方案:
- 尽量缩小引用的作用域,让不可变引用在使用完之后就结束,然后再创建可变引用。在Rust 2018及以后的版本中,引用的作用域是"非词法作用域"(NLL,Non-Lexical Lifetimes),也就是说,引用在最后一次使用之后就结束了,不需要等到作用域结束。所以,只要不可变引用在可变引用创建之前就不再使用了,就不会报错。
- 如果确实需要同时读写,可以使用内部可变性(Interior Mutability),比如
RefCell、Cell等。这些类型,可以在只有不可变引用的情况下,修改内部的数据,但需要在运行时检查借用规则,有一定的运行时开销。
比如:
use std::cell::RefCell;
fn main() {
let s = RefCell::new(String::from("hello"));
let r1 = s.borrow(); // 不可变借用
let r2 = s.borrow(); // 不可变借用,没问题
// 这时候如果调用s.borrow_mut(),会在运行时panic,因为已经有不可变借用了
// 所以要等r1和r2都释放了,再调用borrow_mut()
println!("{} {}", r1, r2);
// r1和r2在这里之后就不再使用了,作用域结束
let mut r3 = s.borrow_mut(); // 现在可以可变借用了
r3.push_str(" world");
println!("{}", r3);
}坑3:悬垂引用(Dangling Reference)
悬垂引用,就是引用了一个已经被释放的值。在C/C++中,悬垂引用是很常见的bug,但在Rust中,编译器会检查,不允许悬垂引用。
比如:
fn dangle() -> &String {
let s = String::from("hello");
&s // 报错!s在函数结束时就被释放了,返回&s会造成悬垂引用
}
fn main() {
let reference_to_nothing = dangle();
}这个坑,我在写函数返回引用的时候踩过。一开始想返回一个引用,避免拷贝,但后来发现,引用的值在函数结束时就被释放了,不能返回引用。
解决方案:
- 直接返回值的所有权,而不是引用:
fn dangle() -> String { let s = String::from("hello"); s } - 如果引用的值的生命周期比函数长,可以返回引用,但需要用生命周期标注,告诉编译器这个引用的有效期。
- 使用智能指针,比如
Rc、Arc,让值的所有权被多个地方共享,不会被提前释放。
坑4:生命周期标注,看起来很复杂
生命周期(Lifetime),是Rust中另一个让人头疼的概念。很多人看到'a、'b这些标注,就觉得很复杂,很困惑。
其实,生命周期标注,只是告诉编译器,引用的有效期是多久,确保引用不会悬垂。大部分情况下,编译器可以自动推断生命周期,不需要手动标注。但在一些复杂的情况下,比如函数返回引用、结构体中有引用等,需要手动标注生命周期。
我自己踩生命周期的坑,是在写一个结构体的时候,里面有一个引用字段,结果不知道怎么标注生命周期,报错了。后来才明白,需要给结构体和引用字段都加上生命周期标注。
比如:
struct ImportantExcerpt<'a> {
part: &'a str, // part是一个引用,生命周期是'a
}
impl<'a> ImportantExcerpt<'a> {
fn level(&self) -> i32 {
3
}
fn announce_and_return_part(&self, announcement: &str) -> &str {
println!("Attention please: {}", announcement);
self.part
}
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().expect("Could not find a '.'");
let i = ImportantExcerpt {
part: first_sentence,
};
}生命周期标注的规则,其实不难:
- 函数的每个引用参数,都有自己的生命周期。
- 如果只有一个输入生命周期,那么它会被赋给所有输出生命周期。
- 如果有多个输入生命周期,但其中一个是
&self或&mut self,那么self的生命周期会被赋给所有输出生命周期。 - 否则,就需要手动标注生命周期。
大部分情况下,编译器会自动推断,不需要手动标注。只有在编译器推断不出来的时候,才需要手动标注。所以,不用太害怕生命周期,遇到报错的时候,按照编译器的提示来标注就可以了。
二、错误处理:Result和Option的正确姿势
Rust的错误处理,和其他语言很不一样,它没有异常,而是用Result和Option来表示可能出错或者可能为空的值。这种方式,虽然更安全,但也需要一些时间来适应。
坑5:用unwrap()和expect()处理错误,导致panic
Result和Option都有unwrap()和expect()方法,可以直接取出里面的值,但如果值是Err或者None,就会panic,导致程序崩溃。
很多初学者,为了方便,到处用unwrap(),结果程序一遇到错误就崩溃,很不稳定。我自己一开始也这样,写代码的时候,图方便,到处unwrap(),结果测试的时候,经常panic,排查起来很麻烦。
比如:
use std::fs::File;
fn main() {
let f = File::open("hello.txt").unwrap(); // 如果文件不存在,就会panic
}解决方案:
- 对于可能出错的地方,尽量用
match或者?操作符来处理错误,而不是unwrap()。 - 只有在你确定值一定是
Ok或者Some的时候,才用unwrap(),比如在测试中,或者在初始化的时候。 - 如果确实要用
unwrap(),建议用expect(),并加上有意义的错误信息,这样出问题的时候,更容易排查。
比如,用?操作符处理错误:
use std::fs::File;
use std::io::Read;
fn read_file() -> Result<String, std::io::Error> {
let mut f = File::open("hello.txt")?; // 如果出错,直接返回Err
let mut contents = String::new();
f.read_to_string(&mut contents)?; // 如果出错,直接返回Err
Ok(contents)
}
fn main() {
match read_file() {
Ok(contents) => println!("{}", contents),
Err(e) => println!("读取文件出错: {}", e),
}
}?操作符,是Rust中处理错误的利器,它可以在出错的时候,直接把Err返回出去,不需要写一大堆match。但要注意,?只能用在返回Result或Option的函数中。
坑6:错误类型不匹配,不能直接用?
在使用?操作符的时候,如果函数返回的错误类型,和?操作符产生的错误类型不一致,就会报错。
比如,一个函数中,既可能出现IO错误,也可能出现解析错误,这两种错误类型不一样,直接用?就会报错。
我自己踩这个坑,是在写一个函数的时候,里面既读文件,又解析JSON,结果IO错误和JSON解析错误类型不一样,?用不了,报错了。
解决方案:
- 定义自己的错误类型,实现
Fromtrait,把其他错误类型转换成自己的错误类型。 - 使用
Box<dyn Error>作为错误类型,它可以包含任何实现了Errortrait的错误类型。 - 使用
thiserror或者anyhow这样的库,简化错误处理。
比如,用Box<dyn Error>:
use std::fs::File;
use std::io::Read;
fn read_and_parse() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let mut f = File::open("config.json")?; // IO错误,自动转换成Box<dyn Error>
let mut contents = String::new();
f.read_to_string(&mut contents)?;
let config: serde_json::Value = serde_json::from_str(&contents)?; // JSON错误,自动转换
Ok(config)
}或者,用anyhow库,更方便:
use anyhow::{Context, Result};
use std::fs::File;
use std::io::Read;
fn read_and_parse() -> Result<serde_json::Value> {
let mut f = File::open("config.json")
.with_context(|| "无法打开配置文件")?;
let mut contents = String::new();
f.read_to_string(&mut contents)
.with_context(|| "无法读取配置文件")?;
let config: serde_json::Value = serde_json::from_str(&contents)
.with_context(|| "无法解析配置文件")?;
Ok(config)
}anyhow库,提供了Result类型的别名,以及Context trait,可以给错误加上上下文信息,让错误更容易排查。在应用程序中,用anyhow处理错误,非常方便。
而在库中,建议用thiserror定义自己的错误类型,这样库的使用者可以更精确地处理错误。
三、trait和泛型:抽象的正确姿势
trait和泛型,是Rust中实现抽象和代码复用的重要机制。但用不好,也会踩很多坑。
坑7:trait对象的动态分发,有运行时开销
在Rust中,可以用dyn Trait来创建trait对象,实现动态分发。但trait对象有运行时开销,因为需要在运行时查找方法表(vtable),而且不能被内联。
很多人,为了方便,到处用dyn Trait,结果性能受到影响。我自己在写一个插件系统的时候,一开始用了dyn Trait,后来发现性能不够,才改成了泛型静态分发。
比如:
trait Shape {
fn area(&self) -> f64;
}
struct Circle { radius: f64 }
impl Shape for Circle {
fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }
}
struct Rectangle { width: f64, height: f64 }
impl Shape for Rectangle {
fn area(&self) -> f64 { self.width * self.height }
}
// 动态分发,有运行时开销
fn print_area(shape: &dyn Shape) {
println!("面积: {}", shape.area());
}
// 静态分发,没有运行时开销,编译时会为每个具体类型生成代码
fn print_area_static<T: Shape>(shape: &T) {
println!("面积: {}", shape.area());
}解决方案:
- 如果性能要求高,尽量用泛型(静态分发),而不是trait对象(动态分发)。泛型在编译时会为每个具体类型生成代码,没有运行时开销,而且可以内联,性能更好。
- 如果确实需要运行时的多态(比如在一个集合中存储不同类型的对象),再用trait对象。但要注意,trait对象有运行时开销,而且有一些限制,比如trait必须是对象安全的(object safe)。
- 可以结合使用,比如在对外的API中用trait对象,在内部的性能关键路径上用泛型。
坑8:孤儿规则(Orphan Rule),不能为外部类型实现外部trait
Rust有一个孤儿规则:不能为外部类型实现外部trait。也就是说,要么trait是你自己定义的,要么类型是你自己定义的,两者至少有一个是你自己的,才能实现。
比如,你不能为Vec(外部类型)实现Display(外部trait),因为两者都不是你定义的。
这个规则,是为了保证代码的一致性,避免不同的库为同一个类型实现同一个trait,造成冲突。但在实际开发中,有时候确实需要为外部类型实现外部trait,这时候就会踩坑。
我自己踩这个坑,是在写一个序列化功能的时候,想为一个外部库的类型实现Serialize trait,结果报错了,因为两者都是外部的。
解决方案:
- 使用newtype模式,也就是用一个自己的结构体,把外部类型包起来,然后为这个结构体实现trait。
use std::fmt;
struct MyVec(Vec<i32>); // newtype,把Vec包起来
impl fmt::Display for MyVec {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.iter().map(|x| x.to_string()).collect::<Vec<_>>().join(", "))
}
}- 如果只是临时用一下,可以在函数中直接处理,不需要实现trait。
- 使用trait的扩展方法,定义一个自己的trait,为外部类型实现这个trait,然后通过这个trait来调用方法。
四、并发和异步:Rust的强项,但也有坑
Rust的并发和异步,是它的强项,它可以在编译时保证线程安全,避免数据竞争。但并发和异步本身就比较复杂,用Rust写并发和异步代码,也会踩一些坑。
坑9:多线程中共享数据,需要Arc和Mutex
在多线程中,如果需要共享数据,不能直接用Rc和RefCell,因为它们不是线程安全的。需要用Arc(原子引用计数)来共享所有权,用Mutex或者RwLock来保证互斥访问。
我自己踩这个坑,是在写一个多线程的爬虫的时候,一开始用了Rc<RefCell<>>来共享数据,结果编译报错,因为它们不是Send和Sync的,不能跨线程传递。后来改成了Arc<Mutex<>>,才解决问题。
比如:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0)); // 用Arc共享所有权,用Mutex保证互斥访问
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter); // 克隆Arc,增加引用计数
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap(); // 加锁,获取可变访问
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("结果: {}", *counter.lock().unwrap());
}需要注意的是:
Mutex在加锁失败的时候(比如另一个线程panic了,锁被污染了),会返回Err,这时候调用unwrap()会panic。在实际项目中,需要处理这种情况,或者用PoisonError::into_inner()来获取数据。Mutex的锁,在使用完之后会自动释放(因为实现了Drop),不需要手动释放。但要注意,锁的作用域尽量小,不要持有锁的时间太长,否则会影响并发性能。- 如果读多写少,可以用
RwLock,它允许多个读锁同时存在,但写锁是独占的,性能比Mutex好。
坑10:异步编程中的生命周期和借用问题
Rust的异步编程(async/await),虽然方便,但也会遇到一些生命周期和借用的问题,尤其是在跨.await点持有引用的时候。
比如,在异步函数中,如果持有一个引用,然后在.await之后还使用这个引用,可能会报错,因为.await可能会让出执行权,这时候引用的有效性无法保证。
我自己踩这个坑,是在写一个异步的网络服务的时候,在一个循环中,获取了一个数据的引用,然后.await发送数据,之后还想用这个引用,结果报错了。
解决方案:
- 尽量不要跨
.await点持有引用,如果需要,就把引用变成拥有所有权的值(比如用clone(),或者把数据取出来)。 - 如果确实需要跨
.await点持有引用,可以用Arc来共享数据,这样就不需要担心生命周期的问题。 - 把借用和
.await分开,在借用的时候不.await,在.await的时候不持有借用。
比如,有问题的代码:
async fn process(data: &Data) {
let result = data.compute(); // 持有data的引用
send(result).await; // .await,这时候data的引用还在作用域中
println!("{}", data.name); // 这里还在用data的引用,可能报错
}修改后的代码:
async fn process(data: &Data) {
let result = data.compute();
let name = data.name.clone(); // 把需要的数据取出来,变成拥有所有权的值
send(result).await;
println!("{}", name); // 用取出来的值,不再持有data的引用
}五、其他常见的坑
除了上面这些,还有一些其他常见的坑,也简单说一下。
坑11:整数溢出和类型转换
Rust中的整数,在debug模式下,溢出会panic;在release模式下,溢出会回绕(wrap around)。这和C/C++不一样,C/C++中整数溢出是未定义行为。
另外,不同整数类型之间的转换,需要用as,但as可能会截断或者改变符号,需要注意。
比如:
let x: u8 = 255;
let y: u8 = x + 1; // debug模式下panic,release模式下y=0解决方案:
- 对于可能溢出的计算,使用
checked、wrapping、saturating_*等方法,明确指定溢出时的行为。 - 类型转换的时候,注意目标类型的范围,避免截断。如果需要安全转换,可以用
TryFrom/TryInto,转换失败的时候返回Err。
坑12:字符串处理,String和&str的区别
Rust中的字符串,有String和&str两种,前者是拥有所有权的,后者是借用的。很多人分不清这两者,经常踩坑。
简单来说:
String是可变的、拥有所有权的字符串,存在堆上,可以修改。&str是不可变的、借用的字符串视图,可以指向堆上的String,也可以指向静态的字符串字面量。
在函数参数中,尽量用&str,这样既可以传String的引用,也可以传字符串字面量,更通用。在函数返回值中,如果是新创建的字符串,返回String;如果是引用参数中的一部分,返回&str(需要生命周期标注)。
坑13:迭代器的消费和惰性
Rust中的迭代器是惰性的,也就是说,创建迭代器的时候,不会真正执行,只有在消费的时候(比如调用collect()、for循环、next()等),才会真正执行。
很多人,创建了迭代器,但没有消费,结果什么都没发生。或者,在迭代器中用了有副作用的闭包(比如println!),但因为没有消费,副作用没有发生,觉得很奇怪。
另外,迭代器只能消费一次,消费完之后就不能再用了。如果需要多次使用,可以用collect()把迭代器的结果收集到一个集合中,然后多次遍历这个集合。
坑14:宏的卫生性和调试
Rust的宏(macro_rules!),很强大,但也比较难写,尤其是宏的卫生性(hygiene),经常让人困惑。宏中的变量,和外部的变量,不会互相冲突,这是宏的卫生性,但有时候也会导致一些奇怪的问题。
另外,宏的调试也比较困难,因为宏展开之后的代码,不容易看到。可以用cargo expand命令,来查看宏展开之后的代码,方便调试。
如果是比较复杂的宏,建议用过程宏(proc macro),虽然写起来复杂一些,但更灵活,也更容易调试。
六、写在最后
Rust是一门很优秀的语言,它的设计理念很先进,性能和安全性都很好。但它的学习曲线确实比较陡峭,尤其是所有权、借用、生命周期这些概念,需要花时间去理解,去适应。
我自己在学习和使用Rust的过程中,踩了很多坑,也走了很多弯路。但慢慢地,我理解了这些概念,也越来越体会到Rust的好处。现在,写Rust代码的时候,编译器报错越来越少了,写出来的代码,也越来越健壮,越来越高效。
今天分享的这些坑,都是我自己在实战中遇到的,也是很多Rust初学者容易遇到的。希望这些经验,能帮助大家少走弯路,更快地掌握Rust。
最后,想说的是,学习Rust,不要怕报错,编译器的报错信息,其实是很好的学习资料。每次报错,仔细看报错信息,理解为什么报错,然后修改,这样进步会很快。Rust的编译器,是你最好的老师。
如果你也在学习Rust,或者正在用Rust做项目,遇到了什么坑,欢迎在评论区交流,我们一起探讨,一起进步。
愿大家都能享受Rust编程的乐趣。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录