Rust 所有权与借用完全指南:从内存安全到生命周期标注

Rust 让人望而生畏的不是语法,而是它独特的所有权系统。第一次写 Rust 的人,几乎都会被借用检查器(borrow checker)反复拒绝。但一旦把所有权、借用、生命周期这三件事想清楚,你就会理解 Rust 为什么敢承诺"内存安全且零运行时开销"。这篇文章一次性把它们讲透,所有结论都配可运行的例子。

从一个看起来很奇怪的报错开始

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
    println!("{}", s1); // 编译错误:value borrowed here after move
}

同样的代码在 Java、Python、JavaScript、Go 里都能跑。但 Rust 直接拒绝编译。要理解为什么,得先理解 Rust 的核心规则。

所有权的三条规则

  1. 每一个值都有且只有一个所有者(owner)
  2. 当所有者离开作用域,值就被销毁(自动调用 Drop,内存被立刻释放)。
  3. 同一时刻,值只能有一个所有者;赋值或传参会发生所有权转移(move)

这套规则让 Rust 不需要垃圾回收器,就能精确管理堆内存。回看开头的代码:let s2 = s1s1 的所有权移动给了 s2。之后 s1 就失效了,不能再用 —— 编译器从源头上消除了"两个变量指向同一块堆内存,谁来释放"的歧义。

Move、Copy、Clone

不是所有类型都会 move。基础类型(整数、浮点、布尔、char、固定大小的数组/元组)实现了 Copy trait,赋值时是"拷贝"而不是"移动":

let x = 5;
let y = x;          // i32 实现了 Copy,x 仍然可用
println!("{} {}", x, y); // 5 5

// 自定义类型如果所有字段都是 Copy,可以派生 Copy
#[derive(Copy, Clone, Debug)]
struct Point { x: i32, y: i32 }

let p1 = Point { x: 1, y: 2 };
let p2 = p1;        // 拷贝,p1 仍然可用

StringVecHashMap 这些拥有堆内存的类型不实现 Copy。如果你确实需要拷贝它们,要显式调用 .clone():

let s1 = String::from("hello");
let s2 = s1.clone();   // 深拷贝,堆上多了一份数据
println!("{} {}", s1, s2); // OK

这个设计的妙处:所有"昂贵的复制"都必须显式写 .clone(),代价对开发者可见。你不会再不小心写出一段隐式深拷贝大对象的代码。

函数调用中的所有权

fn take(s: String) {
    println!("{}", s);
}   // s 在这里离开作用域,被销毁

fn main() {
    let s = String::from("hello");
    take(s);            // s 的所有权移交给函数
    // println!("{}", s); // 错误:s 已经 move 出去了
}

同样,函数返回也会发生所有权转移:

fn give() -> String {
    String::from("world")   // 返回值的所有权交给调用者
}

fn main() {
    let s = give();
    println!("{}", s);      // s 现在拥有这个 String
}

但每次都靠转移所有权太笨重 —— 我只想让函数"看一眼"这个值,不想交出去。这就需要借用

借用:&T 与 &mut T

借用就是"暂时把值借给别人看",所有权不变。用 & 表示不可变借用,&mut 表示可变借用。

fn calculate_length(s: &String) -> usize {
    s.len()             // 只能读,不能改
}   // s 是借用,离开作用域不销毁底层数据

fn main() {
    let s = String::from("hello");
    let len = calculate_length(&s);  // 借出去
    println!("{} 长度 {}", s, len);   // s 还能用,没被 move
}

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

fn main2() {
    let mut s = String::from("hello");
    append_world(&mut s);    // 可变借用
    println!("{}", s);       // hello, world
}

借用检查器的两条铁律

这是 Rust 安全保证的核心,务必记住:

  1. 同一作用域,要么有任意多个不可变借用,要么只有一个可变借用,二者不可同时存在
  2. 借用不能比被借用的值活得更久(防止悬垂引用)。
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;            // OK,多个不可变借用
println!("{} {}", r1, r2);

let r3 = &mut s;        // OK,r1/r2 已经不再使用("NLL")
r3.push_str("!");

// 但这样不行:
// let r1 = &s;
// let r2 = &mut s;     // 错误:同时存在不可变和可变借用
// println!("{} {}", r1, r2);

这条规则从根本上消除了数据竞争(data race) —— 因为数据竞争的成立需要"同一块内存被多线程同时访问,其中至少一个是写",而 Rust 编译期就禁止了"读和写同时存在"。

