首页
/ Rustlings 16_lifetimes 模块详解:用生命周期标注让 Rust 编译器验证引用有效性

Rustlings 16_lifetimes 模块详解:用生命周期标注让 Rust 编译器验证引用有效性

2026-09-04 19:52:46作者:管翌锬

本篇技术指南围绕 Rustlings 的 16_lifetimes 练习模块 展开,核心主题是 Rust 的生命周期(lifetime):编译器如何检查一个引用在其被使用期间是否始终有效、生命周期标注 <'a> 应当写在哪里,以及如何通过调整作用域修复"引用先于其指向的数据失效"的错误。读完本文,你可以独立读懂并修复 longest 函数、结构体持引用两类典型的生命周期编译错误,并理解 Rustlings 是如何组织与校验这一组练习的。

生命周期解决什么问题:保证"借来的数据"活得足够久

Rust 中的引用(&T)是借用而非所有权,它本身不拥有数据。这就引出一个核心问题:如果引用所指向的属主提前离开作用域被释放,这个引用就成了悬空引用。生命周期正是为编译器提供验证手段的机制——它告诉编译器:在任意给定场景下,各引用是否存活得足够久以使其使用合法。

模块 README 对生命周期给出了三条关键定性,这里逐条展开:

  1. 生命周期约束的是"相对存活时长",而非具体秒数或时刻。 典型的表达是"确保参数 a 至少和参数 b 活得一样久,这样返回值才有效"。生命周期标注本身并不延长任何数据的存活时间,它只是向编译器声明各引用之间的存活关系,供其做合法性检查。
  2. 生命周期只与借用(引用)相关。 通过拷贝(Copy)或移动(move)传递的参数在其作用域内是拥有所有权的,作用域结束时统一释放,不存在"被外部引用"的问题,因此无需生命周期标注。需要标注的位置只有一类:类型中出现引用的地方(函数参数、返回值、结构体字段等)。
  3. 生命周期限制的是调用方(callers),而不是被调用的函数本身。 一个带生命周期标注的函数签名,等价于对调用方提出要求:你传入的实参引用必须满足签名声明的存活关系,编译器会在每次调用处检查这一点。函数内部的逻辑不受影响,受约束的是"谁能以什么方式调用它"。

README 还给出两条延伸学习建议:官方的《The Rust Book》第 10.3 章"用生命周期验证引用"是该主题的权威讲解;此外还有一个名为 lifetimekata 的项目,与 Rustlings 风格相似但专门训练"书写生命周期标注"这一项技能,适合在本模块练完后继续加深。

16_lifetimes 模块的结构与校验方式

该模块包含 3 个递进式练习,全部位于 exercises/16_lifetimes 目录下,对应答案在 solutions/16_lifetimes 下逐文件对应:

练习 文件 考察点 是否运行单元测试
lifetimes1 lifetimes1.rs 给返回引用的函数补上生命周期标注 是(自带 #[cfg(test)] 测试模块)
lifetimes2 lifetimes2.rs 调整作用域,让实参满足签名声明的存活关系 否(仅要求编译通过)
lifetimes3 lifetimes3.rs 给持有引用的结构体补上生命周期标注 否(仅要求编译通过)

"是否运行测试"这一点由 rustlings-macros/info.toml 中的练习元数据决定。LIFETIMES 段落(info.toml 第 812 行起)为三个练习分别定义了 namedirhint;其中 lifetimes2lifetimes3 显式设置了 test = false,意味着这两个练习只要编译通过即算完成,而 lifetimes1 未关闭测试,其内部 #[test] 用例必须全部通过。

从源码结构看,每个练习在构建期都被注册为独立的二进制目标:dev/Cargo.toml 中第 131~136 行把 lifetimes1lifetimes2lifetimes3 三个文件(以及对应的 *_sol 参考答案)各自登记为一个 bin。因此 Rustlings 的校验流程是:对当前练习对应的二进制执行编译(有测试的练习还会执行 cargo test),编译并测试全部通过后进入下一题。这也是为什么每个练习文件都必须是一个能独立编译、独立运行的完整程序(带 fn main)。

info.toml 中还为每个练习内置了提示(在 Rustlings 中按 h 查看)。以 lifetimes2 的提示为例,它点出了本模块最关键的一条推断规则:泛型生命周期 'a 最终会被编译器取值为 xy 两者中较短的那个存活期。这条规则贯穿下面全部三个练习。

练习一:给返回引用的函数补上生命周期标注(lifetimes1)

题目代码

lifetimes1.rs 的原始代码中,longest 函数在两个引用参数中返回较长的那个:

// The Rust compiler needs to know how to check whether supplied references are
// valid, so that it can let the programmer know if a reference is at risk of
// going out of scope before it is used. Remember, references are borrows and do
// not own their own data. What if their owner goes out of scope?

// TODO: Fix the compiler error by updating the function signature.
fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

文件尾部自带测试模块(lifetimes1.rs 第 19~28 行),这也是本练习需要"通过测试"而不仅仅是"编译通过"的原因:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_longest() {
        assert_eq!(longest("abcd", "123"), "abcd");
        assert_eq!(longest("abc", "1234"), "1234");
    }
}

