生命周期省略与标注

用生命周期参数准确表达引用之间的关系,掌握省略规则、结构体借用、'static 与高阶生命周期约束。

难度 高级 时长 标准深度约 12分钟
版本 Rust 1.98
what

生命周期标注为引用的有效区间命名,用来表达输入引用、输出引用和借用字段之间的关系; 它是类型契约,不会让任何值活得更久。

trap

给多个引用写同一个 'a 会建立约束,却不代表它们来自相同词法作用域; 滥加 'static 也不能修复局部引用。

fix

先追踪每个返回引用来自哪个输入,只标注真正的依赖; 能由省略规则唯一确定时就省略,数据必须独立存在时则返回拥有型值。

是什么,为什么存在

生命周期(lifetime) 是引用可以被安全使用的程序区域。 每条引用都有生命周期,但大多数生命周期由编译器推断,不需要写在源码中。 它描述的是引用的有效性,不等同于变量可见的整个词法作用域。

生命周期标注(lifetime annotation) 以撇号开头,例如 'a'input'static。 它像类型参数一样在泛型参数列表中声明,但代表区域关系,而不是一种运行时类型。 标注只出现在类型与签名中,编译后不需要保存一份生命周期值。

当函数接收多个引用并返回引用时,函数体可能从不止一个输入借出数据。 仅凭 fn select(x: &str, y: &str) -> &str,调用方看不出结果依赖 xy 还是两者。 显式参数把这种来源关系变成可检查的 API 契约。

例如,fn select<'a>(x: &'a str, y: &'a str) -> &'a str 表示调用方要选择一个区域 'a,两个输入在该区域内都有效,结果也只能在该区域内使用。 它不要求两个所有者在同一个代码块创建或销毁;较长的借用可以缩短到共同可用的区域。 函数实现必须对每一种满足契约的调用都安全。

借用检查器(borrow checker) 结合签名、控制流与引用的实际使用位置求解这些约束。 若结果可能越过来源数据的有效区间,它会在编译时拒绝代码。 生命周期标注给分析增加关系,却不会改变所有者的销毁位置。

你会在返回切片的函数、保存引用的结构体、闭包和 trait 对象、线程边界以及泛型 trait 约束中遇到这套语法。 普通只读参数往往可以依赖省略规则;跨越函数边界保存或返回借用时,关系才更常需要显式表达。

工作原理

签名是一组约束

生命周期参数在函数名后的尖括号中声明,再写到 & 与被引用类型之间。 &'a T 是生命周期为 'a 的共享引用,&'a mut T 是同一区域内的可变引用。 描述性名称如 'source 适合复杂签名,简短关系通常使用 'a'b

输出上的 'a 必须与某个有效来源建立联系。 编译器不会根据函数返回时碰巧执行的分支,为公开签名推导条件式关系。 如果实现可能返回两个输入之一,就要给两个输入与输出提供共同约束。

同名参数表达的是最低保证,而不是强制实际借用拥有完全相同的长度。 在调用处,编译器可以把较长引用重新借用为更短引用,并选择满足全部位置的区域。 因此,“同一个 'a”应读成“在同一个所选区域内有效”。

不同名称表示签名没有自动建立关系。 若 fn prefix<'text>(text: &'text str, delimiter: &str) -> &'text str 只从 text 返回切片,分隔符的借用就不应限制结果。 精确签名既扩大合法调用范围,也让审查者看清数据来源。

省略是确定规则

生命周期省略(lifetime elision) 不是任意猜测。 编译器按固定规则补全函数、函数指针和闭包 trait 签名;规则结束后仍有歧义,就会报告缺少生命周期说明符。

函数省略规则依次是:

  1. 每个省略的引用参数获得一个互不相同的输入生命周期。
  2. 若全部参数中恰好只有一个输入生命周期,所有省略的输出生命周期都使用它。
  3. 方法有多个输入生命周期时,只要接收者是 &self&mut self,所有省略的输出生命周期都使用接收者的生命周期。

因此,fn first(text: &str) -> &str 可以省略,因为只有一个输入生命周期。 fn choose(x: &str, y: &str) -> &str 不能省略,因为规则无法判断输出来自哪个输入。 方法的接收者规则还可能让输出默认绑定到 self,即使另一个参数看起来更像来源。

占位生命周期 '_ 要求编译器在当前位置推断生命周期。 它在类型路径中比完全省略更明确,例如返回 View<'_>;但它仍不会创建新的关系或放松借用检查。

借用字段把关系带进类型

