前置知识: Rust

所有权与借用

8 min入门

Rust 核心机制:所有权规则、移动与复制、借用与引用、切片、生命周期与内存安全

1. 从”图书馆借书”说起:为什么需要所有权

1.1 内存管理的三难问题

任何语言都要回答一个问题:谁负责内存的分配与释放? 三种流派各有取舍:

流派代表语言机制问题
手动管理C/C++程序员 malloc/free悬垂指针、double-free、内存泄漏
垃圾回收Java/Go/Python运行时 GC停顿、内存开销
所有权Rust编译期静态检查学习曲线陡

Rust 选择了第三条路:所有权(Ownership)——在编译期确定每个值的生命周期,既无 GC 停顿、也无手动释放,安全且零开销。

1.2 图书馆的类比

想象图书馆的管理规则:

  • 每本书只有一个”借书人”(值有且只有一个所有者)
  • 借书人离开图书馆时必须把书放回(所有者离开作用域,值自动释放)
  • 借书人可以把书转借给另一个人,转借后原借书人失去资格(所有权转移)

Rust 的所有权系统就是这套”图书馆规则”:编译器像一个严格的图书管理员,在代码编译时逐行检查”谁在管理这本书”,任何违规(比如两个人都声称拥有这本书)直接拒绝编译。

Rust 的承诺:所有内存错误(空指针、悬垂引用、数据竞争、缓冲区溢出)在编译期就被拒绝——不是”尽量安全”,而是”编译不过”。

2. 所有权三规则

规则一:每个值有且只有一个所有者(owner)变量。

规则二:所有者离开作用域时,值被自动释放(drop)。

规则三:值可以被转移(move)给新的所有者,旧所有者随即失效。

fn main() {
    let s = String::from("hello"); // s 是 String 的所有者
    println!("{}", s.len());
} // 此处 s 离开作用域,String 的内存自动释放

解读:

  • String::from 在堆上分配内存,s 持有它的所有权
  • 无需手动 free——离开作用域即析构(Rust 自动调用 drop)
  • 栈上的整数等类型同样适用此规则,只是释放成本趋近于零

关键认知:Rust 没有 GC,内存释放靠”所有者离开作用域”这个可预测的时机。这就是为什么 Rust 能做到”零开销抽象”。

3. 移动(Move)与复制(Copy)

3.1 移动语义

let s1 = String::from("hello");
let s2 = s1;              // 所有权转移(move)
// println!("{s1}");      // 错误:s1 已失效
println!("{s2}");         // 正常

为什么不能再用 s1:let s2 = s1 没有深拷贝堆数据,只是把”指针+长度+容量”这三块栈数据转移给 s2,并让 s1 失效。

这避免了两个严重问题:

  1. double-free:如果 s1、s2 都有效,离开作用域时同一块内存会被释放两次(崩溃)
  2. 悬垂指针:如果 s1 先被释放,s2 就成了悬垂引用

代价是零:转移只是拷贝几个字节的栈数据,堆数据原封不动。而编译器会阻止继续使用 s1——移动不是隐藏的深拷贝,而是所有权转移。

3.2 Copy 类型

像整数、布尔、浮点这样的”纯栈上数据”,赋值是按位复制,不会移动:

let a = 5;
let b = a;   // a 仍然可用,因为 i32 实现了 Copy
println!("{a} {b}"); // 输出 5 5

Copy 与 Move 的判断标准:

  • 实现了 Copy trait 的类型(标量、元组内全 Copy、&T 引用):赋值即复制,原变量仍可用
  • String、Vec 等堆类型:实现的是 Move,赋值后原变量失效

规则:实现 Copy 的类型赋值后原变量仍可用,否则原变量失效。String 不能实现 Copy,因为深拷贝代价高;移动则是零成本的”改名”。

3.3 函数传参与返回

fn take(s: String) { /* 消耗传入的所有权 */ }
fn give() -> String { String::from("new") }

fn main() {
    let s = String::from("x");
    take(s);            // s 的所有权被函数消耗
    // println!("{s}"); // 错误:s 已 move 进函数

    let t = give();     // 返回值转移所有权给 t
}

模式:把所有权交给函数(消耗)、让函数返回所有权(产出),是 Rust 管理资源的基本节奏。但每次都这样传来传去很繁琐——借用就是为此而生。

4. 借用与引用

4.1 不可变引用:只借不拿

不想转移所有权、只想”借来看看”,用引用 &T:

fn calc_len(s: &String) -> usize {
    s.len()          // 只读访问,不获取所有权
}

fn main() {
    let s = String::from("hello");
    let len = calc_len(&s);
    println!("{len}");       // s 仍可用
}

解读:&s 创建不可变引用(借用),借出期间原所有者不受影响,借完自动归还。可以同时存在多个不可变引用(多个读者同时看书没问题)。

4.2 可变引用:独占借用

fn push_hello(s: &mut String) {
    s.push_str(", world");
}

fn main() {
    let mut s = String::from("hello");
    push_hello(&mut s);
    println!("{s}");
}

核心约束(Rust 内存安全的关键):

同一时刻,一个值要么有多个不可变借用,要么只有一个可变借用。二者不可同时存在。

let mut s = String::from("hi");
let r1 = &s;          // 不可变借用,可以
let r2 = &s;          // 多个不可变借用,可以(读读不冲突)
let r3 = &mut s;      // 错误:已有不可变借用时不能再创建可变借用

