BigFish

曾经靠饿,瘦了20斤

0%

Rust一次搞懂生命周期注解

引言

这是Rust九九八十一难第十六篇。上篇聊了下Rust有哪些危险操作,有个操作是无脑Clone的风险,这时候可能用到引用和’a生命周期注解(Lifetime annotations)。那么问题来了,有时候必须加,有时不用,到底什么时候用’a,生命周期的计算规则是什么等等。这篇就整理下生命周期注解的使用。

一、简单了解和入门

使用之前,先做个入门。Lifetime annotations(’a,’b等)像是一种约束,只是告诉编译器引用之间的有效期关系,编译器以此和borrow-checker,保证引用不会比引用数据活的更久。有点类似学校的某班点名册,学生是数据,点名册是引用,他俩关系是学生毕业了,这本点名册就要废弃。

1、函数返回引用

下面的函数接受俩字符串切片,返回返回其中较长的一个

错误使用:

1
2
3
4
5
6
7
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}

编译报错:Missing lifetime specifier [E0106]

正确使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}

fn main() {
let s1 = String::from("hello world");
let s2 = String::from("hi");
let result = longest(&s1, &s2);
println!("Longest: {}", result);
}

这里&'a str表示&str在用在某个生命周期 'a 内有效,x 和 y 的引用都有生命周期 'a,函数返回一个同样具有 'a 生命周期的引用。编译器知道后,保证x和y活多久,只要它们都活过同一段 'a,那么返回的引用就是安全的。

2、struct\enum使用生命周期注解

用结构体存引用,这必须给struct加生命周期注解。比如下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#[derive(Debug)]
struct Book<'a> {
title: &'a str,
author: &'a str,
}

fn main() {
let title = String::from("Rust in Action");
let author = String::from("Tim McNamara");

let book = Book {
title: &title,
author: &author,
};

println!("{:?}", book);
}

Book 存有引用字段,Rust需要知道引用在哪段时间有效(防止悬垂引用、推导借用规则等),于是 Book<'a>'a 表示:这个 struct 所持的引用至少活到 'a,且 Book<'a> 的实例本身不能比 'a 活得更久。也就是说:

  • 'a 是一个 约束条件:Book 内部引用必须有效
  • struct 的生命周期 ≤ 'a

二、Lifetime annotations命名规则

1、规则和限制

  • 必须以 ' 开头,所有生命周期名称都必须以单引号 ' 开头,比如:’a,’static。

  • 'a'b'c 是最常见的

  • 生命周期名可以是多个单词或多个字符(但必须合法的标识符):

    1
    fn parse<'de>(s: &'de str) { }  //常见于 Serde 的 'de
  • 必须是合法标识符字符(字母、数字、下划线),但不能以数字开头:

    • 合法: ‘file、’input1 、’_a
    • 不合法:’1abc、’’abc // 双引号
  • 'static(内置生命周期),可以使用 'static,它不是关键字,但它的意义被语言定义。

  • '_'(匿名生命周期),从 Rust 1.34 开始允许,用于减少显式写法

    1
    fn foo<'_>(x: &'_ str) {}
  • Rust 没有任何关键字被禁止用作生命周期名。但随便用关键字会降低可读性。

