文章目录
- 90% 的 Rust 新手都卡在了所有权、借用和生命周期
-
- 从内存管理说起
- 所有权
- 借用
-
- 规则一:同一时刻,要么有多个不可变借用,要么有一个可变借用,两者不可共存
- 规则二:借用者的生命周期不能长于被借用者的生命周期
- 生命周期
- 生命周期标注
- 结语
90% 的 Rust 新手都卡在了所有权、借用和生命周期
很多 Rust 新手都应该有过这样的经历:入门、再入门、再再入门 Rust。究其原因,不是 Rust 有多难学,而是很多人一开始就没有真正理解 Rust 的内存管理模型,不理解为什么会有所有权、借用和生命周期。
从内存管理说起
内存管理一般就两种方式,一种是以 C 为代表的手动内存管理,另一种是以 Python 为代表的自动垃圾回收(GC),但是这两种方式各有缺点。
在使用 C 编程时,我们调用 malloc 来分配内存,调用 free 来释放内存,这种方式最大的优点是速度快且人为可控。但问题在于我们是可能会出错的,而这就会导致重复释放内存、忘记释放内存、释放后仍使用内存等情况发生。
而使用 Python 编程时,会有一个垃圾回收器(GC)在后台帮我们判断哪些内存不再被引用,然后将其释放,这就避免了很多内存错误问题。但问题在于性能,GC 在扫描和释放内存时,会暂停程序执行(STW,Stop The World),而这会影响程序的响应速度和吞吐量。
我们接触过的编程语言基本是这两种方式,要么是人来管理内存释放,开发者易于理解;要么是 GC 来管理内存释放,开发者完全不需要关心内存管理。
而 Rust 则开创性的走出了第三条路,让编译器来管理内存释放,而为了让这可行,就需要规则来进行约束,也就是所有权、借用和生命周期。同时,这也就不可避免的带来了一定的上手门槛。
所有权
我们知道内存管理管理的是堆内存空间。在 Rust 中,每一块内存都有且仅有一个“所有者”,也就是说所有者拥有对应内存的所有权。所有权有三条规则,编译的时候,编译器会进行检查:
- 每个值都有一个所有者;
- 同一时刻,一个值只能有一个所有者;
- 当所有者离开作用域,值会被自动释放。
fn main() {
let s = String::from("hello"); // s 拥有所有权
} // 离开作用域后自动释放
上面的示例很好理解,我们再来看下一个:
let s1 = String::from("hello");
let s2 = s1;
println!("{}", s1); // 编译器报错
在大多数语言中,s1 和 s2 都会指向同一个字符串。但在 Rust 中,这种情况不会发生,所有权会从 s1 转移到 s2。
这是因为 String 是在堆上分配的,如果 s1 和 s2 都拥有它,那谁来释放它呢?如果两者都尝试释放,那么就会出现重复释放内存的问题。
可以看到,所有权解决的是谁负责释放内存的问题。只要唯一所有者离开作用域,它所拥有的内存就会被自动释放,以此来保证内存安全。
借用
所有权的唯一所有者规则虽然保证了内存安全,但是丢失了灵活性。比如,我们想调用一个计算字符串长度的函数,按照所有权的规则,我们需要像下面的例子这样实现函数:
fn calculate_length(s: String) -> (usize, String) {
let len = s.len();
(len, s) // 将 s 的所有权返回
}
fn main() {
let s = String::from("hello");
let (len, s) = calculate_length(s);
println!("字符串 '{}' 的长度是 {}", s, len);
}
为了解决这一问题,Rust 引入了借用机制,在不转移所有权的前提下,允许临时访问数据。
Rust 的借用分为两种:
- 不可变借用(&T):允许借用者读取数据,但不能修改数据;
- 可变借用(&mut T):允许借用者修改数据。
现在我们将上面示例修改为借用的方式,借用以引用的方式进行,如下所示:
// 参数类型改为 &String(不可变借用)
fn calculate_length(s: &String) -> usize {
s.len() // 直接返回长度,无需返回所有权
}
fn main() {
let s = String::from("hello");
let len = calculate_length(&s); // 传入引用 &s
println!("字符串 '{}' 的长度是 {}", s, len); // s 依然拥有所有权
}
和所有权一样,借用同样有规则:
规则一:同一时刻,要么有多个不可变借用,要么有一个可变借用,两者不可共存
这是一条经典的读写互斥规则,这是为了避免因借用而带来数据竞争问题:
- 多个不可变借用:所有借用者都只读,不会有数据竞争;
- 只有一个可变借用:没有其他任何借用,不存在并发读写,不会有数据竞争。
// 多个不可变借用
let s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} {}", r1, r2); // 编译通过
// 单个可变借用
let mut s = String::from("hello");
let r = &mut s;
r.push_str(", world");
println!("{}", r); // 编译通过
// 可变借用与不可变借用共存
let mut s = String::from("hello");
let r1 = &s; // 不可变借用
let r2 = &mut s; // 可变借用
println!("{} {}", r1, r2); // 编译器报错
规则二:借用者的生命周期不能长于被借用者的生命周期
如果借用者的生命周期超过了被借用者的生命周期,就会产生悬垂引用,也就是被借用者已经被释放,而借用者仍然试图访问其内存。例如:
fn dangle() -> &String {
let s = String::from("hello");
&s // 返回 s 的引用
} // 离开作用域,s 被释放
fn main() {
let s = dangle();
println!("{}", s);
}
这段代码会导致编译器报错,因为 &s(借用者)的生命周期明显长于 s(被借用者)的生命周期。
你现在可能会对于生命周期这一概念是什么还很模糊,可以看完生命周期章节后再回头来看这部分。
生命周期
生命周期什么?它解决的是引用存活多久的问题,而检查借用者与被借用者之间的生命周期关系,也就是借用规则二,这条规则是为了保证能安全地借用,在编译期就杜绝悬垂引用这个内存问题。我们来看例子:
let borrow;
{
let s = String::from("hello"); – s1
borrow = &s; | – s2
} – |
println!("borrow: {}", borrow); –
在上面的示例中,我标注出了被借用者 s 的生命周期 s1,和借用者的生命周期 s2,可以看到 s2 长于 s1。
再来看示例代码,s 在离开作用域后,内存就被释放了,此时 borrow 就成为了悬垂引用了,还继续使用它就会带来问题。
至此,我们也就看明白了借用规则二为什么存在了。
上面的示例比较简单,Rust 的借用检查器能轻松推断出引用的存活范围,直接拦截错误。
但是,当涉及到引用跨函数、跨作用域传递时,编译器就无法自动推断引用的生命周期关系了。它无法判断函数返回的引用,到底和哪个入参的生命周期绑定,也就没法判断返回的引用会不会变成垂悬引用。
// 报错:missing lifetime specifier(缺少生命周期标注)
fn longer_str(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}
上面这个示例是非常经典的生命周期入门示例,编译器会报错。这个函数逻辑很简单:传入两个字符串切片,返回更长的那个。但编译器不知道返回的引用,到底是跟着 x 的生命周期走,还是跟着 y 的生命周期走?
这时候,就需要我们通过生命周期标注,手动告诉借用检查器引用之间的生命周期关系。
生命周期标注
生命周期标注的语法规则很简单:
- 单引号开头,后跟小写字母(社区惯例用 'a、'b);
- 生命周期参数必须先在 <> 中声明;
- 标注位置固定在 & 符号之后,类型之前,比如 &'a str、&'a mut i32。
我们给上面报错的 longer_str 示例加上正确的生命周期标注:
fn longer_str<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
在函数中,生命周期标注的含义是:在所有被同名生命周期标注的入参和返回值中,返回值的生命周期等于所有入参生命周期的最小值,在这个示例中也就是:
返回值的生命周期 = min(x 的生命周期, y 的生命周期)
为什么是最小值呢?这里我们通过一个调用 longer_str 函数的示例来更直观的理解,示例如下:
fn longer_str<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
let ret;
let s1 = String::from("hello");
let s1 = s1.as_str(); – s1
{ |
let s2 = String::from("world!"); |
let s2 = s2.as_str(); – s2 |
ret = longer_str(s1, s2); | |
print!("{}", ret); | |
} – |
} –
在这个示例中,我标注出了 s1 和 s2 的生命周期,s2 的生命周期更小,所以 'a 被解析为 s2 的生命周期,编译器也就能推断出 ret 的生命周期为 s2 的生命周期。
毕竟 longer_str(s1, s2) 有可能返回 s2,此时,如果 ret 离开 s2 的生命周期,就会立即变成悬垂引用。
我们再来举一个结构体中生命周期标注的例子,加强下理解。
// 声明生命周期参数 'a,标注所有引用字段
struct User<'a> {
name: &'a str,
desc: &'a str,
}
在结构体中,生命周期标注的含义是:结构体实例的生命周期等于它包含的引用字段的生命周期的最小值,在这个示例中也就是:
User 实例的生命周期 = min(name 的生命周期, desc 的生命周期)
换句话说,结构体实例必须“死在”它引用的内容之前,避免内部字段变成悬垂引用。我们看一个反例:
struct User<'a> {
name: &'a str,
desc: &'a str,
}
fn main() {
let user;
let name = String::from("张三");
{
let desc = String::from("Rust 开发者");
user = User { name: &name, desc: &desc };
}
// 编译器报错:desc 已经销毁,user 内部的引用成了垂悬引用
println!("{}: {}", user.name, user.desc);
}
同理,之后遇到方法、trait 等场景下的生命周期标注,你都能轻松解决。
结语
很多新人在学习 Rust 时,会把所有权、借用和生命周期当成三套独立的语法规则去死记硬背。
而这就造成了 Rust 入门难的原因,毕竟 Rust 并不是凭空发明了一堆规则来为难开发者。我们应该从内存管理的角度出发,逐步推导出所有权、借用和生命周期,它们为什么存在,解决了什么问题。
当你真正理解之后,你会发现所有权、借用和生命周期不再是 Rust 最难的部分,反而会成为你写出安全代码最可靠的助手。



