Skip to content

所有权

本章是整个教程的学习曲线顶点:把「谁来释放内存」变成编译期可判定的问题。 前置知识:环境搭建与工具链变量与流程控制。 基准环境:rustc 1.98.1 / cargo 1.98.1Rust 2024 edition,Windows 11 + PowerShell。

本章目标

  • 能说清所有权(ownership)要解决的到底是哪个问题,以及它凭什么是「零运行时开销」的。
  • 能背下三条所有权规则,并画出 String 在栈/堆上的 ptr/len/capacity 布局。
  • 能解释 let b = a;String 是 move(移动)、对 i32 是 Copy(复制),并说明 CopyDrop 为什么互斥。
  • 能识别并读懂 E0382E0502E0499E0505E0515E0716E0106 七类核心报错,每类至少会一种修法。
  • 能写带生命周期参数(lifetime parameter)的函数与结构体,理解 'a 描述的是引用之间的关系而不是「活多久」。
  • 能主动选择 &str 而不是 &String&[T] 而不是 &Vec<T> 作为参数类型。
  • 能掌握一套可复用的借用检查器调试流程,而不是靠瞎加 clone() 硬凑。


为什么需要所有权

内存的两块地盘:栈与堆

任何语言都要回答两个问题:值放在哪谁负责回收。先看地基。

        栈(stack)                         堆(heap)
  ┌──────────────────────┐          ┌───────────────────────────────┐
  │ 固定大小、编译期已知  │          │ 大小可变、运行期向分配器申请   │
  │ 后进先出(LIFO)      │          │ 无序、靠地址(指针)访问       │
  │ 分配 = 移动栈指针     │  ptr ──► │ 分配 = 找空闲块(可能系统调用)│
  │ 释放 = 移动栈指针     │          │ 释放 = 归还给分配器            │
  │ 极快、不可能失败      │          │ 较慢、可能失败(OOM)          │
  └──────────────────────┘          └───────────────────────────────┘

关键差别不是「快慢」,而是栈上的值在编译期就知道何时消失(离开作用域即消失), 堆上的值不行 —— 堆块的生命周期是运行期决定的,因此必须有某种机制来回收它。

🧠 原理:Rust 的所有权系统做的唯一一件事,就是把「堆块何时释放」这个运行期问题, 变成「哪个栈上变量持有它」这个编译期问题。一旦能静态确定持有者,释放时机就静态确定了。