2、一些建议

  • 如果不需要写生命周期,就别写,写了会传染

  • 简单场景用 'a 足够,不要过度设计

  • 多个生命周期时,用 'a 'b 'c,除非语义重要

  • 不关心具体生命周期用’_

  • 程序整个生命周期,’static

  • 生命周期与类型参数顺序:先 'aT

    1
    fn f<'a, T>(x: &'a T) {}
  • 特定场景

    • 解析数据:用 'de'input 组合
    1
    2
    3
    fn parse<'de, 'input>(src: &'input str) -> Result<T<'de>, E> {
    ...
    }
    • 上下文:’ctx
    • 配置:’cfg
    • 字符串来源:’s
  • 默认使用 'a', 'b', 'c'。只有当它们导致混淆,或者你需要为特定的外部数据源提供明确的上下文时,才考虑使用更长的、描述性的名称(如 'repo, 'src')。

三、生命周期注解如何计算存活时间

Rust 生命周期注解本身并不计算或控制引用的存活时间,它是描述性的。它的作用是告诉 Borrow Checker不同引用之间的生命周期关系,从而让编译器能够检查引用是否有效,防止悬垂引用的产生。

Rust lifetime = 取“引用被使用的区间”与“被借用对象的存活区间”的交集,并确保借用不超过对象范围,如果涉及多个借用,则返回引用的生存区间必须是输入引用交集中的某个合法子集。通过几个例子说明下:

  • 示例1:引用的生命周期计算(显式注解版, 平常不用加,这里为了展示方便)

    1
    2
    3
    4
    5
    6
    fn main<'main>() {
    let x: i32 = 5; // x: 'main
    let r: &'main i32 = &x; // r: 'main

    println!("{}", r); // r 最后一次使用
    } // 'main 在函数结束
    1
    2
    3
    x:   ─────────────────────────── 'main
    r: ────────────────────── 'main
    ^开始借用 ^最后使用

    生命周期 'main 完全包含 r 的使用区间,因此合法。

  • 示例2:返回引用必须绑定输入(显式注解版)

    1
    2
    3
    fn get_ref<'a>(x: &'a i32) -> &'a i32 {
    x
    }
    1
    2
    x (input):  ──────────────────── 'a
    return: ───────────────── 'a

    返回引用直接继承 'a,不能产生新的生命周期。

  • 示例3:两个输入生命周期不同,但返回其中之一

    1
    2
    3
    fn pick<'a, 'b>(a: &'a i32, b: &'b i32) -> &'a i32 {
    a
    }
    1
    2
    3
    'a:  ───────────────────────────────
    'b: ───────────────
    ret: ─────────────────────────────── (和a一样)
  • 示例4:返回交集例子

    • struct例子
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    impl<'a, 'b> Pair<'a, 'b> {
    // 允许只有当 'a 与 'b 有重叠时
    fn shortest<'c>(&'c self) -> &'c str
    where
    'a: 'c,
    'b: 'c,
    {
    if self.left.len() < self.right.len() {
    self.left
    } else {
    self.right
    }
    }
    }

    1
    2
    3
    4
    'a:  ─────────────────────────────
    'b: ───────────────────────
    'c: ─────────────── (交集区域)

    • 函数例子
    1
    2
    3
    4
    5
    6
    7
    fn overlap<'a, 'b, 'c>(a: &'a str, b: &'b str) -> &'c str
    where
    'a: 'c,
    'b: 'c,
    {
    if a.len() < b.len() { a } else { b }
    }
    1
    2
    3
    4
    'a:    ───────────────────────
    'b: ─────────────────────
    'c (交集): ─────────────

    返回值 'c 就是 'a ∩ 'b 的交集范围

四、使用场景:哪些情况必须使用vs哪些不用

既然生命周期注解有这么重要,为什么好多情况不加也没问题呢,到底什么情况下需要?这就涉及到生命周期省略规则。

1、Rust在这些场景下能自动推断生命周期,无需手动加标注。

a、所有输入引用各自都有一个推断到的生命周期

函数参数里每个 &T,如果没写生命周期,编译器会自动给它加一个,但不返回引用时或者返回值不是引用。

  • 例如:fn foo(x: &str, y: &str) 等价于 fn foo<'a, 'b>(x: &'a str, y: &'b str)

b、只有一个输入引用,则输出引用用它的生命周期

如果函数 有且只有一个输入引用,并且返回引用, 那么返回值的生命周期会自动和这个输入一样。这是最安全的默认推断:如果返回一个引用,那么它必须依赖于唯一的输入引用

  • 例如:fn foo(x: &str) -> &str 等价于 fn foo<'a>(x: &'a str) -> &'a str

c、方法中有 &self / &mut self,输出引用默认跟 self 的生命周期

假设方法是借用(而非所有权转移),返回的任何引用最有可能指向方法调用者(self)所拥有的数据。

  • 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    struct Owner {
    data: String,
    }

    impl Owner {
    // 原始代码:没有手动标注
    fn get_data_ref(&self) -> &str {
    &self.data // 返回对自身数据的引用
    }
    }

    // 编译器推断:等价于
    /*
    impl<'a> Owner {
    fn get_data_ref(&'a self) -> &'a str {
    &self.data
    }
    }
    */
    // 约束:返回的 &str 的生命周期 'a 与 &self 的生命周期 'a 相同。

d、结构体没有引用字段

1
struct Foo { x: i32 }

2、必须加生命周期标注

a、结构体(或枚举)里含有引用字段

1
2
3
struct Foo<'a> {
name: &'a str,
}

结构体持有引用必须明确生命周期,否则编译器不知道引用活多久。

b、函数/方法返回的引用来自多个输入引用(多对一),编译器无法判断返回的是谁

1
fn choose(x: &str, y: &str) -> &str { ... }

