移动语义
本教程共 78 篇 · 第 21 篇 · 更新于 2026-08-08 · 约 11 分钟阅读
本节目标:看清把 String 这类堆数据赋值或传参时发生的”移动(Move)“——所有权转移而非复制,理解它如何避免双重释放,分清 Copy 类型与 clone 的差别,并掌握函数传参、返回中的移动。
上一章说”同一时刻一个值只能有一个所有者”。那当我把值交给另一个变量、或传给函数时,到底发生了什么?这一章就讲这个最劝退新手的”移动语义”。换句话说:把一个值”交出去”的时候,是复制了一份,还是把原本的所有权挪了位?
21-1 整数赋值:复制一份
先看栈上的简单类型,符合直觉:
fn main() {
let x = 5;
let y = x; // 复制 5 给 y
println!("x={x}, y={y}"); // 都能用
}
x 和 y 各有一份 5,互不影响。因为整数是固定大小、存在栈上,复制成本几乎为零,所以 Rust 直接复制一份。这类实现了 Copy 的类型,赋值不转移所有权,原值照样可用。
21-2 String 赋值:移动而非复制
换成堆上的 String,画风突变:
fn main() {
let s1 = String::from("hello");
let s2 = s1; // 移动!s1 把所有权交给 s2
// println!("{s1}"); // 错!s1 已失效
println!("{s2}"); // 只有 s2 能用
}
s1 原本持有堆上 “hello” 的钥匙。执行 let s2 = s1 后,钥匙从 s1 转到 s2 手里,s1 不再拥有它。之后再碰 s1,编译器直接拒绝。这种现象叫移动(Move)。从这一刻起,s1 在编译器眼里成了”无效变量”,碰它就是编译错误。
Warning这是新手第一道坎:“我就赋个值,原来的变量怎么就不能用了?“记住:对堆数据,Rust 默认是”转移所有权”而不是”复制内容”。想保留两个都可用,得显式
clone(下面讲)。
21-3 为什么设计成移动
设想如果 String 赋值是”浅复制”——s1、s2 都指向同一块堆内存。那问题来了:s1 离开作用域,Rust 释放那块内存;s2 之后离开作用域,又去释放同一块。一次释放两次,叫双重释放(double free),是经典内存安全漏洞,会让程序崩溃或被攻击。更深一层,如果 s1 释放后 s2 还在用,那就是”释放后使用(use after free)“,同样危险。
移动机制从根上消除它:所有权只在一个人手里,释放只发生一次。s1 把所有权交出后自己变”空壳”,编译器禁止你再用,于是绝不会有两人抢着释放,也不会有”用了已释放内存”的情况。这套保证在编译期就完成了,运行时不花任何成本。
21-4 移动的内部真相
严格说,移动时 Rust 把栈上的”指针、长度、容量”这三个信息从 s1 复制到了 s2(这步成本极低,只是三个机器字),然后标记 s1 失效、不复制堆上的实际字符数据。所以它比”深复制整个字符串”快得多,又比”浅复制后两人共有”安全。可以记成:移动 = 转移钥匙 + 作废旧持有者。
关键是:Rust 没有真的去拷贝堆上的字节,只是把”指向堆的钥匙”换了个主人,并让旧主人的钥匙作废。这就明白了为什么移动既高效又安全——高效是因为不碰堆数据,安全是因为旧持有者被禁止使用。
21-5 哪些类型会移动,哪些会 Copy
- 会移动(非 Copy):
String、Vec、以及任何内部含堆数据的类型;还有默认没实现Copy的自定义结构体/枚举。 - 会 Copy(自动复制):所有整数(i32 等)、浮点(f64 等)、bool、char、以及全部由 Copy 类型组成的元组(如
(i32, f64))和定长数组(如[i32; 3])。甚至连不可变引用&T也是Copy的(它只是个指针),但可变引用&mut T不是。
判断口诀:类型大小固定、纯栈上 → Copy,赋值即复制;类型含堆数据、大小运行时定 → 移动,赋值即转移。
NoteRust 有个规则:一个类型如果实现了
Copy,就绝不能同时实现Drop(自定义释放逻辑)。因为Copy意味着”复制即新主人”,而Drop意味着”离开要释放”——两者冲突。编译器替你把关,你不用记太死。所以你写let s2 = s1时,到底复制还是移动,完全由 s1 的类型决定。
21-6 想要两个都可用?用 clone
如果你确实想”复制一份完整的字符串内容”,显式调用 .clone():
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // 深复制,堆上真的新开一份
println!("s1={s1}, s2={s2}"); // 两个都能用
}
clone 会真的把堆上数据也拷一份,开销比移动大。所以 Rust 默认用便宜的移动、把”贵的复制”变成你要主动写的 clone,逼你意识到成本。这是刻意的性能自觉设计:默认便宜且安全,要花大代价必须明说。
Tip见到
clone时心里要有数:它在堆上复制数据,有真实性能成本。不要一遇到”moved value”就无脑clone了事,先想想能不能用下一章的”借用&”来避免复制。clone 是工具,不是逃避借用检查的捷径。
21-7 传参给函数也会移动
把值传给函数,和赋值同理:堆数据移动进函数,Copy 数据复制进函数。
fn main() {
let s = String::from("hi");
take_ownership(s); // s 移动进函数,s 之后失效
// println!("{s}"); // 错
let n = 5;
make_copy(n); // n 是 Copy,复制一份,n 仍可用
println!("n 还在:{n}");
}
fn take_ownership(text: String) {
println!("拿到:{text}");
} // text 离开作用域,堆内存释放
fn make_copy(num: i32) {
println!("num={num}");
}
函数拿到堆数据所有权后,原变量就失效了。函数结束时,参数(所有者)离开作用域,值被 drop。注意 make_copy 里 n 是 i32(Copy),所以 main 里的 n 没受影响。
21-8 函数返回也能转移所有权
函数把值 return 出来,所有权就转移给调用方:
fn main() {
let s = give_back(); // 所有权从函数转移到 s
println!("{s}");
}
fn give_back() -> String {
let text = String::from("owned");
text // 移动给调用者
}
这解决了”函数里创建的值怎么带出来”的问题。配合下一章的”借用”,你能做到”借给别人用、但所有权还留自己手里”,那是更常用的模式。返回值移动出去后,函数内部的局部变量被消费掉,不会重复释放。
Tip看到报错 “borrow of moved value” 或 “value used after move”,就是你在值被移动后还想用它。解决办法有三:要么别移动(用借用
&,下一章讲),要么移动前先clone留个副本,要么让函数把所有权通过return转移回来。先想借用,其次才考虑 clone。
21-9 多次使用与所有权唯一
关于移动,还有个细节:不是所有”看起来像复制”的写法都会移动。比如把一个 String 同时传给两个函数就会编译报错,因为第一个函数拿走所有权后,第二个就没得传了。
fn take(s: String) { /* 拿到所有权 */ }
fn main() {
let s = String::from("hi");
take(s);
// take(s); // 错!s 已经被移动走了
}
这时若确实想给两个函数都用,办法要么先 clone() 出一份给第二个,要么把参数改成 &String(借用,下一章讲)。初学者常撞”value used after move”,根因就是”所有权只能有一个主人”。理解这一点,后面看到相关报错就不慌了。
21-10 一个对照:移动 vs 复制 vs 克隆
| 操作 | 适用类型 | 堆数据是否复制 | 原变量是否可用 |
|---|---|---|---|
移动 let b = a | 非 Copy(如 String) | 否(只转钥匙) | 失效 |
复制 let b = a | Copy(如 i32) | 不涉及堆 | 仍可用 |
克隆 a.clone() | 任何可克隆类型 | 是(深复制) | 仍可用(a 没动) |
这张表把三种”交接”说清楚了。记住:默认是移动或复制(由类型决定),克隆是你主动要的昂贵操作。
21-11 小结
对堆数据,赋值/传参是”移动”——所有权转移、旧持有者失效,以此杜绝双重释放;对 Copy 类型则是复制。要保留两个都可用就用 clone(深复制)。函数传参移动、返回也能移动。但每次移动就失去原变量,太不方便——下一章的”引用与借用”解决这个痛点:借给别人用,所有权不搬家。