深入理解 Rust 生命周期

引言

通过上一篇文章《深入理解 Rust 所有权机制》的学习,我们对 Rust 如何管理内存有了一定程度的理解。每一块数据在任意时刻都有且只有一个所有者(Owner)。当所有者离开其作用域时,对应数据会被自动释放(Drop)。所有权机制通过明确的所有者和作用域,使得内存管理变得安全且高效。

当借用(Borrow)一个数据的引用时,并没有转移数据的所有权,只是临时获取了数据的访问权。此时,生命周期机制就会介入了:

  • 它确保引用在使用期间,对应的数据是活着的(即数据的所有者还在作用域内);
  • 编译器通过生命周期检查,让借用在数据的所有权存活周期内有效,防止产生无效引用。

生命周期是指引用在程序中有效存在的范围。在 Rust 中,所有引用都必须有一个确定的生命周期,以保证它们指向的数据在引用存活期间始终有效。生命周期检查器在编译期确保引用不会在其所指向数据的作用域结束后继续使用,从而避免引用悬空

所有权是关于“谁拥有数据”,强调数据的归属和资源释放的确定性。生命周期是关于“引用能活多久”,强调引用在多长范围内是有效的。通过所有权和生命周期这两套机制的紧密配合,Rust 在不使用垃圾回收(GC)的前提下,实现了安全的内存管理与资源使用。

在本篇文章中,我们将深入探讨 Rust 的生命周期机制。我们会从基本概念开始,逐步解析生命周期的工作原理,以及如何在实际编码中正确地使用生命周期标注。通过对生命周期的全面理解,您将能够编写出更健壮和更高效的 Rust 程序,同时充分发挥 Rust 在内存安全和性能方面的优势。

生命周期概念

对于任意编程语言,栈上的值都有自己的生命周期,它和栈帧的生命周期一致,而对于 Rust,还为堆上的内存也引入了生命周期。

我们知道,在其它语言中,堆内存的生命周期是不确定的,或者是未定义的。因此,要么开发者手工维护,要么语言在运行时做额外的检查。但在 Rust 中,内存管理主要依赖于所有权和作用域的概念。当你在堆上分配内存(例如使用 Box<T>、Vec<T> 等),其所有权通常由栈上的变量持有。当这个栈变量离开其作用域时,Rust 会自动调用其 Drop 方法,从而释放堆上的内存。

在 Rust 中,堆内存的生命周期会默认和其栈内存的生命周期绑定在一起,除非显式地做 Box::leak() 、Box::into_raw() 或ManualDrop 等动作。这意味着,当栈上的变量被销毁时,堆上的内存也会被自动释放。在每个函数的作用域中,编译器就可以对比值和其引用的生命周期,来确保“引用的生命周期不超出值的生命周期”。

举个简单的例子:

{
    let b = Box::new(5);
    // 在这里,b 可以正常使用,堆上的整数 5 也存在
}
// 离开作用域,b 被销毁,堆上的内存也被释放

在这个例子中,b 是一个 Box<i32> 类型的栈变量,持有堆上整数 5 的所有权。当 b 离开作用域时,其 Drop 方法被调用,堆上的内存被释放。

如果一个值的生命周期贯穿整个进程的生命周期,我们就称这种生命周期为静态生命周期。当值拥有静态生命周期,其引用也具有静态生命周期。我们在表述这种引用的时候,可以用 'static 来表示。比如: &'static str 代表这是一个具有静态生命周期的字符串引用。

一般来说,全局变量、静态变量、字符串字面量(string literal)等,都拥有静态生命周期。对于堆内存,如果使用了 Box::leak 后,也具有静态生命周期。

如果一个值是在某个作用域中定义的,也就是说它被创建在栈上或者堆上,那么其生命周期是动态的,我们就称这种生命周期为动态生命周期。当这个值的作用域结束时,值的生命周期也随之结束。