这条规则在编译期消灭了数据竞争:

  • 数据竞争 = 多个线程同时读写同一内存
  • Rust 规则:写(可变借用)必须独占,读(不可变借用)可以并行
  • 无论单线程还是多线程,这条规则都成立——数据竞争在编译期就被拒绝

4.3 借用作用域(NLL)

let mut s = String::from("hi");
let r = &s;              // r 的借用开始
println!("{r}");         // 最后一次使用 r
let m = &mut s;          // 此时 r 已不再使用,可以创建可变借用
m.push_str("!");

借用结束于最后一次使用(NLL,非词法生命周期)。上例中 r 打印后就不再被使用,可变借用随后合法。

实用技巧:借用冲突时,通常可以”缩小借用作用域”解决——把借用的使用范围控制在最小区域,让借用尽早结束。

5. 切片(Slice):数据的”窗口视图”

切片是对连续数据的一段借用视图,无所有权。字符串切片 &str 是最常见的:

let s = String::from("hello world");
let hello = &s[0..5];     // "hello"
let world = &s[6..];      // "world"(6 到末尾)
println!("{hello} {world}");

注意(字节 vs 字符):切片范围是字节索引。中文字符占 3 字节,按字节切可能 panic(多字节边界需谨慎)。处理中文建议用 .chars() 迭代。

字符串字面量本身就是 &str:

let greeting: &str = "你好";  // "你好" 是编译期内置的 &str

数组切片:

let arr = [1, 2, 3, 4, 5];
let mid = &arr[1..4];       // [2, 3, 4],类型 &[i32]
for v in mid {
    println!("{v}");
}

5.1 参数用切片而非引用(重要最佳实践)

函数接收参数时优先用切片而非 &String/&Vec,因为 &str/&[T] 能同时接收字面量、String、数组,通用性更强:

fn first_word(s: &str) -> &str {
    match s.find(' ') {
        Some(i) => &s[..i],
        None => s,
    }
}

fn main() {
    let s = String::from("hello world");
    println!("{}", first_word(&s));      // 传 &String 可自动转为 &str
    println!("{}", first_word("hi ok")); // 直接传字面量
}

为什么参数写 &str 最灵活:

  • &String 可自动强转为 &str(deref coercion)
  • 字面量、&String、&Vec 都能传给 &str 参数
  • 返回的切片生命周期与输入绑定,保证不会悬垂

6. 生命周期:编译器怎么知道”引用还活着”

6.1 为什么要生命周期标注

引用必须保证”被引用的值还活着”。多数时候编译器能自动推断(NLL),但某些情况下需要显式标注:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

解读:

  • 'a 是一个生命周期参数,声明”x、y、返回值共享同一个生命周期”
  • 含义:返回值的存活时间,不会超过 x 和 y 中较短的
  • 编译器用这个约束检查调用点:如果返回值在某个参数失效后还在用,编译失败

6.2 三种常见模式

模式写法场景
省略(自动)fn f(x: &str) -> &str单个输入引用,返回其引用
多个输入需标注fn f<'a>(x: &'a str, y: &'a str) -> &'a str多个引用,需关联
结构体含引用struct S<'a> { s: &'a str }结构体持有引用

实用建议:生命周期标注是”编译器需要帮助时的工具”。90% 的代码用省略规则自动推断;只有返回引用且涉及多个输入时,才需要显式标注。不必一开始就掌握全部细节,先理解”生命周期防止悬垂引用”这个核心思想即可。

7. 综合示例:统计单词数

fn count_words(text: &str) -> usize {
    text.split_whitespace().count()
}

fn main() {
    let text = String::from("Rust ownership is safe");
    println!("{}", count_words(&text)); // 输出 4
    println!("{}", count_words("你好 Rust")); // 输出 2
}

解读:全程只借用不拷贝;split_whitespace 返回迭代器直接数个数,零分配。所有权系统的收益在此体现:简洁、安全、无 GC、无手动释放。

8. 常见错误与对策

编译错误原因对策
use of moved value使用了已转移所有权的变量改用引用传参,或 clone 一份
cannot borrow as mutable同时存在不可变与可变借用缩小借用作用域,或调整借用顺序
cannot move out of borrowed content尝试从借用中拿走所有权用 clone 或返回引用
temporary value dropped引用指向了临时值用变量持有临时值再借用
lifetime may not live long enough返回值可能悬垂检查返回值是否关联输入的生命周期

通用调试手段:

  1. 遇到借用错误时,按编译器提示信息(E0502/E0505 等)逐条阅读
  2. rust-analyzer 会标注问题行
  3. 必要时用 clone() 快速通过,再回头优化为引用
  4. 先编译通过,再优化借用——编译器是最好的老师,它的提示几乎总是指向正确方向

9. 小结

所有权三规则(每值一主、主离即释、可转不移)+ 借用两条约束(不可变可并行、可变要独占)+ 切片视图(零拷贝的窗口)+ 生命周期(防止悬垂),构成了 Rust 内存安全的地基。

理解”移动 vs 复制""借用 vs 拥有”两组对立概念,就能读懂编译器的大部分报错——Rust 编译器不是敌人,而是全天候的导师。下一步学习结构体、枚举与模式匹配(见《结构体、枚举与模式匹配》),把这些机制组合成真实的数据结构。

一句话记忆:Rust 用”所有权”替代”手动管理/GC”——每个值一个主人、主人离开作用域自动释放、转移所有权后旧主人失效;借用让”只借不拿”(&T 可多个,&mut T 要独占)成为可能,编译期就消灭了悬垂引用与数据竞争。