为什么 let s2 = s1 有时报错,有时不报错

我把 ACGHub 的后端从 C 往 Rust 上搬。新语言里最有名的那道坎——所有权和借用——我本来挺紧张,结果上手发现「借用规则」这层没难倒我:同一块数据,要么一堆人只读,要么一个人改,不能边读边改。想明白这条,大半场景都顺了。
我一度以为这就通关了。
真正把我看懵的,是个看起来最不该出问题的操作:
let s2 = s1; 就一个赋值。可它有时候编译报错,有时候又啥事没有。同样一行代码,凭什么待遇不一样?
借用规则,我以为是终点
先说我没栽的那层,因为它和后面是一根线。
Rust 的 盯着一条铁律:同一时刻,对一块数据,要么有任意多个不可变借用 &,要么只有一个可变借用 &mut,两者不能并存(shared XOR mutable)。
这条规则防什么?我一开始的理解是「防多线程写竞争」。这没错,但太窄了——它在单线程里照样拦你。最经典的例子:
let mut v = vec![1, 2, 3];
let first = &v[0]; // 借了一个指向 v 里元素的 &
v.push(4); // ❌ 同时要 &mut v 去 push
println!("{first}"); push 可能触发扩容,把底层 buffer 整个搬到新地址,那 first 就成了指向旧地址的悬垂引用。借用检查器不让这段过——它防的是更广的「一边有人引用、一边有人改动」,多线程数据竞争只是其中一类。(附带好处:编译器知道 &mut 是独占的,能更放心地优化。)
这条我懂了,于是我以为所有权这块也就这么回事了。直到那个赋值。
我以为赋值就是拷贝
我当时脑子里的模型是从别的语言带过来的:let s2 = s1 嘛,把 s1 复制一份给 s2,两个各用各的,天经地义。
可这段死活编译不过:
let s1 = String::from("Hello");
let s2 = s1;
println!("{}", s1); // ❌ error[E0382]: borrow of moved value: `s1` s1 怎么就「moved」了?我不就赋个值吗,又没动它。
答案是 。String 在堆上有一块 buffer,它的值其实是「指向那块 buffer 的指针 + 长度 + 容量」。如果 let s2 = s1 真的「各自独立、都能用」,那 s1、s2 就同时拥有同一块堆内存;等它俩各自离开作用域,这块内存会被 free 两次——这就是 C 里要人命的 double free。
Rust 的选择很干脆:一块数据任何时刻只有一个 拥有者。let s2 = s1 不是拷贝,是把所有权转移给 s2;s1 当场失效,再用它编译就拦下来。想要真正的两份,得显式喊一声:
let s2 = s1.clone(); // 真·深拷贝,堆上 buffer 复制一份;s1 仍然有效 .clone() 这一声不是啰嗦,是 Rust 故意让你看见「这里要多花一次堆分配」,而不是让拷贝悄悄发生。
那为什么这段又编译通过了?
把我彻底问住的是下面这段——同样是 let s2 = s1,它却一点事没有:
fn main() {
let s1 = "Hello";
let s2 = s1;
println!("{}", s1); // ✓ 编过了!
println!("{}", s2); // ✓
} 说好的 move 呢?
关键在于:这里的 s1 根本不是 String。
"Hello" 是字符串字面量,类型是 &'static str——一个引用,指向程序二进制里那块只读静态数据。它本身只是「指针 + 长度」这一小撮躺在栈上的东西,而且引用类型是 的。所以 let s2 = s1 是按位拷贝了这一小撮,真正的字符串字节躺在二进制里、谁也不拥有、永远不用释放——既然没有「释放权」要争,s1 当然能接着用。
一句话对照:
let s1 = "Hello"; // &str:借来的视图,Copy → 赋值是拷贝,s1 还在
let s1 = String::from("Hello"); // String:堆上 owned,非 Copy → 赋值是 move,s1 失效 &str(如 "Hello") | String | |
|---|---|---|
| 谁拥有字符串数据 | 没人(在只读静态区 / 别处) | 这个变量自己(堆上) |
| 变量里装的 | 指针 + 长度 | 指针 + 长度 + 容量 |
| 是 Copy 吗 | 是 | 否 |
let s2 = s1 | 拷贝,s1 还能用 | move,s1 失效 |
| 离开作用域 | 什么都不用做 | 释放堆 buffer |
两条线,其实是同一条
绕明白之后我才发现,所有权(move)和借用,本来就是一根线上的两段。
- 所有权管的是:这块数据谁拥有、谁负责释放——任何时刻只有一个,所以赋值默认是转移而不是复制。
- 借用管的是:在不转移所有权的前提下,怎么临时看一眼 / 改一下——于是有了
&和&mut,以及「共享与可变互斥」。
它俩指向同一个目标:一块数据,任何时刻「能动它的人」最多一个,从而在编译期就杜绝重复释放、悬垂、边读边改。借用检查器就是那个全程替你盯着的人。
从 C 过来的我,对这套其实不该陌生——这些规矩我在 C 里一直在手动遵守:malloc / free 配对、别 use-after-free、别攥着一个指向 realloc 之前 buffer 的指针。只不过在 C 里全靠自觉,错了要等运行时(甚至上线)才炸;Rust 把同一套规矩搬到了编译期,错了当场不让过。借用检查器不是在跟我作对,它是在替我做我在 C 里时时刻刻提心吊胆做的那件事。
回头看
我那句「理解了借用规则就没问题了」,其实只对了一半。
借用规则确实不难,难的不是它,是它前面那层——所有权和 move / copy。而我偏偏是栽在最朴素的 let s2 = s1 上才真正把这层补上的:在 Rust 里,赋值默认是转移,不是拷贝;旧变量还能不能用,取决于这个类型是「拥有一块资源」还是「只是个 Copy 的小东西」。
所以那个「有时报错、有时不报错」根本不是 Rust 善变。是我一直拿「赋值=拷贝」这把旧尺子去量它,而 Rust 量的是另一件事:谁,拥有这块数据。
// related
学习笔记
包进事务,点赞就不会数错了吗
ACGHub 里到处是计数——点赞、收藏、回答数。我一直觉得:读出来 +1 再写回去,只要包进 BEGIN…COMMIT,事务保证原子性,就不会出错。这篇拆的就是这个「就不会出错吧」——它其实会,而搞懂它为什么会,得先把我对 MVCC 的误解也一起纠了。
学习笔记
乱码不是字坏了,是尺子拿错了
乱码烦了我好多年——打游戏、改配置、传服务器,每次都靠玄学糊弄过去。直到把「字 → 码点 → 字节」这条链拆开,才明白:字从来没坏,是我每次都拿错了解码的那把尺子。
学习笔记
为什么游戏设置里有五种抗锯齿
大一打游戏翻设置,抗锯齿那栏点开是 SSAA / MSAA / FXAA / SMAA / TAA 一长串全是缩写。好奇查了查才发现,它们其实都在跟同一个东西较劲——锯齿,而锯齿的根,是采样不够。