为什么原签名会编译失败

函数体只是原样返回 xy 本身,返回值必然"绑定"在某个输入引用上。但签名 fn longest(x: &str, y: &str) -> &str 中,参数与返回值的引用都没有标注任何生命周期——编译器为每个未标注的引用各自推断出一个独立的生命周期,于是无法建立"返回值与某个参数等长"的关系,也就无法向调用方保证返回的引用在使用期间一定有效。

修复方案

对照 solutions/16_lifetimes/lifetimes1.rs 的参考答案,只需要改一行——在函数名后声明 'a,并把参数与返回值上的引用都标注为 &'a str

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    //    ^^^^     ^^          ^^          ^^
    if x.len() > y.len() { x } else { y }
}

解读这段标注的语义:

  • <'a> 声明了一个名为 'a生命周期参数,语法上与泛型类型参数并列,写法上以 ' 开头;
  • 两个参数都写作 &'a str,声明"x 和 y 的存活期都不短于 'a";
  • 返回值 &'a str 声明"返回的引用存活期至少为 'a";
  • 结合前文提到的推断规则,'a 在每次调用时会被具体化为 xy 两者存活期的较短者。于是返回值至多存活到两个入参中较早结束的那个——这恰好与函数体"要么返回 x、要么返回 y"的事实吻合,检查通过。

注意函数体内不需要任何额外标注,也不能通过修改函数体来"骗过"编译器:生命周期标注是签名层面的契约,函数体只是恰好满足了这份契约。

练习二:用作用域修复"引用活得比数据久"(lifetimes2)

题目代码

lifetimes2.rs 中,longest 已经是标注好的版本(题目要求"不要改动这个函数"),需要修复的是 main 里的作用域安排:

// Don't change this function.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    // TODO: Fix the compiler error by moving one line.

    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(&string1, &string2);
    }
    println!("The longest string is '{result}'");
}

错误本质

result 的类型是 &'a str,其 'a 在调用时被推断为 string1string2 存活期的较短者。而 string2 在内层代码块结束时就被释放,result 却在第 19 行的 println! 里被使用——此时 result 可能指向已释放的 string2,这正是生命周期机制要拦截的典型悬空引用。

两种修复方式

参考 solutions/16_lifetimes/lifetimes2.rs 给出的两种等价方案(文件注释明确标注了 Solution 1Solution 2):

方案一:把 string2 移出内层块,让它的存活期覆盖到打印语句。

let string1 = String::from("long string is long");
// Solution 1: You can move `strings2` out of the inner block so that it is
// not dropped before the print statement.
let string2 = String::from("xyz");
let result;
{
    result = longest(&string1, &string2);
}
println!("The longest string is '{result}'");
// `string2` dropped at the end of the function.

此时 string1string2 都存活到函数末尾,result 指向的数据在 println! 执行时必然有效,函数结束时两者一起释放。

方案二:把 println! 移入内层块,让使用发生在 string2 释放之前。

