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

读 Rust 代码时,很容易卡在这样两行上:

rust 复制代码
let tools = build_tools(&ctx, args);
let mut tools = tools;

同一个名字怎么能声明两次?第二行已经拿到了所有权,为什么还要写 mut?既然东西归我,难道还不能改吗?

如果之前主要写 Java、C# 或 JavaScript,这种困惑很自然。我们习惯了把对象交给另一个变量,再从任何一个变量修改它。Rust 把这里的几件事分开了,需要稍微换一下思路。

这篇笔记从这些具体问题讲起。先看常见场景,最后留几道题,可以自己判断一下,不用急着记术语。

先分清三个问题

看到一段 Rust 代码,可以先问:

  • 所有权:谁持有这个值,负责在不再需要时释放它?
  • 借用:谁临时使用这个值?
  • 可变性:当前使用方式是否允许修改它?

拥有一个值,并不意味着当前变量就能修改它。把值交给别人,也不一定是在复制数据。后面的报错,大多能从这三个问题里找到原因。

1. 换个变量接住,原来的变量就不能用了

rust 复制代码
let a = String::from("hello");
let b = a;

println!("{b}"); // 可以
println!("{a}"); // 编译错误:a 的值已经被移动

第一次遇到这个错误,可能会觉得:我只是赋了个值,怎么就不能用了?

String 管理着堆上的字符串数据。这里的赋值把所有权从 a 转移给了 b,没有复制字符串内容。转移之后,a 就不能再访问这个值。

为什么要这样规定?如果两个变量都独立负责释放同一块内存,程序就可能重复释放它。Rust 在编译时明确谁负责这份数据。

确实需要两个独立的字符串时,可以写:

rust 复制代码
let a = String::from("hello");
let b = a.clone();

println!("{a}, {b}");

这次 clone() 复制了内容,两个字符串各自管理自己的数据。

2. 为什么整数又没这个问题?

rust 复制代码
let a = 10;
let b = a;

println!("{a}, {b}"); // 可以

同样是 let b = a,结果却不同。关键在类型:整数实现了 Copy,这里复制的是数值,a 仍然有效。

整数、布尔值、浮点数、字符都是常见的 Copy 类型。String 和 Vec 则不是。

为什么要区分?这些简单值可以直接复制,不需要像 String 那样管理堆上的资源。但不要把它记成"栈上的都能复制":赋值时能否保留原变量,要看类型有没有实现 Copy。

所以,只看赋值这一行还不够,也要看变量是什么类型。

3. 函数只是打印一下,为什么把字符串拿走了?

rust 复制代码
fn show(text: String) {
    println!("{text}");
}

fn main() {
    let text = String::from("hello");
    show(text);
    println!("{text}"); // 编译错误
}

函数体确实只打印了字符串,但参数写的是 String。这个签名表示:函数接收字符串的所有权。

为什么调用后不能再用?传参和赋值遵循相同的移动规则。字符串进入 show 后,由参数 text 持有,函数结束时会被释放。

如果只需要读取,可以借用:

rust 复制代码
fn show(text: &str) {
    println!("{text}");
}

fn main() {
    let text = String::from("hello");
    show(&text);
    println!("{text}"); // 可以
}

这里 &text 在调用时可以转换为 &str。函数临时读取字符串,所有权仍在调用方。读取文本的接口经常用 &str,因为它也能接收字符串字面量等文本切片。

遇到"调用后变量不能用"的报错,先看函数签名。很多时候需要的是借用,不必马上加 clone()。

4. 值归我,为什么还要 mut?

rust 复制代码
let text = String::from("hello");
text.push_str(" world"); // 编译错误

这段里的 text 确实拥有字符串。问题在于,它是一个不可变绑定,不能通过它修改字符串。

rust 复制代码
let mut text = String::from("hello");
text.push_str(" world"); // 可以

为什么所有权还不够?所有权回答谁管理值,mut 表达当前绑定允许修改。Rust 不会因为值归你,就默认你准备修改它。

也可以把已有的值交给一个可变的新绑定:

rust 复制代码
let text = String::from("hello");
let mut text = text;
text.push_str(" world");

第二行叫变量遮蔽。右边是旧变量,左边是同名的新变量。它接收旧值的所有权,并被声明为可变。字符串内容没有因此复制。

这就解释了开头的两行。如果中间没有别的操作,一般也可以直接写成:

rust 复制代码
let mut tools = build_tools(&ctx, args);

5. 不拿走所有权,也可以修改

有时希望函数帮忙修改字符串,修改完以后自己继续用:

rust 复制代码
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。

rust 复制代码
let mut number = 1;
let reference = &mut number;
*reference = 2;

reference 没有声明为可变,但它的类型是可变引用,所以能修改指向的数据。let mut reference 控制的是能否给引用变量重新赋值。

另外,Cell、RefCell、Mutex 等类型提供了内部可变性,也可以通过不可变绑定修改内部数据。因此,"修改一定要在变量前写 mut"不是完整的规则。