结构体若保存引用,就必须在结构体定义上声明相应生命周期。 struct View<'a> { text: &'a str } 表示任何 View<'a> 都不能在 text 已失效后继续使用。 约束跟随这个类型进入函数参数、返回值与容器元素。

impl<'a> View<'a> 声明实现适用于任意 'a。 方法返回字段时,可以明确写 -> &'a str,表示结果直接借自字段的来源;写 -> &str 时,接收者省略规则通常把结果限制到这次 &self 借用。 两种签名可能都能编译,但对调用方暴露的关系不同。

借用字段适合短期视图、解析结果和避免复制的大块输入。 如果值要进入长期缓存、跨线程队列或独立于来源存储,拥有 StringVec<T> 或共享所有权指针通常更符合契约。 生命周期参数不能让自引用结构体自动变得安全,也不能替代所有权设计。

先决定所有权,再写标注

返回引用适合结果本来就是输入的一部分,而且调用方能自然保留输入的场景。 切片查找、无复制解析和集合视图都符合这种关系;签名让调用方知道结果不能脱离来源。

返回拥有型值适合函数创建新数据,或结果必须独立进入队列、缓存与异步任务的场景。 这不是对借用检查器的妥协,而是不同的 API 契约;强行借用反而会把内部存储细节传播给所有调用方。

结构体是否保存引用也应由对象的角色决定。 一次请求内使用的解析视图可以借用缓冲区,而长期领域对象通常应拥有关键字段。 先画出所有者与使用期限,再决定是否需要结构体生命周期参数。

编译器建议添加 'a 时,只说明签名缺少可证明的关系,不保证标注是正确修复。 若数据在函数内创建,返回引用仍然没有合法来源;若输出只来自一个输入,把其他参数绑进同一 'a 又会过度约束。

最小契约通常也是最稳定的契约。 它减少调用方必须同时维持的借用,让函数实现的真实数据流直接体现在类型中。 这也让后续重构更容易区分必要约束与偶然耦合。

示例

为两个输入建立共同区域

第一个例子可能返回主标签,也可能返回备用标签,所以三个引用位置使用同一个 'a。 两个 String 的词法作用域不同,但调用只在内部代码块使用结果,编译器可以选择这段共同区域。

choose_label.rs
fn choose_label<'a>(primary: &'a str, fallback: &'a str) -> &'a str {
    if primary.trim().is_empty() {
        fallback
    } else {
        primary
    }
}

fn main() {
    let primary = String::from("priority");

    {
        let fallback = String::from("untitled");
        let selected = choose_label(&primary, &fallback);
        println!("selected: {selected}");
    }
}
selected: priority

selected 的可用区间不会超过两个输入借用的交集。 即使运行时选中了 primary,签名仍允许函数返回 fallback,所以调用方不能把结果带出内部代码块。

标注没有把 primary 缩短,也没有延长 fallback。 它只要求结果的每次使用都位于两者共同有效的区域内。

只关联真正的来源

第二个例子的结果只能来自 text,不会来自 delimiter。 签名只为文本与结果命名 'text,分隔符使用独立的省略生命周期。

prefix_before.rs
fn prefix_before<'text>(text: &'text str, delimiter: &str) -> &'text str {
    text.split_once(delimiter)
        .map_or(text, |(prefix, _)| prefix)
}

fn main() {
    let record = String::from("account=active");

    let key = {
        let delimiter = String::from("=");
        prefix_before(&record, &delimiter)
    };

    println!("key: {key}");
}
key: account

delimiter 在内部代码块结束时销毁,key 仍然有效,因为它只指向 record。 若把两个参数都标成 'text,返回值会被分隔符的短借用不必要地限制。

这种精确度对解析器和查找 API 很重要。 辅助输入只参与计算时,不应被误写成返回数据的来源。

在结构体中保存借用

Header<'source> 保存原始文本的视图,而不复制字符串。 name 的输出按方法省略规则绑定到 &selfraw 则明确返回来源生命周期 'source

header_view.rs
struct Header<'source> {
    raw: &'source str,
}

impl<'source> Header<'source> {
    fn new(raw: &'source str) -> Self {
        Self { raw }
    }

    fn name(&self) -> &str {
        self.raw
            .split_once(':')
            .map_or(self.raw, |(name, _)| name)
    }

    fn raw(&self) -> &'source str {
        self.raw
    }
}

fn main() {
    let source = String::from("content-type:text/plain");
    let header = Header::new(&source);

    println!("name: {}", header.name());
    let complete = header.raw();
    drop(header);
    println!("raw: {complete}");
}
name: content-type
raw: content-type:text/plain