let string1 = String::from("long string is long");
let result;
{
    let string2 = String::from("xyz");
    result = longest(&string1, &string2);
    // Solution 2: You can move the print statement into the inner block so
    // that it is executed before `string2` is dropped.
    println!("The longest string is '{result}'");
    // `string2` dropped here (end of the inner scope).
}

两条路径的共同点在于:没有修改任何生命周期标注,而是让数据的实际存活期满足签名已经声明的契约。这正是 README 所说"生命周期限制的是调用方"的直接体现——被调用函数 longest 一字未动,被调整的是调用侧的作用域组织。另外注意 result 的声明位置(let result;)必须保持在块外,否则方案二就失去了意义。

由于该练习设置了 test = false(见 info.toml),完成标准是程序能编译并正常运行;运行后应看到 The longest string is 'long string is long'

练习三:给持有引用的结构体标注生命周期(lifetimes3)

题目代码

lifetimes3.rs 展示了生命周期在第二类必须出现的位置——结构体字段:

// Lifetimes are also needed when structs hold references.

// TODO: Fix the compiler errors about the struct.
struct Book {
    author: &str,
    title: &str,
}

fn main() {
    let book = Book {
        author: "George Orwell",
        title: "1984",
    };

    println!("{} by {}", book.title, book.author);
}

一个持有 &str 字段的 Book 实例,其"有效性"取决于两处引用的有效性。缺少生命周期标注时,编译器同样无法判断 book 存活期间这两个引用是否始终有效。

修复方案

对照 solutions/16_lifetimes/lifetimes3.rs,修复方式与函数签名完全同构:结构体名后加 <'a>,两个引用字段都标注为 &'a str

struct Book<'a> {
    //     ^^^^ added a lifetime annotation
    author: &'a str,
    //       ^^
    title: &'a str,
    //      ^^
}

标注后的语义是:Book<'a> 实例的存活期不能长于其引用的两处 str 数据;main 中使用的都是字符串字面量(其存活期为 'static,即覆盖整个程序运行期),自然满足该约束,因此 book 的创建与打印均可通过检查。这里也体现了一条实用结论:如果结构体持有的是字面量或静态数据,标注 'a 后几乎无额外代价;但持有局部数据的引用时,结构体实例的作用域就必须控制在被引用数据的作用域之内——与练习二的原理完全一致。

生命周期标注速查:'a 到底写在哪

把三个练习归纳起来,Rustlings 本模块覆盖了生命周期标注的两个"必写位置":

位置 写法 对应练习
函数:参数、返回值出现 &str 且需表达相对存活关系 fn longest<'a>(x: &'a str, y: &'a str) -> &'a str lifetimes1
结构体:字段为引用类型 struct Book<'a> { author: &'a str, title: &'a str } lifetimes3
调用方:让实参的实际存活期满足签名契约 调整 let 声明或使用的相对位置 lifetimes2

三条配套的推断规则(均可由本模块的练习直接验证):

  1. 未标注的生命周期参数会各自独立推断,无法建立跨参数约束——这就是 lifetimes1 原签名失败的原因;
  2. 多个参数共用同一个 'a 时,编译器取较短者作为 'a 的具体值——lifetimes2 提示语中明确写了这一点;
  3. 标注是契约声明而非数据延长手段,函数体与结构体定义都不因标注而改变行为,能修复问题的只有调整作用域(数据存活期)或调整契约本身(签名标注)

小结

Rustlings 的 16_lifetimes 模块用三道题完整演示了生命周期的作用链条:lifetimes1 让你写出标注、lifetimes2 让你站在调用方立场理解"标注限制了谁"、lifetimes3 把标注延伸到结构体字段。三道题的解题材料都能在仓库内找到——题目在 exercises/16_lifetimes/ 下,带逐行注释的参考答案在 solutions/16_lifetimes/ 下,各题的完成标准(是否跑测试)与内置提示定义在 rustlings-macros/info.toml。按"读编译器报错 → 对照本模块三题模式 → 修改签名或作用域"的顺序推进,即可掌握 Rust 中引用有效性检查的核心机制,并为后续《The Rust Book》第 10.3 章的系统学习打下实操基础。

登录后查看全文
热门项目推荐
相关项目推荐