Chapter 03

所有权系统深度解析

Rust 最独特、最强大的特性——理解所有权,就理解了 Rust 的灵魂

内存管理的三种哲学

背景:内存管理的根本矛盾

计算机程序需要在运行时动态地分配内存(用来存储运行时才确定大小的数据),使用完毕后释放内存。这产生了一个根本矛盾:谁来负责释放内存?何时释放?

如果忘记释放,会发生内存泄漏,程序内存越用越多;如果释放后继续使用(use-after-free),会读取到垃圾数据甚至导致安全漏洞;如果重复释放(double-free),会破坏内存分配器的数据结构,导致崩溃。

历史上有三种解决方案:

手动管理(C/C++)
程序员显式调用 malloc/free 或 new/delete。控制权最大,但极易出错。现代 CVE 漏洞库中约 70% 的漏洞源于内存安全问题。
垃圾回收(GC)
运行时自动追踪引用计数或可达性,不再使用的内存自动释放。安全,但有 GC 暂停时间(Stop-the-World)和额外内存开销,不适合低延迟系统编程。
所有权系统(Rust)
在编译期通过类型系统追踪每块内存的"所有者",当所有者离开作用域时自动释放内存。既无运行时开销,又保证安全。这是 Rust 的核心创新。

所有权的三条规则

Rust 的整个所有权系统建立在三条简单的规则之上:

所有权三规则(必须牢记)

规则一:Rust 中每个值都有一个所有者(owner)
规则二:同一时刻,每个值有且只有一个所有者
规则三:当所有者离开作用域时,这个值被自动丢弃(drop),内存被释放。

作用域与自动释放

fn main() {
    // s1 进入作用域,Rust 在堆上分配内存
    let s1 = String::from("hello");
    // 使用 s1...
    println!("{}", s1);

    {
        let s2 = String::from("world");
        // 在这个内部块中,s2 有效
        println!("{}", s2);
    }
    // ← s2 的作用域结束,Rust 自动调用 drop(s2),释放堆内存
    // println!("{}", s2); // 编译错误!s2 不再有效

    // s1 在这里仍然有效
    println!("{}", s1);
}
// ← main 结束,s1 被 drop,堆内存释放

这里涉及到 Rust 的一个重要机制:当变量离开作用域时,Rust 自动调用该值的 drop 函数(如果有的话)。String 类型的 drop 函数会释放它持有的堆内存。这个机制叫做 RAII(Resource Acquisition Is Initialization),源自 C++,但 Rust 通过所有权系统让它更加可靠和安全。

栈与堆:内存布局可视化

两种内存区域的本质区别

理解所有权,必须先理解栈(Stack)和堆(Heap)的区别。

栈(Stack) 堆(Heap) ┌─────────────────────┐ ┌─────────────────────────────────────┐ │ 高地址 │ │ │ │ ┌────────────┐ │ │ addr 0x1234: │ │ │ main 帧 │ │ │ ┌─────────────────────┐ │ │ │ s: [ptr]───┼─────┼──────────────┼─>│ h e l l o │ │ │ │ [len=5] │ │ │ └─────────────────────┘ │ │ │ [cap=5] │ │ │ (由 String 分配管理) │ │ └────────────┘ │ │ │ │ 低地址 │ │ 其他堆数据... │ └─────────────────────┘ └─────────────────────────────────────┘ String 在栈上的三个字段: ptr → 指向堆上实际数据的指针 len → 当前已存储的字节数 cap → 已分配的总容量
整数(i32)存储在栈上(固定大小): let x: i32 = 42; 栈帧: ┌──────┐ │ 42 │ ← x 的值直接存在栈上,4 字节 └──────┘ 复制时,直接拷贝栈上的值: let y = x; ┌──────┐ ┌──────┐ │ 42 │ │ 42 │ ← x 和 y 各有独立的副本 └──────┘ └──────┘ x 的栈 y 的栈

Move 语义:所有权的转移

为什么不能简单复制?

当你把一个 String 赋值给另一个变量时,会发生什么?在大多数语言里,这是浅拷贝(复制指针)或深拷贝(复制全部数据)。但 Rust 选择了第三种方式:Move(移动)——所有权被转移,原变量失效。

let s1 = String::from("hello"); 内存状态: 栈 堆 ┌─────────┐ ┌───────────────┐ │ s1 │ │ │ │ ptr ────┼─────────────────>│ h e l l o │ │ len = 5 │ │ │ │ cap = 5 │ └───────────────┘ └─────────┘ let s2 = s1; ← 所有权移动(move),不是复制 移动后的内存状态: 栈 堆 ┌─────────┐ ┌───────────────┐ │ s1 │ (失效/已移动) │ │ │ ptr ────┼──────── × │ h e l l o │ │ len = 5 │ │ │ │ cap = 5 │ └───────────────┘ └─────────┘ ↑ ┌─────────┐ │ │ s2 │ │ │ ptr ────┼────────────────────────┘ │ len = 5 │ │ cap = 5 │ └─────────┘ s1 已失效!若访问 s1 则编译错误。 只有一个所有者(s2)指向这块堆内存。
fn main() {
    let s1 = String::from("hello");
    let s2 = s1;  // s1 的所有权移动给 s2

    // 编译错误!s1 已经被移走(moved),不能再使用
    // println!("{}", s1);
    // error[E0382]: borrow of moved value: `s1`

    println!("{}", s2);  // 可以,s2 现在是所有者
}
// s2 离开作用域,内存被释放(只释放一次!)

