Rustlings 16_lifetimes 模块详解:用生命周期标注让 Rust 编译器验证引用有效性
本篇技术指南围绕 Rustlings 的 16_lifetimes 练习模块 展开,核心主题是 Rust 的生命周期(lifetime):编译器如何检查一个引用在其被使用期间是否始终有效、生命周期标注 <'a> 应当写在哪里,以及如何通过调整作用域修复"引用先于其指向的数据失效"的错误。读完本文,你可以独立读懂并修复 longest 函数、结构体持引用两类典型的生命周期编译错误,并理解 Rustlings 是如何组织与校验这一组练习的。
生命周期解决什么问题:保证"借来的数据"活得足够久
Rust 中的引用(&T)是借用而非所有权,它本身不拥有数据。这就引出一个核心问题:如果引用所指向的属主提前离开作用域被释放,这个引用就成了悬空引用。生命周期正是为编译器提供验证手段的机制——它告诉编译器:在任意给定场景下,各引用是否存活得足够久以使其使用合法。
模块 README 对生命周期给出了三条关键定性,这里逐条展开:
- 生命周期约束的是"相对存活时长",而非具体秒数或时刻。 典型的表达是"确保参数
a至少和参数b活得一样久,这样返回值才有效"。生命周期标注本身并不延长任何数据的存活时间,它只是向编译器声明各引用之间的存活关系,供其做合法性检查。 - 生命周期只与借用(引用)相关。 通过拷贝(Copy)或移动(move)传递的参数在其作用域内是拥有所有权的,作用域结束时统一释放,不存在"被外部引用"的问题,因此无需生命周期标注。需要标注的位置只有一类:类型中出现引用的地方(函数参数、返回值、结构体字段等)。
- 生命周期限制的是调用方(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 行起)为三个练习分别定义了 name、dir 与 hint;其中 lifetimes2 和 lifetimes3 显式设置了 test = false,意味着这两个练习只要编译通过即算完成,而 lifetimes1 未关闭测试,其内部 #[test] 用例必须全部通过。
从源码结构看,每个练习在构建期都被注册为独立的二进制目标:dev/Cargo.toml 中第 131~136 行把 lifetimes1、lifetimes2、lifetimes3 三个文件(以及对应的 *_sol 参考答案)各自登记为一个 bin。因此 Rustlings 的校验流程是:对当前练习对应的二进制执行编译(有测试的练习还会执行 cargo test),编译并测试全部通过后进入下一题。这也是为什么每个练习文件都必须是一个能独立编译、独立运行的完整程序(带 fn main)。
info.toml 中还为每个练习内置了提示(在 Rustlings 中按 h 查看)。以 lifetimes2 的提示为例,它点出了本模块最关键的一条推断规则:泛型生命周期 'a 最终会被编译器取值为 x 与 y 两者中较短的那个存活期。这条规则贯穿下面全部三个练习。
练习一:给返回引用的函数补上生命周期标注(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");
}
}
为什么原签名会编译失败
函数体只是原样返回 x 或 y 本身,返回值必然"绑定"在某个输入引用上。但签名 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在每次调用时会被具体化为x、y两者存活期的较短者。于是返回值至多存活到两个入参中较早结束的那个——这恰好与函数体"要么返回 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 在调用时被推断为 string1、string2 存活期的较短者。而 string2 在内层代码块结束时就被释放,result 却在第 19 行的 println! 里被使用——此时 result 可能指向已释放的 string2,这正是生命周期机制要拦截的典型悬空引用。
两种修复方式
参考 solutions/16_lifetimes/lifetimes2.rs 给出的两种等价方案(文件注释明确标注了 Solution 1 与 Solution 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.
此时 string1 与 string2 都存活到函数末尾,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 |
三条配套的推断规则(均可由本模块的练习直接验证):
- 未标注的生命周期参数会各自独立推断,无法建立跨参数约束——这就是 lifetimes1 原签名失败的原因;
- 多个参数共用同一个
'a时,编译器取较短者作为'a的具体值——lifetimes2 提示语中明确写了这一点; - 标注是契约声明而非数据延长手段,函数体与结构体定义都不因标注而改变行为,能修复问题的只有调整作用域(数据存活期)或调整契约本身(签名标注)。
小结
Rustlings 的 16_lifetimes 模块用三道题完整演示了生命周期的作用链条:lifetimes1 让你写出标注、lifetimes2 让你站在调用方立场理解"标注限制了谁"、lifetimes3 把标注延伸到结构体字段。三道题的解题材料都能在仓库内找到——题目在 exercises/16_lifetimes/ 下,带逐行注释的参考答案在 solutions/16_lifetimes/ 下,各题的完成标准(是否跑测试)与内置提示定义在 rustlings-macros/info.toml。按"读编译器报错 → 对照本模块三题模式 → 修改签名或作用域"的顺序推进,即可掌握 Rust 中引用有效性检查的核心机制,并为后续《The Rust Book》第 10.3 章的系统学习打下实操基础。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00