Rustlings Move Semantics 实战:用五道递进练习吃透 Rust 的所有权、移动与借用
Rust 的所有权系统(Ownership)是语言最核心也最劝退新人的概念,rustlings 仓库中的 exercises/06_move_semantics 章节正是围绕这一主题设计的五道递进练习(move_semantics1~5)。本文以该章节的 README 与五道练习文件为主体,结合 solutions/06_move_semantics 中的官方解法、rustlings-macros/info.toml 中每道练习内置的 hint 文本,以及 rustlings 运行器源码(src/exercise.rs),完整拆解每道练习的报错原因、修改思路与背后原理,帮助读者真正掌握“移动语义 + 借用规则”的决策方法。
一、章节定位:README 说明了什么
exercises/06_move_semantics/README.md 篇幅很精炼,但给出了两条关键信息:
- 本章练习改编自 pnkfelix 的 Rust Tutorial(ICFP 2014 教程),说明这些题目是经过多年教学验证的经典题型;
- 本章配套 Rust 官方文档(The Rust Book)的两个章节:Ownership(所有权) 与 References and borrowing(引用与借用)。仓库的 exercises/README.md 中也确认了这一映射:
move_semantics对应书中 §4.1–4.2。
因此本章的学习路径非常明确:先把所有权模型讲清楚——每个值有且只有一个所有者;所有者离开作用域时值被释放;把值传给函数或函数返回时发生“移动”(move),原绑定不可再用;再看借用如何避免不必要的移动——不可变引用 &T 可以多个并存,可变引用 &mut T 同一时刻只能有一个,且二者不能与前者并存。
本章共五道练习,难度层层递进:
| 练习 | 文件 | 核心考点 |
|---|---|---|
| move_semantics1 | move_semantics1.rs | 可变绑定与 push 的编译错误 |
| move_semantics2 | move_semantics2.rs | “borrow of moved value” 与 clone |
| move_semantics3 | move_semantics3.rs | 直接声明参数为 mut |
| move_semantics4 | move_semantics4.rs | 可变引用的排他使用与借用检查 |
| move_semantics5 | move_semantics5.rs | 按引用传参与按值传参(取所有权)的选择 |
二、rustlings 如何驱动这些练习
理解运行方式有助于把报错信息对号入座。从 dev/Cargo.toml 可以看到,每道练习及其解法都被注册为独立的 bin target:
{ name = "move_semantics1", path = "../exercises/06_move_semantics/move_semantics1.rs" },
{ name = "move_semantics1_sol", path = "../solutions/06_move_semantics/move_semantics1.rs" },
# ……move_semantics2~5 同理
其中四个练习(1~4)内部带 #[cfg(test)] 测试,第 5 题则是一个可执行程序(main 直接运行)。rustlings 的 src/exercise.rs 中 Exercise 结构体携带了 name、dir、path、test、strict_clippy、hint 等字段,与 rustlings-macros/info.toml 中的 [[exercises]] 配置一一对应——hint 文本正是 watch 模式下按 h 显示的那段提示。
实际操作上:克隆仓库并初始化后运行 rustlings 进入 watch 模式,修改 exercises/ 下当前练习文件即会自动重新编译运行;按 l 可打开练习列表,用 c 跳到任意练习、r 重置某道练习的文件与状态。更多键位说明见 website/content/usage/index.md。卡住时按 h 查看官方 hint 是本章官方推荐的用法(例如 move_semantics4 的 hint 直接提示读者去分析每个可变引用的作用域区间)。
三、move_semantics1:从“不能可变借用”开始
题目代码(exercises/06_move_semantics/move_semantics1.rs):
// TODO: Fix the compiler error in this function.
fn fill_vec(vec: Vec<i32>) -> Vec<i32> {
let vec = vec;
vec.push(88);
vec
}
fn main() {
// You can optionally experiment here.
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn move_semantics1() {
let vec0 = vec![22, 44, 66];
let vec1 = fill_vec(vec0);
assert_eq!(vec1, vec![22, 44, 66, 88]);
}
}
报错发生在 vec.push(88):cannot borrow vec as mutable, as it is not declared as mutable。这里 vec 是按值传入的 Vec<i32>,函数体内 let vec = vec; 又新建了一个不可变绑定,push 需要 &mut self,因此编译失败。
官方解法(solutions/06_move_semantics/move_semantics1.rs)只加了一个关键字:
fn fill_vec(vec: Vec<i32>) -> Vec<i32> {
let mut vec = vec;
// ^^^ added
vec.push(88);
vec
}
info.toml 中这道题的 hint 还给了一个进阶思考方向:“试着在调用 fill_vec() 之后访问 vec0,看看会发生什么”——因为 fill_vec(vec0) 把 vec0 的所有权移走了,调用后再使用 vec0 会触发下一题的报错。
四、move_semantics2:“借用已移动的值”与 clone
题目要求让 vec0 和 vec1 同时可用(exercises/06_move_semantics/move_semantics2.rs):
fn fill_vec(vec: Vec<i32>) -> Vec<i32> {
let mut vec = vec;
vec.push(88);
vec
}
#[cfg(test)]
mod tests {
use super::*;
// TODO: Make both vectors `vec0` and `vec1` accessible at the same time to
// fix the compiler error in the test.
#[test]
fn move_semantics2() {
let vec0 = vec![22, 44, 66];
let vec1 = fill_vec(vec0);
assert_eq!(vec0, [22, 44, 66]); // 错误:borrow of moved value: `vec0`
assert_eq!(vec1, [22, 44, 66, 88]);
}
}
fill_vec(vec0) 按值传参,vec0 的所有权随调用移入函数,之后的 assert_eq!(vec0, ...) 是对已移动值的借用,编译器直接拒绝。官方 hint(rustlings-macros/info.toml)原样解释了这一点:
In Rust, when an argument is passed to a function and it's not explicitly returned, you can't use the original variable anymore. We call this "moving" a variable. … You could make another, separate version of the data that's in
vec0and pass it tofill_vecinstead. This is called cloning in Rust.
官方解法(solutions/06_move_semantics/move_semantics2.rs):
// Cloning `vec0` so that the clone is moved into `fill_vec`, not `vec0` itself.
let vec1 = fill_vec(vec0.clone());
Vec<i32> 没有实现 Copy,因此按值传递就是移动;clone() 产生一份完整拷贝,拷贝被移走而 vec0 本体安然无恙。这引出所有权的第一条决策原则:只有“不再需要原值”时才让它被移动;需要原值则考虑 clone(有堆内存复制成本)或借用(无成本但受借用规则约束)。
五、move_semantics3:让参数本身可变,少写一行
题目(exercises/06_move_semantics/move_semantics3.rs)刻意删掉了第 1 题解法中的 let mut vec = vec;,并要求不新增任何一行来修复错误:
// TODO: Fix the compiler error in the function without adding any new line.
fn fill_vec(vec: Vec<i32>) -> Vec<i32> {
vec.push(88);
vec
}
此时 push 仍然失败,因为函数参数 vec 默认是不可变绑定。hint 的提示是:“与其把 let mut vec = vec; 加回来,不如在某个位置加上 mut,把一个既有绑定从不可变改成可变绑定”。解法(solutions/06_move_semantics/move_semantics3.rs):
fn fill_vec(mut vec: Vec<i32>) -> Vec<i32> {
// ^^^ added
vec.push(88);
vec
}
在参数声明处直接写 mut vec 是更地道的 Rust 写法:参数本身就是一个绑定,直接声明为可变即可,省去了“用同名不可变绑定遮蔽自己”的冗余代码。这道题顺带强化了 Rust 的通用规则——绑定默认不可变,mut 必须显式声明,这与很多动态/垃圾回收语言“变量默认可变”的直觉正好相反。
六、move_semantics4:可变引用同一时刻只能有一个
前四题的主角还是所有权,第 4 题(exercises/06_move_semantics/move_semantics4.rs)进入借用规则的正题,且约束苛刻:只能交换行序,不能增删或修改任何一行:
#[test]
fn move_semantics4() {
let mut x = Vec::new();
let y = &mut x;
let z = &mut x; // 错误:cannot borrow `x` as mutable more than once
y.push(42);
z.push(13);
assert_eq!(x, [42, 13]);
}
Rust 的借用检查器规定:对同一变量的可变引用,同一时刻只能存在一个(防止数据竞争与别名写冲突)。原代码中 y 和 z 两个 &mut x 在创建时并存,即使后续是先后使用,也在编译期被判非法。
解法利用的是 NLL(Non-Lexical Lifetimes,非词法生命周期) 对借用“结束点”的精确分析——只要前一个可变引用最后一次被使用之后才创建下一个,两者生命周期就不再重叠。官方解法(solutions/06_move_semantics/move_semantics4.rs):
let mut x = Vec::new();
let y = &mut x;
// `y` used here.
y.push(42);
// The mutable reference `y` is not used anymore,
// therefore a new reference can be created.
let z = &mut x;
z.push(13);
assert_eq!(x, [42, 13]);
注意 y 最后一次使用在 y.push(42),let z = &mut x; 发生在其后,借用区间自然错开。info.toml 中该题的 hint 也点题了“Carefully reason about the range in which each mutable reference is in scope”(rustlings-macros/info.toml)。这道题的实际意义在于:把“借用规则是词法作用域”这一常见误解纠正为“借用生命周期以最后一次使用为准”,这是阅读复杂 Rust 代码时判断借用冲突的关键心智模型。
七、move_semantics5:借用来读、按值来改——按语义选择传参方式
第 5 题是本章的综合题(exercises/06_move_semantics/move_semantics5.rs),约束是只能增删 & 字符,其余不许改。题目中两个函数的注释直接标出了语义意图:
#![allow(clippy::ptr_arg)]
// Shouldn't take ownership
fn get_char(data: String) -> char { // 注释说“不该”取所有权,签名却按值
data.chars().last().unwrap()
}
// Should take ownership
fn string_uppercase(mut data: &String) { // 注释说“应该”取所有权,签名却是引用
data = data.to_uppercase();
println!("{data}");
}
fn main() {
let data = "Rust is great!".to_string();
get_char(data);
string_uppercase(&data);
}
问题在于两处“语义与签名错位”:
get_char只读最后一个字符,却按值拿走data,导致data被移动;string_uppercase内部执行data = data.to_uppercase();是替换绑定,必须持有所有权才能完成,签名却只是&String——对不可变引用根本赋不了值(即使改成&mut也不能把String赋回引用指向的位置,因为引用指向的是String本体而非绑定本身)。
官方解法(solutions/06_move_semantics/move_semantics5.rs)把 & 放到正确的一边:
// Borrows instead of taking ownership.
// It is recommended to use `&str` instead of `&String` here. But this is
// enough for now because we didn't handle strings yet.
fn get_char(data: &String) -> char {
data.chars().last().unwrap()
}
// Takes ownership instead of borrowing.
fn string_uppercase(mut data: String) {
data = data.to_uppercase();
println!("{data}");
}
fn main() {
let data = "Rust is great!".to_string();
get_char(&data);
string_uppercase(data);
}
两个调用顺序也体现了所有权模型:get_char(&data) 只是借用,data 之后仍然可用;string_uppercase(data) 把它移走作为最后一步,顺序颠倒会编译失败。解法注释还顺带埋了一个进阶知识点:更惯用的写法是 &str(字符串切片引用)而非 &String——不过本章还没涉及 strings 章节,暂不展开。这道题给出的实用结论是:判断一个参数该借用还是按值传递,看函数是否需要“改走”这个值本身——只读取就借用(零成本),要重新绑定/转移所有权就按值传入。
八、小结与验证路径
把五道练习串起来,正好构成所有权学习的完整阶梯:
- move_semantics1:绑定默认不可变,
mut显式声明; - move_semantics2:按值传参即移动,需要原值时
clone(); - move_semantics3:参数本身就是绑定,直接
mut声明更简洁; - move_semantics4:可变引用排他 + 借用生命周期以最后使用为准(NLL);
- move_semantics5:按“是否要拿走值”决定传引用还是传所有权。
验证与自查资源都在仓库内:每道题先让 rustlings watch 模式复现编译错误,再对照 solutions/06_move_semantics 下的解法(rustlings 在提示完成时会输出“Solution for comparison”一行的解法路径,实现见 src/exercise.rs 的 solution_link_line);提示不够用时按 h 读取 rustlings-macros/info.toml 中为每道题准备的 hint 文本。理论部分建议按 README 指引精读 Rust 官方书第 4.1(Ownership)与 4.2(References and borrowing)两节——本章五道练习几乎可以逐条对应到这两节的规则上,这也是 rustlings 用“最小可复现错误”来教学所有权的设计初衷。
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