这个设计的精妙之处:如果 Rust 允许浅拷贝(两个变量指向同一块堆内存),当两者都离开作用域时,内存就会被释放两次(double-free),这是严重的内存安全问题。Move 语义保证任何时刻都只有一个所有者,从而只释放一次。

函数调用中的所有权转移

fn takes_ownership(s: String) {
    // s 进入这个函数,成为新的所有者
    println!("拥有: {}", s);
}  // s 离开作用域,内存被释放

fn gives_ownership() -> String {
    let s = String::from("hello");
    s  // 返回 s,所有权移出函数
}

fn main() {
    let s1 = gives_ownership();  // s1 获得所有权

    let s2 = String::from("world");
    takes_ownership(s2);          // s2 的所有权移入函数
    // println!("{}", s2); // 编译错误!s2 已被移走

    println!("{}", s1);           // 正常,s1 仍然有效
}
所有权与函数调用

每次将 String(或其他拥有堆数据的类型)传入函数,所有权就转移了,函数返回后原变量失效。这很不方便——难道每次都要把数据传进去再传回来?Rust 的解决方案是借用(borrowing),详见第4章。

Clone vs Copy:显式与隐式复制

Clone:显式深拷贝

如果你确实需要独立复制堆数据,可以调用 clone() 方法——这是一个明确的、可能昂贵的操作:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();  // 显式深拷贝:在堆上创建独立副本

    println!("s1={}, s2={}", s1, s2);  // 两者都有效
    // 此时堆上有两份独立的 "hello" 数据
}

在代码中看到 .clone(),就知道这里发生了可能昂贵的内存分配操作。这种显式性让你在阅读代码时能快速识别性能敏感的地方。

Copy Trait:栈上类型的隐式复制

对于那些完全存储在栈上、大小固定的类型,复制操作代价极低(就是复制几个字节),Rust 通过 Copy trait 标记这类类型,让赋值时自动复制,而不是移动:

fn main() {
    let x = 5;
    let y = x;  // 整数实现了 Copy,这里是复制,不是移动
    println!("x={}, y={}", x, y);  // 两者都有效!

    // 以下类型都实现了 Copy:
    let a: i32 = 42;      // 所有整数类型
    let b: f64 = 3.14;   // 所有浮点类型
    let c: bool = true;  // bool
    let d: char = 'z';   // char
    let e = (1, 2);       // 元组(当所有元素都是 Copy 时)
    let f = [1, 2, 3];   // 数组(当元素是 Copy 时)
}

为自定义类型实现 Copy

// 只有完全由 Copy 类型组成的结构体才能实现 Copy
#[derive(Debug, Clone, Copy)]
struct Point {
    x: f64,
    y: f64,
}

fn main() {
    let p1 = Point { x: 1.0, y: 2.0 };
    let p2 = p1;  // 自动 Copy,p1 仍然有效
    println!("p1={:?}, p2={:?}", p1, p2);

    // String 包含堆数据,不能实现 Copy
    // 如果 Point 中有 String 字段,就不能 #[derive(Copy)]
}

Drop Trait:自定义清理逻辑

理解自动内存释放机制

当值离开作用域时,Rust 调用 drop 方法。你可以为自己的类型实现 Drop trait,在对象被销毁时执行自定义清理逻辑——关闭文件句柄、释放网络连接、写入缓冲区等。

struct CustomResource {
    name: String,
}

impl Drop for CustomResource {
    fn drop(&mut self) {
        println!("释放资源: {}", self.name);
        // 在这里关闭文件、断开网络连接等
    }
}

fn main() {
    let r1 = CustomResource { name: String::from("资源A") };
    {
        let r2 = CustomResource { name: String::from("资源B") };
        println!("使用资源...");
    }  // 打印 "释放资源: 资源B"
    println!("内部块结束后");
}  // 打印 "释放资源: 资源A"
析构顺序

Rust 按照声明顺序的逆序析构(drop)变量,与 C++ 的析构顺序一致。这在处理有依赖关系的资源时很重要——例如,先析构锁,再析构它保护的数据。

String vs &str:两种字符串类型

两种字符串的本质区别

Rust 有两种主要的字符串类型,初学者经常对此感到困惑:

String(拥有所有权的字符串): ┌──────────┐ ┌─────────────────┐ │ ptr ─────┼──────────>│ h e l l o │ (堆上) │ len = 5 │ └─────────────────┘ │ cap = 8 │ └──────────┘ 可增长、可修改、拥有堆内存所有权 &str(字符串切片,借用): ┌──────────┐ │ ptr ─────┼──────────> 指向某处的 UTF-8 字节序列 │ len = 5 │ (可能在堆上、栈上、或程序数据段) └──────────┘ 不可变的视图,不拥有内存,生命周期受限
fn main() {
    // &str:字符串字面量,存储在程序的只读数据段
    let s1: &str = "hello";  // 程序整个生命周期内有效

    // String:堆分配的可增长字符串
    let mut s2: String = String::from("hello");
    s2.push_str(", world");
    s2.push('!');
    println!("{}", s2);

    // &String 可以自动强制转换为 &str(Deref coercion)
    let s3: &str = &s2;   // 借用 s2 中的字符串数据
    println!("{}", s3);

    // 字符串切片
    let hello = &s2[0..5];  // "hello"
    println!("{}", hello);
}
函数参数应该用 &str 还是 String?

通常来说,函数参数应优先使用 &str,因为:1) 它既接受字符串字面量,又接受 &String(自动 Deref);2) 不涉及所有权转移,调用方保留数据控制权;3) 零开销。只有在函数需要拥有字符串、需要修改它,或者需要存储它时,才使用 String