悬垂引用?编译器替你拒绝

fn dangle() -> &String {     // 错误:返回了一个对局部变量的引用
    let s = String::from("hello");
    &s
}   // s 在这里被销毁,引用就变成悬垂的了 —— 编译器直接拒绝

很多语言要靠"小心写代码 + Code Review"才能避免悬垂指针,Rust 把它做成了编译错误。

生命周期标注:&'a T 不是吓人,是必要

当函数同时持有多个引用,编译器有时无法自动推断这些引用的"寿命关系",这时需要你用生命周期参数标注。

// 不写生命周期会报错:expected named lifetime parameter
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

这个 'a 不是创造寿命,而是声明一种约束:返回的引用,其寿命必须等于参数 xy 中较短的那个。这样编译器就能检查:"调用方在使用返回值时,xy 都还活着吗?"

fn main() {
    let s1 = String::from("long string");
    let result;
    {
        let s2 = String::from("short");
        result = longest(&s1, &s2);  // result 借用 s2 或 s1
        println!("{}", result);       // OK,s1 s2 都活着
    }   // s2 在这里销毁
    // println!("{}", result);        // 错误:result 可能借用了已死的 s2
}

生命周期省略规则

大多数时候不用写 'a,因为编译器有三条省略规则:

  1. 每个引用参数自动获得自己的生命周期。
  2. 如果只有一个输入引用,所有输出引用的生命周期等于这个输入。
  3. 方法里有 &self&mut self,所有输出引用的生命周期等于 self
// 这两个写法等价
fn first_word(s: &str) -> &str { /* ... */ }
fn first_word<'a>(s: &'a str) -> &'a str { /* ... */ }

智能指针:Box、Rc、Arc、RefCell

所有权和借用规则在某些场景下显得"太严"。比如:递归数据结构(链表、树)在编译期不知道大小,需要堆分配;同一个值需要被多处共享;需要"运行时"决定借用规则。Rust 用一组智能指针来精确地"破例",但每次破例都明码标价。

// Box<T>:把 T 放到堆上,大小已知;适合递归类型
enum List {
    Cons(i32, Box<List>),
    Nil,
}

// Rc<T>:引用计数,允许多个所有者(单线程,不可变共享)
use std::rc::Rc;
let a = Rc::new(String::from("shared"));
let b = Rc::clone(&a);   // 引用计数 +1
let c = Rc::clone(&a);
println!("{} 引用数 = {}", a, Rc::strong_count(&a)); // 3

// Arc<T>:原子引用计数,多线程版本的 Rc
use std::sync::Arc;
use std::thread;
let data = Arc::new(vec![1, 2, 3]);
for _ in 0..3 {
    let d = Arc::clone(&data);
    thread::spawn(move || println!("{:?}", d));
}

// RefCell<T>:把借用规则从编译期推到运行期,违反时 panic
use std::cell::RefCell;
let cell = RefCell::new(5);
*cell.borrow_mut() += 1;
println!("{}", cell.borrow());     // 6

组合用法 Rc<RefCell<T>> 是 Rust 里实现"多个所有者 + 可变性"的经典套路,在写树/图结构时很常用。多线程版本是 Arc<Mutex<T>>

实战:写一个简单的链表

#[derive(Debug)]
enum List {
    Cons(i32, Box<List>),
    Nil,
}

use List::{Cons, Nil};

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
    println!("{:?}", list);
}

// 计算长度,接收不可变引用,递归
fn len(l: &List) -> usize {
    match l {
        Cons(_, rest) => 1 + len(rest),
        Nil => 0,
    }
}

常见错误模式与修复

错误 1:返回闭包但闭包借用了局部变量。

// 错误
fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
    let local = vec![x];
    |n| n + local[0]   // 错误:local 离开作用域就销毁,闭包成为悬垂引用
}

// 修复:用 move 把所有权交给闭包
fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
    let local = vec![x];
    move |n| n + local[0]
}

错误 2:在迭代时修改集合。

let mut v = vec![1, 2, 3];
for x in &v {
    v.push(*x * 2);   // 错误:&v 是不可变借用,push 需要可变借用
}

// 修复:先收集要追加的内容,再统一 push
let to_add: Vec<i32> = v.iter().map(|x| x * 2).collect();
v.extend(to_add);

错误 3:闭包捕获引发的借用冲突。

