本课目标:
不再靠「试出来的」.clone() 过编译。学完你应该能看着一个签名说出「这个函数会拿走我的东西」,并在写代码前先决定好谁拥有谁。
本课是 Rust 的分水岭。前面是语法,从这里开始才是 Rust。建议一次只读一半,中间一定要动手把练习做完,否则第三节讲 Option/Result 时你会同时被两层概念压垮。
一、先建立心智模型:一句话规则
每个值有且只有一个所有者;所有者离开作用域,值被丢弃。
就这一句。后面所有的报错------moved value、borrowed value does not live long enough、cannot return reference to local variable------全是这一句的推论。
三个推论:
- 赋值、传参、返回,默认都是「移动」(move),不是拷贝。 let b = a; 之后 a 就废了。
- 想让别人「看一眼」而不拿走,就借出去(&T / &mut T)。 借的东西必须还,而且还在期间原主不能乱动。
- 有些类型天生可以随便抄(Copy)。 它们是这条规则的例外,也是新手最容易混淆的地方。
关键认知切换:
在其他语言里你默认「变量就是值」;在 Rust 里你要养成习惯问自己------这行之后,这个值还在不在原来的变量里? 前三十次你会答错,没关系,编译器会纠正你。
二、先撞墙:三段代码,别急着看解释
撞墙 1:移动
rust
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{}", s1); // ← ?
}
报错 E0382: borrow of moved value。注意措辞:borrow of moved value------错在第 3 行,但病根在第 2 行。第 2 行执行完,s1 已经是个空壳了。
对比一下这个:
rust
fn main() {
let n1 = 42i32;
let n2 = n1;
println!("{}", n1); // ← 这行居然能过
}
同样的写法,一个死一个活。区别不在语法,在类型有没有实现 Copy。 这就是下面第三节的重点。
撞墙 2:借用
rust
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
let r3 = &mut s; // ← ?
println!("{} {} {}", r1, r2, r3);
}
E0502: cannot borrow 's' as mutable because it is also borrowed as immutable。
规则:可变借用和不可变借用不能同时存在。 更精确的说法是:同一时刻,要么任意多个 &T,要么恰好一个 &mut T。
把 println! 挪到 r3 之前呢?能过------因为 r1、r2 在最后一次使用之后就「死」了,借用提前结束(这是 NLL,非词法生命周期,Rust 1.31 就有的特性,老教程说「借用要到作用域结束才结束」已经过时了)。
撞墙 3:悬垂引用
rust
fn make_ref() -> &String {
let s = String::from("hello");
&s // ← ?
}
E0515: cannot return value referencing local variable 's'。函数结束时 s 被 drop,返回的指针指向已释放的内存------这在 C 里是未定义行为,在 Rust 里直接编译失败。这是 Rust 用类型系统白送你的安全性,代价就是得学会跟它谈判。
三、三条规则 + 一个例外
规则一:移动(move)
发生移动的场合只有四个,背下来:
场合 例子
赋值 let b = a;
传参 fn f(s: String) {} / f(a)
返回 fn f() -> Vec { v }
模式匹配解构 let Some(x) = opt(对非 Copy 类型)
哪些类型会移动? 几乎所有「 owning 」类型:String、Vec、Box、HashMap、PathBuf、你自己定义的 struct(除非全字段都是 Copy)。
哪些不会? 所有标量(整数、浮点、bool、char)、元组/数组(当元素全是 Copy 时)、以及引用本身 &T。最后一条很重要:let r2 = r1; 永远不会让 r1 失效,因为引用只是指针,是 Copy 的。
规则二:借用(borrow)
fn greet(name: &str) -> String { ... } // 只读,不抢
fn append(s: &mut String) { ... } // 要改,但不占
fn take(s: String) -> String { ... } // 我要 ownership,可能还回来(返回值)
这三档签名的选择,是你每天要做几十次的决策。 一个极简的决策树:
函数需要修改它吗? ──是──> &mut T
│否
▼
函数需要拥有它吗(存起来 / 线程间送走 / 返回部分)? ──是──> T
│否
▼
&T
「需要拥有」的典型信号:要把值塞进 struct 字段、要 spawn 进线程、要放进 Vec 长期保存、要返回它的一部分(此时必须拥有,否则返回的是指向局部量的引用------见撞墙 3)。
规则三:可变性不是传递的
let mut x = ... 只影响「我能不能通过 x 这个名字改」,不影响「别人能不能借走」。反过来,&mut T 本身就隐含了独占性。
还有两个易混点:
- &mut T 不能同时存在两个,但可以「重借用」(reborrow):fn f(s: &mut String) { g(s); } 这里传给 g 的不是 move,而是从 s 临时借出一个新的 &mut。这就是为什么传进去还能继续用。
- move 之后原变量不是「空指针」,是被标记为不可用。 编译器静态跟踪,运行时没有开销。
例外:Copy
Copy 是一个 marker trait,含义是「按位拷贝是安全的」。实现它的类型,赋值和传参变成拷贝而非移动,原变量照常可用。
判断口诀:如果类型的值完全存在栈上、且没有「指向外部资源」的指针,它大概率是 Copy。
可以自己加:
rust
#[derive(Clone, Copy)]
struct Point { x: i32, y: i32 }
注意:Copy 要求所有字段都是 Copy,且 Copy 隐含 Clone(所以 derive 时要写 Clone, Copy,顺序无所谓但习惯上 Clone 在前)。一旦给 struct 加了个 String 字段,Copy 立刻推导失败------编译器会明说。
Clone vs Copy:Copy 是隐式、免费、按位的;Clone 是显式(.clone())、可能很贵(深拷贝堆数据)。所以 .clone() 的正确定位是「我知道我在多分配一次,但我接受」,而不是「让编译器闭嘴的咒语」。
四、切片:不拷贝就能「看一部分」
切片 &T / &str 是所有权模型里最实用的工具:它是一个胖指针(指针 + 长度),指向别人的数据,不拥有、不拷贝。
String vs &str
rust
let s: String = String::from("hello");
let slice: &str = &s; // 借用整个
let sub: &str = &s[0..2]; // 借一部分,"he"
三个必须知道的事实:
- &str 是 UTF-8 字节切片,索引按字节不按字符。"你好"0...1 会 panic(边界切在码点中间)。遍历请用 .chars()。
- 字符串字面量 "hello" 的类型就是 &'static str,不是 String。这是初学者最常见的 expected String, found &str 来源。
- Deref coercion 会让 &String 自动退化成 &str,所以 fn f(s: &str) 可以传 &my_string。这也是「参数用 &str 更通用」的原因。
Vec vs &T
同理,&T 比 &Vec 通用------数组、Vec、其他切片都能传。clippy 的 ptr_arg lint 专门抓这个。
一个经典陷阱
rust
fn first_word(s: &String) -> &str {
&s[0..1] // 中文输入下直接崩
}
正确做法是 .chars().take(1).collect::()(这会分配)或者明确文档约定「只处理 ASCII」。这里没有免费的午餐:安全 ≠ 不会 panic,= panic 只发生在明确的边界条件下。
五、报错速查表(建议收藏)
错误码 人话 首选解法
E0382 borrow of moved value 值被移走了还在用 改签名传引用;或确认是否真需要再用
E0507 cannot move out of *x which is behind a shared reference 想从引用背后把值抠走 .clone()、std::mem::take、或改用 Copy 类型
E0502 / E0499 可变+不可变借用冲突 读写同时存在 缩小借用作用域;先 let v = ... 把中间结果算完再借
E0596 cannot borrow as mutable, as it is not declared as mutable 忘写 mut 加 mut(注意:&mut 和 mut 是两回事)
E0597 / E0515 借用活得比所有者长 / 返回局部量引用 悬垂引用 改为返回 owned 类型(String/Vec);或把数据提到外层作用域
E0505 cannot move out because it is borrowed 借用期间想 move 走所有者 先结束借用(作用域/clone),再 move
E0506 cannot assign because it is borrowed 借用期间想覆盖所有者 同上
E0384 cannot assign twice to immutable variable 忘写 mut 加 mut
通用调试套路:
遇到借用错误,先在纸上画两条线------谁拥有这块数据?这个引用活到第几行? 90% 的情况画完就明白了。画图比瞎改快。
六、反模式清单(本课专属)
- 满屏 .clone() ------ 所有权没吃透的体征。允许临时用,但每处都要标 TODO。
- 参数写 String / Vec 却只读 ------ 改成 &str / &T。
- 参数写 &Vec / &String ------ 改成 &T / &str。
- 返回 String 却返回了 &str 的字面量 ------ 反过来,该 owned 就 owned。
- 用多个 bool 字段表达互斥状态 ------ 用 enum(下节课的主题,先提一句)。
- let mut 滥用:能不可变就不可变,可变性越窄越好。
- 为了绕过借用检查器上 Rc<RefCell> ------ 这是入门期最大的过早抽象,第一周绝对不要用。
七、练习
每题都配了测试桩,cargo test 全绿即通过。每题只考一个维度,做不出来就回去看速查表对应的错误码。
练习 1(填空,Copy 判断)
下面哪些类型是 Copy?先凭直觉圈,再写代码验证(尝试 let b = a; println!("{}", a);):
i32、f64、bool、char、String、Vec、&String、(i32, i32)、(i32, String)、u8; 4、&i32
答案
Copy:i32、f64、bool、char、&String、(i32,i32)、u8;4、&i32。
非 Copy:String、Vec、(i32, String)。
注意 &String 是 Copy------引用本身只是指针。这是最常猜错的一个。
练习 2(改错,只许改签名)
rust
fn longest(a: String, b: String) -> String {
if a.len() > b.len() { a } else { b }
}
fn main() {
let s1 = String::from("hi");
let s2 = String::from("hello");
let r = longest(s1, s2);
println!("longer: {}", r);
println!("s1 is still: {}", s1); // ← 要让这行也能编译
}
要求:不许 .clone(),不许改动 main 里的逻辑意图。提示:返回值需要生命周期标注,这是本课唯一一处要写 'a 的地方。
参考思路
rust
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}
// 调用:longest(&s1, &s2)
这是官方 book 的经典例子,但它在这里的价值是展示为什么生命周期标注不是可选的装饰:返回引用时,编译器必须知道这个引用来自哪个输入。去掉 'a 直接编译不过------这正是下一课的入口。
练习 3(改错,悬垂引用)
rust
fn summarize(items: &[String]) -> &str {
let mut result = String::new();
for item in items {
result.push_str(item);
}
&result
}
修好它。给出两版答案:owned 版(推荐)和「调用方提供 buffer」版(进阶,零分配)。
参考思路
Owned 版:fn summarize(items: &\&str) -> String,内部 result.push_str(item),返回 result(无分号,尾表达式 move 出去)。
Buffer 版:fn summarize_into(items: &\&str, buf: &mut String),调用方复用 buffer。适合循环里反复拼接的场景。
练习 4(反模式:参数太贪心)
rust
fn count_vowels(s: String) -> usize {
s.chars().filter(|c| "aeiouAEIOU".contains(*c)).count()
}
这段能跑,但调用方传完就不能再用原字符串了。重构签名,并写出三种不同调用处的用法(String、&str、字符串字面量)都能编译。
练习 5(切片陷阱)
写 fn truncate_to_bytes(s: &str, max_bytes: usize) -> &str:截断到不超过 max_bytes 字节,且不能切坏 UTF-8。提示:s.is_char_boundary(i)。
参考思路
rust
fn truncate_to_bytes(s: &str, max_bytes: usize) -> &str {
let end = max_bytes.min(s.len());
let mut i = end;
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
&s[..i]
}
测试用例必须包含 "你好世界" 和 max_bytes = 3 这种会切在中间的输入。
练习 6(开放题,接上一课的项目)
回到第一课的 wordcount CLI,做三件事:
- 统计函数全部改成接收 &str,返回 owned 结果,函数体内不允许出现 String 作为参数类型。
- 文件读取部分:把 read_to_string 的结果不要 clone 多次,只用一次借用链完成行/词/字的统计。
- 加一个 fn top_n(words: &(\&str, usize), n: usize) -> Vec<(&str, usize)>,返回切片视图而不是拷贝的 (String, usize)。
然后跑 cargo clippy,数数还有几个 ptr_arg / needless_borrow 警告。目标是零。
八、本课可以跳过的部分
- Rc / Arc / RefCell / Mutex ------ 一个字都别看。它们是用来解决「确实需要共享所有权」的场景,而那种场景你在前两周遇不到。现在碰只会让你以为 Rust 很麻烦。
- unsafe、裸指针、Box::leak ------ 跳过。
- Cow<'_, str> ------ 很有用,但第三课之后再学。
- 自引用结构体、Pin ------ 跳过。
- 深入理解 Drop trait、ManuallyDrop ------ 知道 drop() 存在就行。
- NLL / Polonius 的实现细节 ------ 知道「借用可以提前结束」就够了。
九、延伸阅读(按优先级)
- The Rust Programming Language 第 4 章(Ownership)------ 必读,和你本课互为补充。
- Rust By Example: Ownership / Borrowing ------ 用来刷题。
- rustc --explain E0382 等系列 ------ 配合第五节的速查表用。
- Jon Gjengset 的 "Crust of Rust: Subtyping and Variance" ------ 现在别看,等学到生命周期那课再回来,那是全网讲得最好的。
- 《Rust 程序设计语言》中文版第 4 章 ------ 官方书有社区翻译版,可对照阅读。
⚠️ 提醒:网上大量「Rust 所有权图解」「三分钟搞懂借用检查器」是 AI 批量生成的,配图里的内存模型经常是错的(比如画成「move 后置空指针」)。以编译器的实际报错和官方 book 为准。
十、下节课预告
第三课讲 Option / Result / match / 自定义错误类型。
预告一个关联点:Option<&T> 和 Option 的选择,本质还是所有权问题------你想不想把东西拿出来。如果你能把本课练习 2 和练习 3 的两版答案都讲清楚,第三课的难度会下降一半。