值语义(value semantics)与引用语义(reference semantics)

  值语义:变量里装的就是数据本身            引用语义:变量里装的是「通往数据的地址」
  ┌──────────────┐                          ┌──────────────┐
  │  a = 42       │  a 就是 42               │  r ──────────┼──► ┌──────────┐
  └──────────────┘                          └──────────────┘    │ 对象/堆块 │
  b = a  ⇒ b 也有一份独立的 42                                  └──────────┘
                                            b = r  ⇒ b 和 r 指向同一块数据
  • 值语义(C 的 struct、C++ 的 int、Go 的数组、Rust 的默认行为):赋值即拷贝内容
  • 引用语义(Java 的对象、Python 的一切、JS 的对象、C# 的 class):赋值只拷贝地址

Java/Python 的问题恰恰出在引用语义上:别名(aliasing)。你手上有两个名字, 不知道还有谁也在改同一块数据,于是出现「改了 A 结果 B 也变了」的幽灵 bug。 Java 试图用 final、不可变类(Stringrecord)、Collections.unmodifiableList 来缓解, 但这些都是约定和运行期包装,编译器不会强制你。

Rust 的答案非常激进:值语义是默认的,要共享就显式写借用(&), 而且共享(&T)与修改(&mut T)在编译期互为排他。

💡 对照:Python 的 b = alist 是共享,对 int 是共享但不可变(看起来像拷贝)。 Rust 里 let b = a;Vec<T>移动(移动后 a 不可用),对 i32 是拷贝。 两者都由类型特征(Copy)在编译期决定,没有「小整数碰巧不可变」这种巧合。

C++ 的三条路:RAII、悬垂、双重释放

C++ 用 RAII(Resource Acquisition Is Initialization,资源获取即初始化)把释放绑到析构函数上, 这是伟大的发明,Rust 的 Drop 直接继承了这个思想。但 C++ 留给你三个必须自己盯住的坑:

cpp
// C++ 伪代码,仅用于说明问题,不是本章要跑的代码
void bad() {
    std::string* p = new std::string("hi");
    delete p;
    std::cout << *p;          // 悬垂指针(dangling pointer):读已释放内存,UB
}
void worse() {
    std::string* p = new std::string("hi");
    std::string* q = p;
    delete p;
    delete q;                 // 双重释放(double free):堆元数据被破坏
}
// 提前 return / 抛异常时忘记 delete ⇒ 内存泄漏(leak)

C++ 的补救手段是智能指针:std::unique_ptr(独占)、std::shared_ptr(引用计数)、 std::weak_ptr(打破循环)。但这些都是库类型,你可以选择不用; 而且 shared_ptr 有运行期计数开销,循环引用还会泄漏。

Java / Python 的路:GC 与共享可变状态

Java 的 GC(Garbage Collector,垃圾回收器)和 Python 的引用计数 + 分代 GC 彻底解决了悬垂与双重释放 —— 它们把「谁还活着」推迟到运行期用图可达性(reachability)计算。

代价是:

  1. 运行期成本:GC 线程、写屏障(write barrier)、STW(Stop-The-World)停顿; Python 的引用计数还要在每个对象头上加计数器,并处理循环引用。
  2. 内存放大:每个对象都有对象头(object header),值类型被装箱(boxing)。
  3. 共享可变状态(shared mutable state)依然无解:GC 只管「回收」, 不管「谁能改」。两个线程拿着同一个 ArrayList 互相踩,GC 一声不吭。

Rust 的选择是:用编译期所有权替换 GC,也替换手动 free

     手动管理            GC                所有权(Rust)
  ┌────────────┐   ┌────────────┐     ┌────────────────────┐
  │ C / C++    │   │ Java/Python│     │ 编译期证明唯一持有  │
  │ free/delete│   │ 运行期追踪 │     │ 离开作用域自动 drop │
  ├────────────┤   ├────────────┤     ├────────────────────┤
  │ 悬垂/双释/ │   │ 运行期开销 │     │ 编译期开销(变慢)  │
  │ 泄漏       │   │ 停顿       │     │ 运行期 0 开销       │
  └────────────┘   └────────────┘     └────────────────────┘

🧠 原理:「零开销」指的是运行期零开销:没有引用计数增减、没有 GC 扫描、 没有看门狗线程。代价挪到了编译期 —— 编译变慢,且你必须写编译器能证明的代码。 这是 Rust 最核心的取舍(trade-off),后面所有「借用检查器不让我过」的痛苦都源于此。

🚀 进阶Rc<T>/Arc<T> 提供了引用计数(〈智能指针与闭包〉一章), 相当于「在需要时用一小块 GC 换灵活性」。Rust 不是没有 GC,而是默认没有



三条所有权规则

规则正文

规则 1:Rust 中每一个值(value)都有一个所有者(owner)。
规则 2:同一时刻只能有一个所有者。
规则 3:当所有者离开作用域(scope),这个值就被丢弃(drop)。

三条规则合起来就得到一个推论:值在唯一确定的位置、唯一确定的时间被释放。 编译器不需要运行任何东西就能算出这个位置和时间。

String 在内存里到底长什么样

String 是最适合讲所有权的类型,因为它在堆上有数据,而 &str / i32 讲不出这点。

rust
fn main() {
    let s = String::from("hello");
    println!("{s}");
}
  变量 s(栈上,24 字节固定)              堆上(分配器给的缓冲区)
 ┌──────────────┬──────────┬───────────┐   ┌───┬───┬───┬───┬───┐
 │ ptr ─────────┼──────────┼───────────┼──►│ h │ e │ l │ l │ o │
 │ 0x7ffd_1a20  │ len = 5  │ cap = 5   │   └───┴───┴───┴───┴───┘
 └──────────────┴──────────┴───────────┘    ^ 这 5 字节是堆内存,归 s 所有
   指针(8B)      长度(8B)    容量(8B)
字段含义变化时机
ptr指向堆缓冲区的地址重新分配(reallocate)时变化
len当前实际内容长度(字节)push_str 时增长
capacity已向分配器申请的总容量(字节)len > capacity 时翻倍增长
rust
fn main() {
    let mut s = String::from("hello");
    println!("len={} cap={}", s.len(), s.capacity());   // 输出:len=5 cap=5
    s.push_str(" world");
    println!("len={} cap={}", s.len(), s.capacity());   // 输出:len=11 cap=11
    s.push('!');
    println!("len={} cap={}", s.len(), s.capacity());   // 输出:len=12 cap=22
}

输出:len=5 cap=5len=11 cap=11len=12 cap=22。 第三次 len 超过 capacity,Rust 申请了 22 字节(近似翻倍),把旧数据搬过去,再释放旧缓冲区。 这正是为什么 push 之后旧引用会失效ptr 变了。

⚠️ 陷阱capacity 的增长倍率是实现细节,官方文档不保证。不要写依赖 cap == 22 的逻辑。

💡 对照:Java 的 String 不可变,StringBuilder 有类似的 capacity 概念; Python 的 str 不可变,list 有类似「多留一点空间」的 append 优化。 Rust 的 StringVec<u8> 的薄包装,把「缓冲区扩容」这件事暴露给你看

规则 3 的实证:作用域结束就 drop

rust
fn main() {
    {
        let s = String::from("block-local");
        println!("块内可用:{s}");
    }   // 这一行的右花括号处调用 drop(s),堆缓冲区返回给分配器
    // println!("{s}");   // E0425: cannot find value `s` in this scope
}

输出:块内可用:block-local。之后 s 在名字层面就消失了, 连「访问已释放内存」的机会都不存在 —— 这就是所有权相对裸指针的本质优势。



move 语义:let b = a; 到底发生了什么

String:移动,不复制堆数据

rust
fn main() {
    let a = String::from("hello");
    let b = a;              // 所有权从 a 转移到 b(move)
    println!("{b}");        // 合法:b 是现在的所有者
    // println!("{a}");     // E0382: borrow of moved value: `a`
}

输出:hello。注释掉的那行会编译失败 —— 这正是本节要讲的错误。

  move 之前                                 move 之后
  a ──┐                                    a(栈槽还在,标记为「已移动」)
      │  ptr ──►┌─h─e─l─l─o─┐                 ╳  不可再使用
      │  len=5   └──────────┘              b ──┐
      │  cap=5                                  │  ptr ──►┌─h─e─l─l─o─┐
                                               │  len=5   └──────────┘
  b(尚未初始化)                              │  cap=5

机器码上发生了什么:只有栈上那 24 字节被逐字节复制(memcpy), 堆上的 hello 一个字节都没动。也不会调用 free。所谓「移动」就是一次浅拷贝(shallow copy) 加上编译期把旧名字作废。

i32:复制,两个变量都可用

rust
fn main() {
    let x = 5;
    let y = x;              // i32 实现了 Copy,这里是复制而非 move
    println!("x={x}, y={y}");   // 两个都合法
}

输出:x=5, y=5

为什么 i32 不移动?因为 i32 完全在栈上,复制它就是复制全部数据, 不存在「两个变量共享同一个堆块,谁该释放」的问题。既然没有所有权问题, 让旧变量失效就是纯粹的麻烦,所以 Rust 直接复制。

Copy trait:谁能复制

Copy 是一个标记 trait(marker trait,没有方法的 trait), 它向编译器承诺:该类型的值可以按位复制(bitwise copy),且复制后新旧值都有效

实现 Copy 的类型(全部在栈上、无堆所有权):

类别例子
整数 / 浮点i32u8usizef64
布尔与字符boolchar
不可变引用&T(注意:&mut T 不是 Copy
函数指针fn(i32) -> i32
元组 / 数组(元素全是 Copy(i32, bool)[u8; 4]
你手动 derive 的结构体 / 枚举(字段全是 Copy#[derive(Copy, Clone)] struct P { x: i32 }
Option<T> / Result<T, E>(泛型参数全是 CopyOption<i32>

不是 Copy 的类型(拥有堆资源或有析构逻辑):

类型为什么不能 Copy
StringVec<T>Box<T>拥有堆缓冲区,复制会导致双重释放
&mut T独占借用必须唯一,复制会破坏别名排他性
FileTcpStreamMutexGuard拥有操作系统资源,复制会双重关闭句柄
任何实现了 Drop 的类型DropCopy 互斥(见下)

CopyDrop 互斥

rust
// 编译失败示例 A(E0204):字段不 Copy,所以整体不能 Copy
#[derive(Copy, Clone)]
struct Bad {
    name: String,
}
// error[E0204]: the trait `Copy` cannot be implemented for this type
//   | struct Bad { name: String }
//   |        ^^^   ------------ this field does not implement `Copy`

// 编译失败示例 B(E0184):有析构函数就不能 Copy
struct AlsoBad;
impl Drop for AlsoBad {
    fn drop(&mut self) {}
}
impl Copy for AlsoBad {}
// error[E0184]: the trait `Copy` cannot be implemented for this type;
//                the type has a destructor

🧠 原理Copy 意味着「一份数据变两份,各自独立」。 若类型有 Drop,那两份数据都会调用析构函数 —— 对 String 就是双重释放。 编译器用「互斥」这一条规则,把 C++ 里最容易出的 double free 直接变成编译错误。

⚠️ 陷阱Clone 不等于 CopyClone 是普通的 trait 方法 fn clone(&self) -> Self, 可以很昂贵(深拷贝堆数据);Copy 是编译器内建的按位复制语义。 所有 Copy 类型都应当实现 CloneCopy: Clone 是 supertrait 约束),但反之不成立。

move 之后使用旧变量:E0382 完整解读

rust
fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
    println!("{}", s1);
}

rustc --edition 2024 的原始输出(已用 1.98.1 实测):

error[E0382]: borrow of moved value: `s1`
 --> src\main.rs:4:20
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |     println!("{}", s1);
  |                    ^^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let s2 = s1.clone();
  |                ++++++++

逐行读这段错误信息:

含义
borrow of moved value: 's1'你使用了一个已经失去所有权的变量
move occurs because 's1' has type 'String', which does not implement the 'Copy' trait根因StringCopy,所以把它赋给别人就是 move
value moved here罪案现场:第 3 行
value borrowed here after move报案现场:第 4 行
help: consider cloning编译器给的一种修法(不是唯一,也不总是最优)

三种修法:

rust
// 修法 A:clone —— 真的复制堆数据,两个变量都可用。代价:O(n) 内存与时间
fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();
    println!("{s1} / {s2}");      // 输出:hello / hello
}
rust
// 修法 B:传引用 —— 双方都不获得所有权,零拷贝。多数场景的最优选
fn main() {
    let s1 = String::from("hello");
    let s2 = &s1;                 // s2 是 &String,只借用不夺取
    println!("{s1} / {s2}");      // 输出:hello / hello
}
rust
// 修法 C:移出作用域 —— 让旧变量在 move 前就用完,之后不再引用它
fn main() {
    let s1 = String::from("hello");
    println!("{s1}");             // 先用完 s1
    let s2 = s1;                  // 再移动;此后 s1 作废
    println!("{s2}");             // 输出:hello
}

🚀 进阶:还有第四种「修法」,是在函数末尾用 s1 就用不上时干脆把 move 当成优化: 把 String 交给别的函数比 clone() 更省,因为 heap 数据原地不动。 判据是:以后还需要 s1 的内容吗?需要 → clone;只是用一下 → &;不再需要 → 直接 move。



函数与所有权

传参即 move(或 Copy)

rust
fn main() {
    let s = String::from("hello");
    takes_ownership(s);            // s 的所有权移入函数
    // println!("{s}");            // E0382: borrow of moved value: `s`

    let n = 5;
    makes_copy(n);                 // i32 是 Copy,n 依然有效
    println!("{n}");               // 输出:5
}

fn takes_ownership(some_string: String) {
    println!("{some_string}");     // 输出:hello
}   // 此处 some_string 离开作用域,drop 掉堆缓冲区

fn makes_copy(some_integer: i32) {
    println!("{some_integer}");    // 输出:5
}

输出:hello55

返回值即转移所有权

rust
fn main() {
    let s1 = gives_ownership();          // 所有权从函数内移出到 s1
    let s2 = String::from("hello");
    let s3 = takes_and_gives_back(s2);   // s2 move 进函数,返回值 move 给 s3
    println!("{s1} / {s3}");             // 输出:yours / hello
    // println!("{s2}");                 // E0382: borrow of moved value: `s2`
}

fn gives_ownership() -> String {
    String::from("yours")
}   // 返回值被移出函数,因此这里不会 drop

fn takes_and_gives_back(a_string: String) -> String {
    a_string                             // 原样返回,所有权回到调用方
}

所有权流转全景图

   main 的栈帧                         函数 foo 的栈帧
  ┌────────────────────────┐         ┌──────────────────────────┐
  │ let s = String::from() │         │                          │
  │   s ──[ptr|5|5]────────┼───┐     │                          │
  └────────────────────────┘   │     │                          │
                               │     │                          │
  调用 foo(s)  ── move ──────┼────►│ fn foo(t: String)        │
                               │     │   t ──[ptr|5|5]──────────┼──┐
                               │     │   println!("{t}")        │  │
                               │     │ }  ← t 离开作用域,drop  │  │
                               │     └──────────────────────────┘  │
                               │                                   ▼
                               │                        ┌─h─e─l─l─o─┐
                               │                        └──────────┘
                               │  若 foo 改为返回 String:
                               │     let s = foo(s);
                               └── 所有权沿返回值移回 main,堆块始终没动过

要点:整张图里堆上的 hello 一次都没有被复制,也没有被提前释放。 只是「谁在栈上拿着那 24 字节描述符」在编译期不断易主。

为什么 C++ 的拷贝构造 / 移动构造在 Rust 里是「显式的」

cpp
// C++:编译器会悄悄帮你调用拷贝构造 / 移动构造,你只能事后分析
std::string f() {
    std::string s = "hi";
    return s;            // 可能触发 NRVO,也可能触发移动构造
}
std::string a = f();
std::string b = a;       // 拷贝构造:a 仍可用,堆上多了一份 "hi"
std::string c = std::move(a);   // 移动构造:a 处于「有效但未指定」状态

Rust 的对照:

C++ 行为Rust 等价物显式性
隐式拷贝构造b = a.clone()必须写 .clone(),编译器绝不偷偷深拷贝
隐式移动构造 / std::movelet b = a;默认行为,不需要 std::move
移动后 a 处于未指定状态a 编译期直接作废静态保证,不会误用
const T& 参数&T 参数语法不同,语义相近
T& 参数(可改)&mut T 参数Rust 额外要求调用方声明 let mut

💡 对照:Rust 里没有对应的 std::move。因为「移动」是默认语义且发生在编译期, 是一个位置(place)的重新解释,不需要运行期函数。 你唯一需要显式决定的是:这次要 move、要 borrow,还是要深拷贝 .clone()

⚠️ 陷阱:C++ 程序员最容易在 Rust 里到处写 .clone() 来「让编译器闭嘴」。 clone() 常常是正确的,但先问一句:我是不是可以传 &s? 一个 &s 通常是零成本,一次 s.clone() 是 O(n)。



延伸阅读


内容以 rustc 1.98.1 · Rust 2024 edition 为基准