complete 借自 source,不是借自结构体本身,所以移动并销毁 header 后仍能读取它。 若方法写成省略的 -> &str,返回引用通常只能保证持续到 &self 借用结束。

结构体的生命周期参数没有拥有文本。 source 仍必须比所有从它派生的视图活得更久。

要求回调接受任意短借用

最后一个例子把规范化函数应用到多个临时字符串切片。 for<'a> 要求回调对每个调用选择的 'a 都成立,并把结果关联回该次输入。

normalize_all.rs
fn normalize_all<F>(values: &[String], normalize: F) -> Vec<&str>
where
    F: for<'a> Fn(&'a str) -> &'a str,
{
    values.iter().map(|value| normalize(value)).collect()
}

fn trim_label(value: &str) -> &str {
    value.trim()
}

fn main() {
    let labels = vec![String::from(" alpha "), String::from(" beta")];
    let normalized = normalize_all(&labels, trim_label);

    println!("normalized: {normalized:?}");
}
normalized: ["alpha", "beta"]

返回向量中的每个 &str 都借自 labels 中对应的 String。 回调不能返回某个与输入无关、寿命更短的局部切片。

普通函数 trim_label 满足高阶约束,因为它能处理任意有效输入借用。 这种边界常见于保存泛型闭包并在方法内部借用自身数据的 API。

陷阱

深入 省略、边界与高阶约束

省略、边界与高阶约束

长于关系

长于约束(outlives bound) 'long: 'short 表示 'long 至少覆盖 'short。 若函数要在要求 'short 的位置返回一个 'long 引用,这个约束说明较长引用可以缩短后使用。 边界描述可替换关系,不会延长底层值。

类型边界 T: 'a 表示 T 中包含的所有引用都至少对 'a 有效。 对 &'a T 来说,类型良构本身已经隐含 T: 'a,所以现代 Rust 中许多旧式显式边界是冗余的。 trait 边界不会以同样方式全部自动推导,不能把两类规则混为一谈。

长于关系通常出现在组合多个借用字段、泛型关联类型或将较长借用交给较短接口时。 若签名只处理一个普通 &T,先检查省略和隐含边界是否已经足够。

'static 的两种读法

静态生命周期(static lifetime) 覆盖程序的整个执行期。 字符串字面量和 static 项可以产生 &'static T,因为其引用目标在程序结束前不会销毁。 这描述引用目标的有效期。

T: 'static 是类型边界,表示 T 不含短于静态期的借用。 拥有型 String 满足这个边界,却仍可在普通代码块末尾被立即销毁;边界没有承诺值本身永远存在。 相反,指向局部 String&str 通常不满足需要 'static 的线程或回调边界。

trait 对象还有默认对象生命周期规则。 在没有外层约束的类型位置,Box<dyn Trait> 通常等价于 Box<dyn Trait + 'static>;需要保存非静态借用时应明确写出合适的 + 'a+ '_。 这套默认规则不同于普通函数的三条省略规则。

对所有生命周期成立

高阶 trait 约束(higher-ranked trait bound,HRTB) 把生命周期参数放在 for<'a> 中。 F: for<'a> Fn(&'a str) -> &'a str 表示 F 必须接受任意调用时才确定的输入借用,并返回与该次输入关联的借用。

普通 F: Fn(&'a str) -> &'a str 中的 'a 往往由外层调用方选择一次。 高阶约束则允许被调用函数在每次内部借用时选择新的短生命周期,这正是对局部数据反复调用回调时需要的能力。

函数指针与闭包 trait 的常见省略形式会自动得到类似高阶含义,例如 fn(&str) -> &str 可展开为 for<'a> fn(&'a str) -> &'a str。 显式写出 for<'a> 适合复杂泛型边界,也能让审查者看出量化发生在哪里。

从诊断反推契约

E0106 通常表示输出引用的来源仍有歧义,先检查签名缺少哪条输入输出关系。 E0515 常指向返回了局部数据的引用,这种错误不能靠增加任意生命周期参数修好。 E0597 则表示某次实际调用让被借用值过早销毁,需要调整使用区间或所有权。

修复顺序应从数据来源开始:确认输出指向谁,再确定调用方需要使用多久,最后写最小约束。 若没有任何输入或字段能拥有返回数据,就改为返回拥有型值。 只有契约本身正确后,缩短借用区间、重借用或拆分结构才有意义。

不要用 unsafe 把编译错误变成无法检查的承诺。 生命周期诊断往往暴露了真实的所有权缺口;绕开检查只会把失败从编译期移动到未定义行为。

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

前置内容 所有权借用规则
下一篇 生命周期 闭包 Trait bounds 即将上线 字符串
复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?