【Rust入门知识点学与练】第36课:所有权——移动、借用、Copy、切片

本课目标:

不再靠「试出来的」.clone() 过编译。学完你应该能看着一个签名说出「这个函数会拿走我的东西」,并在写代码前先决定好谁拥有谁。

本课是 Rust 的分水岭。前面是语法,从这里开始才是 Rust。建议一次只读一半,中间一定要动手把练习做完,否则第三节讲 Option/Result 时你会同时被两层概念压垮。

一、先建立心智模型:一句话规则

每个值有且只有一个所有者;所有者离开作用域,值被丢弃。

就这一句。后面所有的报错------moved value、borrowed value does not live long enough、cannot return reference to local variable------全是这一句的推论。

三个推论:

  1. 赋值、传参、返回,默认都是「移动」(move),不是拷贝。 let b = a; 之后 a 就废了。
  2. 想让别人「看一眼」而不拿走,就借出去(&T / &mut T)。 借的东西必须还,而且还在期间原主不能乱动。
  3. 有些类型天生可以随便抄(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"

三个必须知道的事实:

  1. &str 是 UTF-8 字节切片,索引按字节不按字符。"你好"0...1 会 panic(边界切在码点中间)。遍历请用 .chars()。
  2. 字符串字面量 "hello" 的类型就是 &'static str,不是 String。这是初学者最常见的 expected String, found &str 来源。
  3. 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,做三件事:

  1. 统计函数全部改成接收 &str,返回 owned 结果,函数体内不允许出现 String 作为参数类型。
  2. 文件读取部分:把 read_to_string 的结果不要 clone 多次,只用一次借用链完成行/词/字的统计。
  3. 加一个 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 的实现细节 ------ 知道「借用可以提前结束」就够了。

九、延伸阅读(按优先级)

  1. The Rust Programming Language 第 4 章(Ownership)------ 必读,和你本课互为补充。
  2. Rust By Example: Ownership / Borrowing ------ 用来刷题。
  3. rustc --explain E0382 等系列 ------ 配合第五节的速查表用。
  4. Jon Gjengset 的 "Crust of Rust: Subtyping and Variance" ------ 现在别看,等学到生命周期那课再回来,那是全网讲得最好的。
  5. 《Rust 程序设计语言》中文版第 4 章 ------ 官方书有社区翻译版,可对照阅读。

⚠️ 提醒:网上大量「Rust 所有权图解」「三分钟搞懂借用检查器」是 AI 批量生成的,配图里的内存模型经常是错的(比如画成「move 后置空指针」)。以编译器的实际报错和官方 book 为准。

十、下节课预告

第三课讲 Option / Result / match / 自定义错误类型。

预告一个关联点:Option<&T> 和 Option 的选择,本质还是所有权问题------你想不想把东西拿出来。如果你能把本课练习 2 和练习 3 的两版答案都讲清楚,第三课的难度会下降一半。

相关推荐
传奇开心果编程1 小时前
【Rust入门知识点学与练】第35课:工具链与反馈环——先学会读编译器在说什么
开发语言·学习·rust
重生之小比特2 小时前
【Java SE】字符串 - String类
java·开发语言·intellij-idea
优化Henry2 小时前
学习笔记之关于MME不通问题归纳
运维·网络·学习·5g·信息与通信
刘发财9 小时前
前端2秒生成500页矢量PDF,rust真的强到没朋友
前端·javascript·rust
M78佐菲10 小时前
51单片机学习笔记:ds18b20要点整理
linux·笔记·嵌入式硬件·学习·51单片机
lsswear10 小时前
Python 并发 线程
开发语言·python
郑州光合科技余经理10 小时前
国际版外卖系统:税率字段怎么和订单主流程解耦
android·java·开发语言·前端·后端·php·ai编程
高亦真10 小时前
今天是学习嵌入式的第37天
linux·学习·算法
软件课代表CiCi10 小时前
运行库是什么?电脑缺运行库怎么办?c++运行库缺失修复全攻略
开发语言·c++·windows·电脑·dll修复·dll丢失·运行库