let mut count = 0;
let inc = || count += 1;       // 闭包持有 count 的可变借用
inc();
// println!("{}", count);       // 错误:闭包还没销毁,这里又借用了
inc();                          // OK
println!("{}", count);          // OK,inc 不再使用

性能:零成本抽象不是口号

所有上述检查都在编译期完成,运行时没有引用计数(Rc / Arc 除外)、没有 GC、没有动态借用检查(RefCell 除外)。最终编译出来的代码,运行时的内存布局和 C 几乎一致 —— 但安全性是 C 完全没有的。这就是"零成本抽象":安全保证完全由编译器在编译期支付,运行期一点不掏。

结构体里存引用:必须显式生命周期

如果一个结构体的字段是引用,编译器无法自动推断这个引用相对于结构体本身的寿命关系,必须由你声明:

// 错:expected named lifetime parameter
struct ImportantExcerpt {
    part: &str,
}

// 对:声明结构体依赖一个生命周期 'a
struct ImportantExcerpt<'a> {
    part: &'a str,
}

// 含义:ImportantExcerpt 实例的存活时间,不能超过它 part 字段所指向的字符串
fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let excerpt = ImportantExcerpt { part: first_sentence };
    println!("{}", excerpt.part);
    // novel 比 excerpt 活得久,所以合法
}

这个规则的工程含义:"借用别人的数据但又想存起来"在 Rust 里是要付出语法成本的。很多新手感觉吃力,真实原因是他们的设计本来该用 String(自己拥有)的地方,他们硬塞了 &str。要存就拥有,要借就借完就走 —— 把这个原则放在前面,生命周期标注就少很多。

'static:活到程序结束

有一个特殊的生命周期 'static,表示"整个程序运行期间都活着"。字符串字面量天然是 &'static str,因为它们存在程序的只读数据段。

let s: &'static str = "hello";  // 编译进二进制,永不释放

// trait 对象的默认生命周期要求是 'static,除非显式标注
fn boxed_str() -> Box<dyn std::fmt::Display> {
    Box::new("hello")            // OK,'static
}

新手常被编译器要求"加 'static"吓到,以为自己代码有大问题。其实大多数情况只是因为传给了一个"会在另一个线程跑或者要存进 trait 对象"的接口 —— 这类接口对生命周期要求严格,加上 'static 让接口知道"这个值会一直存在,放心用"。

实战:用 Drop 实现 RAII

所有权离开作用域就 drop,这个机制让 Rust 自然支持 RAII(资源获取即初始化)。文件、锁、连接的释放都不需要 defertry/finally

struct FileGuard {
    name: String,
}

impl Drop for FileGuard {
    fn drop(&mut self) {
        println!("关闭文件 {}", self.name);
    }
}

fn main() {
    let _g1 = FileGuard { name: "a.txt".into() };
    {
        let _g2 = FileGuard { name: "b.txt".into() };
        // 这里 _g2 离开作用域,自动调用 drop,打印 "关闭文件 b.txt"
    }
    // 这里 _g1 离开作用域,打印 "关闭文件 a.txt"
}

这个机制延伸到锁、数据库连接、网络句柄,都是"创建就持有,作用域结束就自动释放"。再也不会有"我是不是忘了 close 文件"或者"异常路径下锁没释放"的问题 —— 编译器和 Drop 一起替你管。

写在最后

所有权、借用、生命周期是 Rust 的"三条腿",理解的钥匙只有一句:编译器希望你把"这块内存归谁、能被谁读、能被谁写、什么时候释放"这四个问题在源代码里写清楚,清楚到它能机械地验证。一旦你接受这个心智模型,借用检查器就从"挡路的家伙"变成"免费的代码评审员"。它当下让你不爽的每一行报错,都在阻止你写出未来线上某个夜里两点把你叫醒的 bug。

给新手的进阶路径:先把不带生命周期标注的代码写顺(让编译器替你推断);再写涉及多引用、结构体里存引用的场景,主动加 'a;最后挑战 Rc<RefCell<T>> 和并发场景的 Arc<Mutex<T>>。每一阶段卡住时,都问自己一句:"如果这段代码能编过,会出现什么样的不安全?"想通了,你就和编译器站在同一边了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Go 并发编程完全指南:goroutine、channel 与 select 的正确打开方式

2026-5-15 10:47:08

技术教程

TypeScript 泛型从入门到精通:让类型为你工作

2026-5-15 10:55:54

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索