欢迎光临
我们一直在努力

90% 的 Rust 新手都卡在了所有权、借用和生命周期

文章目录

  • 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 最难的部分,反而会成为你写出安全代码最可靠的助手。

赞(0)
未经允许不得转载:171主机测评 » 90% 的 Rust 新手都卡在了所有权、借用和生命周期
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址