这会报错,因为编译器不知道返回值应该跟 'x 还是 'y。必须写:

1
fn choose<'a>(x: &'a str, y: &'a str) -> &'a str //// 约束:返回的引用 'a 依赖于 x 和 y 中生命周期最短的那个。

c、方法返回的引用不属于 self,但有多个输入引用

1
2
3
fn pick<'a>(&self, other: &'a str) -> &str {
other
}

如果不写:

1
fn pick(&self, other: &str) -> &str

编译器会以为输出是 self 的生命周期(规则 3),导致错误。

3、特殊场景

3.1、 声明生命周期的“长短关系” 'a: 'b

你有两个输入生命周期 'a'b,但你要保证 'a >= 'b'a 活得至少跟 'b 一样久),否则返回 'b 的引用可能会出错。

1
2
3
4
5
6
fn choose_first<'a, 'b>(x: &'a str, _y: &'b str) -> &'b str
where
'a: 'b, // 'a 长于 'b,所以返回 &'b str 时是安全的
{
x
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Mapper<'a, 'b> {
from: &'a str,
to: &'b str,
}

impl<'a, 'b> Mapper<'a, 'b>
where
'a: 'b, // 必须明确指出关系
{
fn map(&self) -> &'b str {
self.from
}
}

'a: 'b 是 Rust 生命周期 subtyping(子类型)约束的语法,用来表达:

生命周期 'a 必须比 'b 活得更久(或至少一样久)
换句话说:'a'b

3.2、trait object 中的生命周期(如 Box<dyn Trait + 'a>

这个 Box 必须至少活 'a 那么久,否则其中的引用就可能悬垂。通常情况下不需要,但在某些特定场景下是必须的。在绝大多数情况下,当写 Box<dyn Printer> 时,编译器会自动添加生命周期参数。

Box<dyn Trait>等同于 'static,不能放非 'static 引用。只有当 Trait Object 内部的类型依赖于一个'static' 的,即局部作用域的生命周期时,才必须手动添加 'a 来约束它。

  • 必须加举例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
trait Printer {
fn print(&self);
}

struct StrPrinter<'a> {
msg: &'a str,
}

impl<'a> Printer for StrPrinter<'a> {
fn print(&self) {
println!("{}", self.msg);
}
}

//不加‘a报这个错误:lifetime may not live long enough
//returning this value requires that `'a` must outlive `'static`
fn make_printer<'a>(msg: &'a str) -> Box<dyn Printer + 'a> {
Box::new(StrPrinter { msg })
}

fn main() {
let s = String::from("hello");
let p = make_printer(&s);
p.print();
}

上面例子创建一个 StrPrinter,并且这个对象内部封装了一个生命周期短于 'static' 的引用。省略 'a' 会导致编译器默认采用 'static' 约束,从而引发安全错误.

  • 不用加举例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

trait Logger {
fn log(&self, message: &str);
}

struct OwnedLogger {
prefix: String, // 拥有所有权,生命周期与结构体实例绑定
}

impl Logger for OwnedLogger {
fn log(&self, message: &str) {
println!("[{}] {}", self.prefix, message);
}
}

// 注意:这里不需要写 <'a> 或 + 'a
// Box<dyn Logger> 隐式等价于 Box<dyn Logger + 'static>
fn create_logger(prefix: &str) -> Box<dyn Logger> {
Box::new(OwnedLogger {
prefix: prefix.to_string(), // 将 &str 复制为 String (拥有所有权)
})
}

fn main() {
let msg = String::from("Main scope message");

// 1. 创建 Logger:返回的 Box<dyn Logger> 默认是 'static
// 输入 prefix 是局部的 &str,但它在函数内部被复制成了 String
let logger_box = create_logger("APP");

// 2. 在 msg 被释放后(理论上,如果 logger_box 活得更久),logger_box 仍然安全
// 因为 logger_box 内部的 prefix 是它自己拥有的 String
// 它不依赖于任何外部的局部引用。
logger_box.log("User logged in.");

// 3. 传入一个 'static 的字符串字面量也是安全的:
let static_logger_box = create_logger("STATIC");
static_logger_box.log("System started.");
}

3.3、HRTB 必须加 for<'a>

HRTB(Higher-Rank Trait Bounds,高阶生命周期约束)

HRTB 使用 for<'a> 语法,是因为它用于声明约束一个类型(通常是闭包或函数指针)的能力,即该类型对于任意传入的生命周期都是有效的。

  • 普通函数签名只能用一个固定生命周期,比如:
1
2
//这里的 fn(&i32) 实际上隐含了某个生命周期 'x
fn call_fn(f: fn(&i32)) { ... }
  • 如果你想让 f 可以接受 任意生命周期 的引用,就需要 HRTB:
1
2
3
4
fn call_fn<F>(f: F)
where
F: for<'a> Fn(&'a i32),
{ ... }

这里 for<'a> 告诉 Rust:f所有生命周期 'a 的引用 都有效

  • 举个例子

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    fn call_with_i32<F>(f: F)
    where
    F: for<'a> Fn(&'a i32),
    {
    let x = 5;
    let y = 10;
    f(&x); // &'a i32 可以是 x 的生命周期
    f(&y); // &'a i32 可以是 y 的生命周期
    }
    //调用,闭包 |n| println!("{}", n) 可以接受任意生命周期的 &i32
    call_with_i32(|n| println!("{}", n));
  • 什么时候必须用HRTB(for<'a>

    • 函数接受泛型闭包或函数指针

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      // 无法省略!必须用 for<'a> 来表达对任意生命周期 'a 的适用性
      fn process_multiple_borrows<F>(f: F)
      where
      // HRTB: 约束 F 必须对于所有生命周期 'a 都满足 Fn(&'a str) Trait
      F: for<'a> Fn(&'a str) -> u32,
      {
      // 短命引用 L_short
      let data_temp = String::from("ephemeral");
      let len_temp = f(&data_temp);
      println!("Temp data length: {}", len_temp);

      // 长命引用 L_static
      let len_static = f("static string");
      println!("Static data length: {}", len_static);
      }

      fn main() {
      let my_counter = |s: &str| s.len() as u32;
      process_multiple_borrows(my_counter);
      }
    • 高阶函数 / trait / async / future 的引用需要泛型生命周期时

      1
      2
      3
      4
      5
      6
      fn apply<F>(f: F)
      where
      F: for<'a> Fn(&'a str) -> &'a str,
      {
      // 可以接受任意生命周期的 &str
      }

3.4 、GATs需要加’a场景

GATs (Generic Associated Types),即通用关联类型,是 Rust 语言中一项重要的、相对高级的特性。它允许 Trait(特型)的关联类型能够拥有自己的泛型参数(包括生命周期参数和类型参数),就像 Trait 本身可以拥有泛型参数一样。使用 GATs,可以定义一个关联类型 Ref,并为其引入一个生命周期参数 'a,从而明确这个引用依赖于 'a

语法:

1
2
3
4
5
6
7
8
trait MyTrait {
// T 是 Trait 的泛型参数
// Item<U> 是关联类型 Item 的泛型参数
type Item<U>;

// 允许关联类型依赖于生命周期参数 'a
type Reference<'a> where Self: 'a;
}

在迭代器场景,Iterator::Item 是一个 独立于迭代器(self)生命周期的类型。没有GATs,不能安全地写出“返回对自身内部数据的引用”的迭代器。

举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pub trait LendingIterator {
// GATs: 定义一个关联类型 Item,它依赖于生命周期参数 'a
// 这里的 Item<'a> 表示“一个生命周期为 'a 的引用”
type Item<'a> where Self: 'a;

// next 方法返回的 Item<'a>,其生命周期 'a 取决于 &self 的生命周期
fn next<'a>(&'a self) -> Option<Self::Item<'a>>;
}

struct DataProcessor {
data: Vec<i32>
}

impl LendingIterator for DataProcessor {
// 实例化 Item<'a> 为一个依赖于 'a 的引用
type Item<'a> = &'a i32;

fn next<'a>(&'a self) -> Option<Self::Item<'a>> {
// 实际代码会复杂的多,这里只是示意
self.data.get(0)
}
}
  • Item<'a> 不是一个固定类型,而是一个模板,根据 'a 不同得到不同的具体类型。Item<’short>会变成&’short str, Item<’long>会变成&’long str
  • next 返回的内容与 &self 生命周期保持一致,保证安全

五、总结

本文总结了生命周期注解的使用方式、命名规则和必须使用的场景等,优点是帮助编译器更好的管理。缺点也不少,比如学习复杂;注解有传播性,一旦一个类型(struct、enum、函数)带上生命周期 'a,那么它的使用者可能也必须带上 'a,项目大,可能会反省爆炸;编译器无法推断所有场景。使用的时候,能不加就不加,仅在编译器报错/性能需求/结构设计确实要求借用时,才加生命周期注解。

如果觉得本文有用,本人公众号大鱼七成饱,辛苦点个关注吧。

Welcome to my other publishing channels