6. 为什么读着读着,就不能改了?

rust 复制代码
let mut text = String::from("hello");
let reader = &text;

text.push_str(" world"); // 编译错误
println!("{reader}");

reader 后面还要使用,因此这次不可变借用仍然有效。而 push_str 需要可变借用,两者发生了冲突。

为什么不能同时存在?例如,往字符串追加内容可能导致重新分配内存。如果允许保留旧引用再修改,引用就可能失效。Rust 的普通借用规则要求:对于同一份数据,可以同时有多个不可变借用,或者一个可变借用。

把读取放到修改之前,就可以:

rust 复制代码
let mut text = String::from("hello");
let reader = &text;
println!("{reader}"); // reader 最后一次使用

text.push_str(" world");

引用变量还在作用域里,但借用通常能在最后一次使用之后结束。遇到借用冲突时,可以检查:这个引用后面是否真的还需要使用?

7. 返回引用,为什么也不行?

rust 复制代码
fn make_text() -> &String {
    let text = String::from("hello");
    &text
}

这段不能编译。局部变量 text 会在函数结束时被释放,返回它的引用,调用方就会拿到指向失效数据的引用。

为什么不能加一个生命周期标记解决?生命周期描述引用与数据之间的有效关系,不能让已经要被释放的局部变量活得更久。

这里直接返回值就行:

rust 复制代码
fn make_text() -> String {
    let text = String::from("hello");
    text
}

let text = make_text();

所有权转移给调用方,字符串不会在函数结束时被释放。返回 String 也不意味着必须复制一遍字符串内容。

8. 打印了一遍集合,集合怎么没了?

rust 复制代码
let names = vec![String::from("Alice"), String::from("Bob")];

for name in names {
    println!("{name}");
}

println!("{names:?}"); // 编译错误

如果你以前写 Java 或 JavaScript,看到这里可能会觉得很别扭:循环里没有删除元素,没有修改集合,只是打印了一下,为什么集合就不能用了?

这里说的"没了",是原来的 names 不能继续使用,并不是遍历把一个可用的集合变成了空集合。所有权在进入循环时就已经转移了。

for 会先把右边的东西变成迭代器

理解这段代码,可以先把 for 想成下面这种写法。这不是编译器展开的全部细节,但能看清所有权往哪里走:

rust 复制代码
let mut iterator = IntoIterator::into_iter(names);

while let Some(name) = iterator.next() {
    println!("{name}");
}

关键在第一行。names 的类型是 Vec<String>,按值调用 into_iter 会把整个 Vec 的所有权交给迭代器。从这一刻开始,原来的变量 names 就不能再使用了,甚至不需要等到循环执行完。

之后每次调用 next(),迭代器会取出一个 String,把它的所有权交给这一轮的 name。这里没有克隆字符串内容。

在这个例子中,println! 只是借用 name 来格式化输出。打印结束后,name 仍然持有字符串;这一轮循环结束时,name 被释放,对应的字符串也随之释放。

所以,即使循环体只打印,外面的集合仍然已经被移动。Rust 根据你选择的遍历方式处理所有权,不会因为循环体看起来只读取,就自动改成借用。

只想看一遍,就借用集合

如果后面还要用 names,可以在集合前加上 &:

rust 复制代码
for name in &names {
    println!("{name}");
}

println!("{names:?}"); // 可以

这次循环处理的是 &Vec<String>,没有拿走集合。每轮的 name 是 &String,也就是指向集合中某个元素的不可变引用。

可以把类型明确写出来观察:

rust 复制代码
for name in &names {
    let name: &String = name;
    println!("{name}");
}

打印时没有复制字符串,也没有把元素从集合里取走。循环结束后,集合和里面的元素都还在。

这个写法也常写成:

rust 复制代码
for name in names.iter() {
    println!("{name}");
}

对于这里的 Vec,&names 和 names.iter() 都是在借用元素进行遍历。只读一遍时,通常不需要为了保留集合而写 names.clone()。

想改集合里的元素,就用可变借用

如果想给每个名字加上感叹号,可以这样写:

rust 复制代码
let 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(...):迭代器正在可变借用集合中的元素,再修改集合结构会产生借用冲突。扩容还可能改变元素所在的内存位置。如果需要新增元素,可以先收集待添加的值,等循环结束后再追加。

把三种方式放在一起,区别会更清楚:

写法 每轮拿到的类型(以 Vec<String> 为例) 循环后还能用原集合吗?
for name in names String 不能,集合已被移动
for name in &names 或 names.iter() &String 能,元素没有被取走
for name in &mut names 或 names.iter_mut() &mut String 能,元素可能被修改

那按值遍历有什么用?

如果集合已经不需要了,或者你想把元素交给另一个地方,按值遍历就很合适:

rust 复制代码
let 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 也不会恢复可用:整个集合早已交给迭代器,退出循环时,迭代器会释放尚未取出的元素。

