内存管理的三种哲学
背景:内存管理的根本矛盾
计算机程序需要在运行时动态地分配内存(用来存储运行时才确定大小的数据),使用完毕后释放内存。这产生了一个根本矛盾:谁来负责释放内存?何时释放?
如果忘记释放,会发生内存泄漏,程序内存越用越多;如果释放后继续使用(use-after-free),会读取到垃圾数据甚至导致安全漏洞;如果重复释放(double-free),会破坏内存分配器的数据结构,导致崩溃。
历史上有三种解决方案:
所有权的三条规则
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)的区别。
Move 语义:所有权的转移
为什么不能简单复制?
当你把一个 String 赋值给另一个变量时,会发生什么?在大多数语言里,这是浅拷贝(复制指针)或深拷贝(复制全部数据)。但 Rust 选择了第三种方式:Move(移动)——所有权被转移,原变量失效。
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 有两种主要的字符串类型,初学者经常对此感到困惑:
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,因为:1) 它既接受字符串字面量,又接受 &String(自动 Deref);2) 不涉及所有权转移,调用方保留数据控制权;3) 零开销。只有在函数需要拥有字符串、需要修改它,或者需要存储它时,才使用 String。