对于动态生命周期,我们约定用 'a 、'b 或者 'hello 这样的小写字符或者字符串来表述。 ' 后面具体是什么名字不重要,它代表某一段动态的生命周期,其中, &'a str 和 &'b str 表示这两个字符串引用的生命周期可能不一致。

生命周期标注

编译器在编译某个函数时,并不知道这个函数将来有谁调用、怎么调用,所以,函数本身携带的信息,就是编译器在编译时使用的全部信息。

我们看一个例子:

// 这个函数返回两个字符串切片中较长的那个引用
// 去掉生命周期标注后,编译器无法判断返回值与参数的生命周期关系
fn longer(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let first = String::from("long string");
    {
        let second = String::from("xyz");
        let result = longer(first.as_str(), second.as_str());
        // 此处将无法编译通过,因为编译器需要生命周期信息来确保引用安全
        println!("{}", result);
    }
}

运行该程序,发现编译器报错:缺少生命周期标注

zhangxiaolongdeMacBook-Pro:ownership zhangxiaolong$ cargo run
   Compiling ownership v0.1.0 (/Users/zhangxiaolong/Desktop/D/rust-workspace/ownership)
error[E0106]: missing lifetime specifier
 --> src/main.rs:3:32
  |
3 | fn longer(x: &str, y: &str) -> &str {
  |              ----     ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
  |
3 | fn longer<'a>(x: &'a str, y: &'a str) -> &'a str {
  |          ++++     ++          ++          ++

For more information about this error, try `rustc --explain E0106`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

此时,就需要我们在函数签名中提供生命周期的信息,也就是生命周期标注(lifetime specifier)。在生命周期标注时,使用的参数叫生命周期参数(lifetime parameter)。通过生命周期标注,我们告诉编译器这些引用间生命周期的约束。

我们给 longer 函数添加生命周期标注:

// 使用 'a 来标注生命周期,将函数返回的引用与参数的生命周期关联
fn longer<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

再次运行该程序:

zhangxiaolongdeMacBook-Pro:ownership zhangxiaolong$ cargo run
   Compiling ownership v0.1.0 (/Users/zhangxiaolong/Desktop/D/rust-workspace/ownership)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.44s
     Running `target/debug/ownership`
long string

可见,生命周期参数的描述方式和泛型参数一致,不过只使用小写字母。这里,两个入参 x 和 y,以及返回值都用 'a 来约束。生命周期参数,描述的是参数和参数之间、参数和返回值之间的关系,并不改变原有的生命周期。

在我们添加了生命周期参数后,x 和 y 的生命周期只要大于等于(outlive) 'a,就符合参数的约束,而返回值的生命周期同理,也需要大于等于 'a 。

你可能会有困惑了:为什么我之前写的代码,很多函数的参数或者返回值都使用了引用,但编译器却没有提示我要额外标注生命周期呢?这是因为编译器希望尽可能减轻开发者的负担,其实所有使用了引用的函数,都需要生命周期的标注,只不过编译器会自动做这件事,减轻了开发者的麻烦。

生命周期标注的目的是,在参数和返回值之间建立联系或者约束。调用函数时,传入的参数的生命周期需要大于等于(outlive)标注的生命周期。

编译器能够为函数自动添加生命周期标注,是因为它遵循了一套简单清晰的规则:

  • 所有引用类型的参数都有独立的生命周期 'a 、'b 等;
  • 如果只有一个引用型输入,它的生命周期会赋给所有输出;
  • 如果有多个引用类型的参数,其中一个是 self,那么它的生命周期会赋给所有输出。

在处理生命周期时,编译器会根据这些缺省规则为函数自动添加生命周期标注,但当代码结构和引用关系比较复杂,以至于使用缺省规则无法确定时,才需要开发者手工添加标注。于是乎,Rust 开发者就相当于一个接线员,他需要将编译器无法自动接续的线以手工的方式全局接续起来,这是非常有挑战的一件事情

接线员.png

当为每个函数都添加好生命周期标注后,编译器就可以从函数调用的上下文中分析出,在传参时引用的生命周期是否和函数签名中要求的生命周期匹配。如果不匹配,就违背了“引用的生命周期不能超出值的生命周期”的约束,编译器会报错。

使用数据结构时,数据结构自身的生命周期,需要小于等于其内部字段的所有引用的生命周期。

生命周期单位

我们看一个例子:

pub fn strtok(s: &mut &str, delimiter: char) -> &str {
    if let Some(i) = s.find(delimiter) {
        let prefix = &s[..i];
        // 由于 delimiter 可以是 utf8,所以我们需要获得其 utf8 长度,
        // 直接使用 len 返回的是字节长度,会有问题
        let suffix = &s[(i + delimiter.len_utf8())..];
        *s = suffix;
        prefix
    } else { // 如果没找到,返回整个字符串,把原字符串指针 s 指向空串
        let prefix = *s;
        *s = "";
        prefix
    }
}

fn main() {
    let s = "hello world".to_owned();
    let mut s1 = s.as_str();
    let hello = strtok(&mut s1, ' ');
    println!("hello is: {}, s1: {}, s: {}", hello, s1, s);
}

运行程序,编译错误:缺少生命周期标注

zhangxiaolongdeMacBook-Pro:ownership zhangxiaolong$ cargo run
   Compiling ownership v0.1.0 (/Users/zhangxiaolong/Desktop/D/rust-workspace/ownership)
error[E0106]: missing lifetime specifier
 --> src/main.rs:1:49
  |
1 | pub fn strtok(s: &mut &str, delimiter: char) -> &str {
  |                  ---------                      ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say which one of `s`'s 2 lifetimes it is borrowed from
help: consider introducing a named lifetime parameter
  |
1 | pub fn strtok<'a>(s: &'a mut &'a str, delimiter: char) -> &'a str {
  |              ++++     ++      ++                           ++

For more information about this error, try `rustc --explain E0106`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

一个引用一个生命周期,而不是一个参数一个生命周期。按照编译器的规则, &mut &str 添加生命周期后变成 &'b mut &'a str,这将导致返回的 &str 无法选择一个合适的生命周期。

反证法:假设一个参数一个生命周期,那么给 &mut &str 添加生命周期后就会变成 &mut &'a str,于是编译器根据缺省规则可以判定出返回的 &str 的生命周期就是 'a。但实际上编译器报错了,原因是缺少生命周期标注,所以不是一个参数一个生命周期,而是一个引用一个生命周期。

我们可以为 strtok 添加生命周期标注:

pub fn strtok<'b, 'a>(s: &'b mut &'a str, delimiter: char) -> &'a str {...}

再次运行程序,符合期望:

zhangxiaolongdeMacBook-Pro:ownership zhangxiaolong$ cargo run
   Compiling ownership v0.1.0 (/Users/zhangxiaolong/Desktop/D/rust-workspace/ownership)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/ownership`
hello is: hello, s1: world, s: hello world

因为返回值的生命周期跟字符串引用有关,所以我们仅为这部分的约束人工添加标注就可以了,而剩下的标注让编译器自动添加,于是乎代码就可以简化成下面这样(实际上编译器会将其再扩展成上面的形式):

pub fn strtok<'a>(s: &mut &'a str, delimiter: char) -> &'a str {...}

例外情况

有些情况下,我们可能需要让堆内存的生命周期与栈变量的生命周期解耦,这就需要使用一些特殊的方法,如 Box::leak()、Box::into_raw() 和 std::mem::ManuallyDrop。

Box::leak()

作用:Box::leak() 方法会消耗一个 Box<T>,并返回一个具有 'static 生命周期的可变引用 &'static mut T。这会导致堆上的内存不会在栈变量离开作用域时被释放,从而“泄漏”了内存。
示例

let x = Box::new(42);
let static_ref: &'static mut i32 = Box::leak(x);
// 现在,堆上的内存不会在 x 离开作用域时被释放

说明

  • 使用 Box::leak() 后,原来的 Box<T> 所有权被转移,x 不再持有所有权;
  • 返回的引用具有 'static 生命周期,可以在整个程序生命周期内使用;
  • 堆上的内存不会自动释放,需要在适当的时候手动处理,或者允许程序在结束时由操作系统回收。

适用场景

  • 当需要创建全局变量或静态数据,且生命周期为 'static 时;
  • 当希望数据在程序的整个生命周期内存在,而不受栈变量作用域限制时。

Box::into_raw()

作用:Box::into_raw() 方法会将 Box<T> 转换为一个原始指针 *mut T,并且不再负责管理堆内存。这意味着,当栈变量离开作用域时,堆内存不会被释放,需要程序员手动管理。
示例

let x = Box::new(42);
let raw_ptr: *mut i32 = Box::into_raw(x);
// x 的所有权被转移,堆内存不再由 Rust 自动管理

说明
使用 Box::into_raw() 后,x 不再有效,所有权被转移给原始指针。
堆内存的生命周期与栈变量的生命周期解耦,需要手动调用 Box::from_raw(raw_ptr) 来重新获得所有权,以便正确地释放内存。

重新获得所有权并释放内存:

unsafe {
    let x = Box::from_raw(raw_ptr);
    // 现在 x 重新持有所有权,离开作用域时会自动释放内存
}

适用场景

  • 与 C 语言或其他需要原始指针的外部接口交互时;
  • 需要手动控制内存管理的高级场景。

注意

  • 使用原始指针需要确保内存安全,避免出现悬垂指针或重复释放内存的情况;
  • 需要使用 unsafe 块来重新获得所有权。

std::mem::ManuallyDrop

作用:ManuallyDrop<T> 是一个包装器,阻止其内部的值在离开作用域时自动调用 Drop 方法,需要程序员手动决定何时(或是否)调用 Drop。

示例

use std::mem::ManuallyDrop;

let x = ManuallyDrop::new(Box::new(42));
// x 不会在离开作用域时自动释放,需要手动处理

说明

  • ManuallyDrop<T> 阻止了自动析构;
  • 如果需要释放内存,可以调用ManuallyDrop::drop(&mut x), 也可以调用ManuallyDrop::into_inner(x),或者调用 std::ptr::drop_in_place(&mut x)。

手动释放内存

use std::mem::ManuallyDrop;

let mut x = ManuallyDrop::new(Box::new(42));
unsafe {
    ManuallyDrop::drop(&mut x); // 手动调用 drop
}
// 或者

let inner = ManuallyDrop::into_inner(x); // 取得内部值,自动 drop

适用场景

  • 需要精确控制资源释放的时机和顺序;
  • 防止某些情况下的自动析构,避免双重释放或其他内存安全问题。

注意

  • 避免在正常逻辑下频繁使用 ManuallyDrop,除非确有需要手动控制析构顺序或延迟资源释放的复杂场景;
  • 需要使用 unsafe 块来手动调用 drop。

小结

本文在所有权的基础上进一步阐明了引用在 Rust 中的安全保证和约束条件,详细介绍了Rust 生命周期的概念、标注及其单位,同时探讨了几种例外情况。通过所有权和生命周期这两套机制的紧密配合,Rust 在不使用垃圾回收的前提下,实现了安全的内存管理与资源使用。

Rust 生命周期包括静态生命周期和动态生命周期:

  • 静态生命周期:全局变量、静态变量、字符串字面量(string literal)和故意被泄漏的堆变量(小口子 leaked 机制)等;
  • 动态生命周期:栈变量,默认的堆变量(没有故意被泄漏)。


    lifetime.png

参考资料

  • 极客时间专栏,《Rust 编程第一课》,陈天
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,658评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,482评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,213评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,395评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,487评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,523评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,525评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,300评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,753评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,048评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,223评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,905评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,541评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,168评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,417评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,094评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,088评论 2 352

推荐阅读更多精彩内容