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