还有个常见误会:如果元素是整数,集合是不是就能继续用了?

rust 复制代码
let numbers = vec![1, 2, 3];

for number in numbers {
    println!("{number}");
}

println!("{numbers:?}"); // 仍然是编译错误

整数实现了 Copy,但 Vec<i32> 没有实现 Copy。被移动的是整个集合,元素能复制并不能阻止这次移动。

如果想保留集合,同时让每轮拿到一个整数值,可以用:

rust 复制代码
for number in numbers.iter().copied() {
    // number 是 i32,来自对借用元素的复制
    println!("{number}");
}

选择遍历方式时,先问自己:后面还要不要这个集合?这一轮需要的是元素本身,还是它的引用?这两个问题通常比"编译器报错后该加什么符号"更容易判断。

9. Arc 可以共享,为什么还要 clone?

rust 复制代码
use std::sync::Arc;

let a = Arc::new(String::from("hello"));
let b = a;

println!("{a}"); // 编译错误

Arc 支持共享,但这个赋值仍然移动了 Arc 本身。需要两个持有者,就要明确克隆句柄:

rust 复制代码
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 和移动的区别。下面两组代码分别能否编译?

rust 复制代码
let a = 100;
let b = a;
println!("{a}, {b}");
rust 复制代码
let x = String::from("Rust");
let y = x;
println!("{x}, {y}");

题目 2:只算长度,需要拿走字符串吗?

考查函数签名如何表达借用。修改函数签名和调用方式,让最后一行正常执行,不使用 clone()。

rust 复制代码
fn length(text: String) -> usize {
    text.len()
}

fn main() {
    let text = String::from("hello");
    let size = length(text);
    println!("{text}: {size}");
}

题目 3:所有权已经转移,为什么不能 push?

考查所有权和可变性的区别。第二行接收了所有权,第三行能否编译?如果不能,应该修改哪里?

rust 复制代码
let values = vec![1, 2, 3];
let values = values;
values.push(4);

题目 4:引用变量没写 mut,能修改数据吗?

考查绑定可变性和可变引用的区别。判断这段代码能否编译,并解释 *reference 修改的是谁。

rust 复制代码
let mut number = 10;
let reference = &mut number;
*reference = 20;
println!("{number}");

题目 5:只换个顺序,为什么结果不同?

考查借用的结束位置。下面 A、B 哪段能编译?

代码 A:

rust 复制代码
let mut text = String::from("hello");
let reader = &text;
println!("{reader}");
text.push('!');

代码 B:

rust 复制代码
let mut text = String::from("hello");
let reader = &text;
text.push('!');
println!("{reader}");

题目 6:遍历结束后还要用集合

考查按值遍历和借用遍历。只修改 for 所在的那一行,让最后的输出正常执行。

rust 复制代码
let words = vec![String::from("hello"), String::from("world")];

for word in words {
    println!("{word}");
}

println!("一共有 {} 个单词", words.len());

题目 7:支持共享的 Arc,也会被移动吗?

考查共享句柄的移动和克隆。修改第二个 let,让两个变量都能使用。修改后,底层有几份字符串数据?

rust 复制代码
use std::sync::Arc;

let a = Arc::new(String::from("hello"));
let b = a;

println!("{a}");
println!("{b}");

刚开始写 Rust,很容易把解决报错变成试排列组合:加个 &,不行再加 mut,最后加 clone()。暂时编译通过了,下次还是会卡住。

下次遇到类似错误,可以先沿着值的使用过程看一遍:它有没有被移动,借出去的引用后面还用不用,当前操作是否需要修改。把这几件事看清楚,编译器的提示也会更容易读懂。

相关推荐
西红柿炖牛腩3541 小时前
Three.js 模型体积优化:Draco/Meshopt 压缩与 DRACOLoader 配置的 4 个步骤
javascript·3d·性能优化
薛一半1 小时前
React-Redux核心原理与容器组件实战
前端·javascript·react.js
web打印社区1 小时前
C-Lodop 提示未准备好或 WebSocket 没准备好:先让本机服务起来
javascript·网络·websocket·网络协议·pdf·html
言乐61 小时前
JavaScript概括前端原理
开发语言·前端·javascript·python·ecmascript
回眸&啤酒鸭2 小时前
DeepSeek V4.1 Flash 批量处理实战指南
服务器·前端·javascript
传人2 小时前
网页文字滚动效果如何做
前端·javascript·jquery
福兮说2 小时前
前后端算的 MD5、SHA-256 对不上?编码、换行、BOM、HMAC、JSON 顺序,八个原因逐个实测
前端·javascript·node.js·json·哈希算法
王霸天2 小时前
Three.js 模型体积优化:Draco/Meshopt 压缩与 DRACOLoader 配置的 4 个步骤
java·前端·javascript
web打印社区3 小时前
Lodop 提示未安装或请升级:Chrome 里先分清该装哪套
开发语言·前端·javascript·chrome·websocket·http