cd ../articles

学习笔记

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

4 min read
两个变量名指向同一个箱子,箭头从一个划向另一个,旧的那个被打上灰色叉号。
两个变量名指向同一个箱子,箭头从一个划向另一个,旧的那个被打上灰色叉号。

我把 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

作者头像
yen@harvey:~$ exit 0