ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Rust 所有权:看你的答对几道题?

Rust 所有权:看你的答对几道题? 读 Rust 代码时很容易卡在这样两行上rustlet tools build_tools(ctx, args);let mut tools tools;同一个名字怎么能声明两次第二行已经拿到了所有权为什么还要写 mut既然东西归我难道还不能改吗如果之前主要写 Java、C# 或 JavaScript这种困惑很自然。我们习惯了把对象交给另一个变量再从任何一个变量修改它。Rust 把这里的几件事分开了需要稍微换一下思路。这篇笔记从这些具体问题讲起。先看常见场景最后留几道题可以自己判断一下不用急着记术语。!-- more --## 先分清三个问题看到一段 Rust 代码可以先问- **所有权**谁持有这个值负责在不再需要时释放它- **借用**谁临时使用这个值- **可变性**当前使用方式是否允许修改它拥有一个值并不意味着当前变量就能修改它。把值交给别人也不一定是在复制数据。后面的报错大多能从这三个问题里找到原因。## 1. 换个变量接住原来的变量就不能用了rustlet a String::from(hello);let b a;println!({b}); // 可以println!({a}); // 编译错误a 的值已经被移动第一次遇到这个错误可能会觉得我只是赋了个值怎么就不能用了String 管理着堆上的字符串数据。这里的赋值把所有权从 a 转移给了 b没有复制字符串内容。转移之后a 就不能再访问这个值。为什么要这样规定如果两个变量都独立负责释放同一块内存程序就可能重复释放它。Rust 在编译时明确谁负责这份数据。确实需要两个独立的字符串时可以写rustlet a String::from(hello);let b a.clone();println!({a}, {b});这次 clone() 复制了内容两个字符串各自管理自己的数据。## 2. 为什么整数又没这个问题rustlet a 10;let b a;println!({a}, {b}); // 可以同样是 let b a结果却不同。关键在类型整数实现了 Copy这里复制的是数值a 仍然有效。整数、布尔值、浮点数、字符都是常见的 Copy 类型。String 和 Vec 则不是。为什么要区分这些简单值可以直接复制不需要像 String 那样管理堆上的资源。但不要把它记成“栈上的都能复制”赋值时能否保留原变量要看类型有没有实现 Copy。所以只看赋值这一行还不够也要看变量是什么类型。## 3. 函数只是打印一下为什么把字符串拿走了rustfn show(text: String) {println!({text});}fn main() {let text String::from(hello);show(text);println!({text}); // 编译错误}函数体确实只打印了字符串但参数写的是 String。这个签名表示函数接收字符串的所有权。为什么调用后不能再用传参和赋值遵循相同的移动规则。字符串进入 show 后由参数 text 持有函数结束时会被释放。如果只需要读取可以借用rustfn show(text: str) {println!({text});}fn main() {let text String::from(hello);show(text);println!({text}); // 可以}这里 text 在调用时可以转换为 str。函数临时读取字符串所有权仍在调用方。读取文本的接口经常用 str因为它也能接收字符串字面量等文本切片。遇到“调用后变量不能用”的报错先看函数签名。很多时候需要的是借用不必马上加 clone()。## 4. 值归我为什么还要 mutrustlet text String::from(hello);text.push_str( world); // 编译错误这段里的 text 确实拥有字符串。问题在于它是一个不可变绑定不能通过它修改字符串。rustlet mut text String::from(hello);text.push_str( world); // 可以为什么所有权还不够所有权回答谁管理值mut 表达当前绑定允许修改。Rust 不会因为值归你就默认你准备修改它。也可以把已有的值交给一个可变的新绑定rustlet text String::from(hello);let mut text text;text.push_str( world);第二行叫变量遮蔽。右边是旧变量左边是同名的新变量。它接收旧值的所有权并被声明为可变。字符串内容没有因此复制。这就解释了开头的两行。如果中间没有别的操作一般也可以直接写成rustlet mut tools build_tools(ctx, args);## 5. 不拿走所有权也可以修改有时希望函数帮忙修改字符串修改完以后自己继续用rustfn append(text: mut String) {text.push_str( world);}fn main() {let mut text String::from(hello);append(mut text);println!({text}); // hello world}mut String 是可变引用。函数临时获得修改能力没有取得字符串的所有权。为什么这里用可变借用因为调用方还要保留字符串而函数需要修改它。对于这里的普通字符串变量调用方也要声明 mut才能创建这个可变借用。| 参数 | 函数获得什么 || --- | --- || String | 字符串的所有权 || String | 临时读取字符串的引用 || mut String | 临时修改字符串的引用 |还有一个容易混淆的细节引用变量自己不一定需要写 mut。rustlet mut number 1;let reference mut number;*reference 2;reference 没有声明为可变但它的类型是可变引用所以能修改指向的数据。let mut reference 控制的是能否给引用变量重新赋值。另外Cell、RefCell、Mutex 等类型提供了内部可变性也可以通过不可变绑定修改内部数据。因此“修改一定要在变量前写 mut”不是完整的规则。## 6. 为什么读着读着就不能改了rustlet mut text String::from(hello);let reader text;text.push_str( world); // 编译错误println!({reader});reader 后面还要使用因此这次不可变借用仍然有效。而 push_str 需要可变借用两者发生了冲突。为什么不能同时存在例如往字符串追加内容可能导致重新分配内存。如果允许保留旧引用再修改引用就可能失效。Rust 的普通借用规则要求对于同一份数据可以同时有多个不可变借用或者一个可变借用。把读取放到修改之前就可以rustlet mut text String::from(hello);let reader text;println!({reader}); // reader 最后一次使用text.push_str( world);引用变量还在作用域里但借用通常能在最后一次使用之后结束。遇到借用冲突时可以检查这个引用后面是否真的还需要使用## 7. 返回引用为什么也不行rustfn make_text() - String {let text String::from(hello);text}这段不能编译。局部变量 text 会在函数结束时被释放返回它的引用调用方就会拿到指向失效数据的引用。为什么不能加一个生命周期标记解决生命周期描述引用与数据之间的有效关系不能让已经要被释放的局部变量活得更久。这里直接返回值就行rustfn make_text() - String {let text String::from(hello);text}let text make_text();所有权转移给调用方字符串不会在函数结束时被释放。返回 String 也不意味着必须复制一遍字符串内容。## 8. 打印了一遍集合集合怎么没了rustlet names vec![String::from(Alice), String::from(Bob)];for name in names {println!({name});}println!({names:?}); // 编译错误如果你以前写 Java 或 JavaScript看到这里可能会觉得很别扭循环里没有删除元素没有修改集合只是打印了一下为什么集合就不能用了这里说的“没了”是原来的 names 不能继续使用并不是遍历把一个可用的集合变成了空集合。所有权在进入循环时就已经转移了。### for 会先把右边的东西变成迭代器理解这段代码可以先把 for 想成下面这种写法。这不是编译器展开的全部细节但能看清所有权往哪里走rustlet mut iterator IntoIterator::into_iter(names);while let Some(name) iterator.next() {println!({name});}关键在第一行。names 的类型是 VecString按值调用 into_iter 会把整个 Vec 的所有权交给迭代器。从这一刻开始原来的变量 names 就不能再使用了甚至不需要等到循环执行完。之后每次调用 next()迭代器会取出一个 String把它的所有权交给这一轮的 name。这里没有克隆字符串内容。在这个例子中println! 只是借用 name 来格式化输出。打印结束后name 仍然持有字符串这一轮循环结束时name 被释放对应的字符串也随之释放。所以即使循环体只打印外面的集合仍然已经被移动。Rust 根据你选择的遍历方式处理所有权不会因为循环体看起来只读取就自动改成借用。### 只想看一遍就借用集合如果后面还要用 names可以在集合前加上 rustfor name in names {println!({name});}println!({names:?}); // 可以这次循环处理的是 VecString没有拿走集合。每轮的 name 是 String也就是指向集合中某个元素的不可变引用。可以把类型明确写出来观察rustfor name in names {let name: String name;println!({name});}打印时没有复制字符串也没有把元素从集合里取走。循环结束后集合和里面的元素都还在。这个写法也常写成rustfor name in names.iter() {println!({name});}对于这里的 Vecnames 和 names.iter() 都是在借用元素进行遍历。只读一遍时通常不需要为了保留集合而写 names.clone()。### 想改集合里的元素就用可变借用如果想给每个名字加上感叹号可以这样写rustlet mut names vec![String::from(Alice)];for name in mut names {name.push(!);}println!({names:?}); // [Alice!]这次每轮的 name 是 mut String所以修改的是集合里的原字符串。它也可以写成 names.iter_mut()。注意循环里的 name 不需要声明成 mut name。可变引用本身已经允许修改指向的数据这和前面 reference 的例子是同一个道理。不过在这种循环中不能同时调用 names.push(...)迭代器正在可变借用集合中的元素再修改集合结构会产生借用冲突。扩容还可能改变元素所在的内存位置。如果需要新增元素可以先收集待添加的值等循环结束后再追加。把三种方式放在一起区别会更清楚| 写法 | 每轮拿到的类型以 VecString 为例 | 循环后还能用原集合吗 || --- | --- | --- || for name in names | String | 不能集合已被移动 || for name in names 或 names.iter() | String | 能元素没有被取走 || for name in mut names 或 names.iter_mut() | mut String | 能元素可能被修改 |### 那按值遍历有什么用如果集合已经不需要了或者你想把元素交给另一个地方按值遍历就很合适rustlet names vec![String::from(Alice), String::from(Bob)];let mut selected Vec::new();for name in names {if name.starts_with(A) {selected.push(name);}}println!({selected:?}); // [Alice]这里把符合条件的字符串直接移动到 selected不需要克隆内容。被选中的 name 已经移走不会在这一轮结束时释放没有选中的字符串则会在对应那一轮结束时释放。即使提前 break原来的 names 也不会恢复可用整个集合早已交给迭代器退出循环时迭代器会释放尚未取出的元素。还有个常见误会如果元素是整数集合是不是就能继续用了rustlet numbers vec![1, 2, 3];for number in numbers {println!({number});}println!({numbers:?}); // 仍然是编译错误整数实现了 Copy但 Veci32 没有实现 Copy。被移动的是整个集合元素能复制并不能阻止这次移动。如果想保留集合同时让每轮拿到一个整数值可以用rustfor number in numbers.iter().copied() {// number 是 i32来自对借用元素的复制println!({number});}选择遍历方式时先问自己后面还要不要这个集合这一轮需要的是元素本身还是它的引用这两个问题通常比“编译器报错后该加什么符号”更容易判断。## 9. Arc 可以共享为什么还要 clonerustuse std::sync::Arc;let a Arc::new(String::from(hello));let b a;println!({a}); // 编译错误Arc 支持共享但这个赋值仍然移动了 Arc 本身。需要两个持有者就要明确克隆句柄rustlet a Arc::new(String::from(hello));let b Arc::clone(a);println!({a}, {b});为什么这里的克隆不会复制字符串Arc::clone 增加引用计数两个指针仍然指向同一份底层数据。最后一个持有者释放时数据才会释放。| 操作 | 结果 || --- | --- || String::clone() | 复制字符串内容得到独立字符串 || Arc::clone(a) | 增加引用计数共享底层数据 |这也是项目代码中“先创建再 clone 给不同组件”的常见原因每个组件需要持有一个句柄又必须连接到同一份状态。创建两次通常会得到两份独立状态效果不同。不过共享持有不代表能随意修改。需要多个地方共享修改的数据时常见做法是用 ArcMutexT通过锁取得访问能力。## 留几道题试试能不能说出原因每道题都先判断能否编译再解释原因。如果不能编译尝试做最小修改。能把原因说清楚比只把报错消掉更有用。### 题目 1相同的赋值结果为什么不同考查 Copy 和移动的区别。下面两组代码分别能否编译rustlet a 100;let b a;println!({a}, {b});rustlet x String::from(Rust);let y x;println!({x}, {y});### 题目 2只算长度需要拿走字符串吗考查函数签名如何表达借用。修改函数签名和调用方式让最后一行正常执行不使用 clone()。rustfn length(text: String) - usize {text.len()}fn main() {let text String::from(hello);let size length(text);println!({text}: {size});}### 题目 3所有权已经转移为什么不能 push考查所有权和可变性的区别。第二行接收了所有权第三行能否编译如果不能应该修改哪里rustlet values vec![1, 2, 3];let values values;values.push(4);### 题目 4引用变量没写 mut能修改数据吗考查绑定可变性和可变引用的区别。判断这段代码能否编译并解释 *reference 修改的是谁。rustlet mut number 10;let reference mut number;*reference 20;println!({number});### 题目 5只换个顺序为什么结果不同考查借用的结束位置。下面 A、B 哪段能编译代码 Arustlet mut text String::from(hello);let reader text;println!({reader});text.push(!);代码 Brustlet mut text String::from(hello);let reader text;text.push(!);println!({reader});### 题目 6遍历结束后还要用集合考查按值遍历和借用遍历。只修改 for 所在的那一行让最后的输出正常执行。rustlet words vec![String::from(hello), String::from(world)];for word in words {println!({word});}println!(一共有 {} 个单词, words.len());### 题目 7支持共享的 Arc也会被移动吗考查共享句柄的移动和克隆。修改第二个 let让两个变量都能使用。修改后底层有几份字符串数据rustuse std::sync::Arc;let a Arc::new(String::from(hello));let b a;println!({a});println!({b});刚开始写 Rust很容易把解决报错变成试排列组合加个 不行再加 mut最后加 clone()。暂时编译通过了下次还是会卡住。下次遇到类似错误可以先沿着值的使用过程看一遍它有没有被移动借出去的引用后面还用不用当前操作是否需要修改。把这几件事看清楚编译器的提示也会更容易读懂。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进