← 返回模块
3.5.1.3beta 可读 · 未来付费校验通过内容版本 2026-05-27

所有权、借用与生命周期

3.5.1 · Rust 基础 · 编程

国内某头部券商自营 IT 把 C++ 中频交易框架的 FIX-to-内部协议适配层切到 Rust 上来; 第一周接手的资深工程师周三被 error[E0382]: borrow of moved value 卡了三次, 周四被 error[E0499]: cannot borrow as mutable more than once 卡了两次, 周五看到一行陌生的 'a 生命周期标注一时读不动。到下周一她已经回到了原来的产出节奏, 而且会告诉你: 借用检查器 (borrow checker) 现在每天会拦下她在 C++ 里十五年没意识到自己在犯的错。本课正是把那一周「跟编译器较劲」转化成接下来一年「靠编译器顶着」的关键对话。你之后写的每一行 Rust 都会被本课所讲的内容塑形。

三条规则

(1) 每个值有且仅有一个 owner (所有者)。(2) owner 出作用域时, 该值被 drop——其析构 (Drop trait 的 drop 方法) 运行, 它持有的资源 (堆内存、打开的文件、锁、socket) 都被释放。(3) 一个值可以通过引用被「借用 (borrow)」, 但借用检查器在程序任意一点上, 要么允许存在 一个 可变借用 (&mut T), 要么允许存在 任意数量 的共享借用 (&T), 但二者不能同时存在。这三条规则联合起来, 在编译期就消除了「释放后使用 (use-after-free)」、「重复释放 (double-free)」、「迭代器失效 (iterator invalidation)」和线程间「数据竞争 (data race)」整一类 bug。C++ 端要靠 RAII 加纪律加 sanitizer 加 code review; Rust 端编译器直接拒绝产出二进制。

规则 (1): 移动 (move)

下面是 Fenced Rust 块, 把 String 的 move 错误与 Copy 类型的对照例并排放:

let s = String::from("hello");
let t = s;
// println!("{}", s);  // compile error: E0382 borrow of moved value

let x: i64 = 5;
let y = x;
println!("x = {}, y = {}", x, y);  // compiles fine — i64 is Copy

String::from("hello") 构造一个堆上的 UTF-8 字符串, s 是它的 owner。let t = s; 这一行 ​不是​ 拷贝——String 不实现 Copy (它持有堆分配, 按位拷贝会出现「两个 owner 指向同一块堆」违反规则 (1))——所以这次赋值是一次 移动 (move): 所有权从 s 转给了 t, s 之后不可再用。任何对 s 的后续使用都会触发标志性的 E0382: borrow of moved value: \s`` 错误, 这是 Rust 里见到频率最高的一种错误, 你必须带着完整上下文亲手读它一次。

对照整型版本: i64 实现 Copy, 它整个值都在栈上, 按位拷贝就产出一个独立的第二份, 所以 let y = x; 之后继续用 x 与 C++ / Python 直觉一致。Copy trait 正是把这两种情况区分开的标记。工作中你会碰到的 Copy 类型: 所有整数、所有浮点、boolchar、共享引用 &T (永远 Copy——可以把指向数据的指针拷一份, 但不是拷数据本身), 以及组件全部 Copy 的元组与数组。其余一切——StringVec<T>Box<T>FileMutex<T>、默认情况下用户自定义的 structenum——都是 move。

规则 (2): Drop 自动且承重

在 C++ Fundamentals 里你手写 class FileHandle { ~FileHandle() { fclose(fp_); } }; 来保证退出作用域时文件被关闭——RAII 的核心就是「语言替你调析构」。Rust 把同一思路推得更远: std::fs::FileDrop 关 fd; Vec<T>Drop 释放堆; MutexGuard<T>Drop 释放锁——而你几乎不必自己写 Drop 实现, 因为标准库已经为所有「拥有资源的拥有型类型」都实现好了。编译器在你的代码生成期把析构调用插入正确的位置, 你的源代码里没有任何「关」的字眼。

Box<T> 现在记一段就够, 细节后面学: Box::new(x)x 移到堆上、给你一个 Box<T> owner, 其 Drop 释放堆分配。lesson 4 里你会在递归 enum 中见到它, 3.5.2 里你会在 trait object 讨论中再见到。

规则 (3): 借用——日常代码的承重机制

绝大多数 Rust 代码把数据传进函数都不是靠所有权转移, 而是靠引用:

fn len(s: &String) -> usize {
    s.len()
}

fn push_char(s: &mut String, c: char) {
    s.push(c);
}

fn main() {
    let mut greeting = String::from("hello");
    let n = len(&greeting);
    push_char(&mut greeting, '!');
    println!("len = {}, greeting = {}", n, greeting);
}

&greeting 创建一个共享借用; &mut greeting 创建一个可变借用。共享借用存在期间可以再有任意数量的共享借用, 但不能有可变借用; 可变借用存在期间不能有任何其他引用。编译器在每一行代码上跟踪这些借用的生命周期, 一旦违反就拒绝编译。

借用检查器要拦下的典型 bug:

fn bad(a: &mut String, b: &mut String) {
    a.push_str(b);
}

fn main() {
    let mut x = String::from("hello");
    // bad(&mut x, &mut x);  // compile error: E0499 cannot borrow `x` as mutable more than once at a time
    let _ = x;  // silence unused-variable warning
}

「同一变量被两次可变借用」在 C++ 里编得过, 加线程后就是经典的 data race; 国内某私募中频系统两年前在 C++ 路径上吃过一次完全同源的内存安全 bug, 复盘后整组迁 Rust 的决议就此定下。Rust 编译器直接拒绝产出二进制。E0499 是你的朋友; 看清它指的是哪两段借用, 把函数体拆成两个作用域、让每段借用活在更小的区域里, 就能让借用检查器顺畅工作。

四个 「引用相关」 的类型

String 是拥有型 (owned)、堆上、可增长、可变的; 它持有自己的 UTF-8 字节。&str 是对别人字节的共享借用——要么借自一个 String, 要么借自二进制只读数据段 (字符串字面量如 "hello" 就住在那里, 所以字面量的类型是 &'static str, 一个活到整段程序结束的共享借用)。&mut str 存在但少见。Vec<T> 是拥有型、堆上、可增长的; 它持有自己的元素:

let v: Vec<i64> = vec![1, 2, 3, 4, 5];
let whole: &[i64] = &v[..];
let middle: &[i64] = &v[1..4];
println!("whole = {:?}, middle = {:?}", whole, middle);

&[T] 是对一段连续 T 元素的共享借用——可以来自 &Vec<T>&[T; N] (对数组的借用)、或子区间 &v[2..5]。给工作中的概念点: 函数签名只读输入时, 优先用 &str&[T]——这样调用方可以传任何来源 (一个 String、一个字面量、一个子切片、一个 Vec<i64>、一个数组), 不必为了你的函数额外分配; 一个能在整个 codebase 内复用的函数签名与一个迫使每个调用方先 materialise 一个新 String / Vec 的签名, 差别就在这里。

生命周期, 轻量地讲

你写的大多数函数签名都不用显式标 'a, 因为编译器会用 生命周期省略 (lifetime elision) 规则推断。省略规则覆盖两种最常见情形: 单输入引用、输出引用与之共享生命周期; 含 &self 的方法、输出引用与 self 共享生命周期。省略失败的场景是: 函数有多个输入引用、输出引用从其中之一派生——编译器猜不到是哪一个——这时你才自己写 'a:

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

fn main() {
    let s1 = String::from("long string");
    let s2 = String::from("short");
    let result = longest(s1.as_str(), s2.as_str());
    println!("longest = {}", result);
}

把签名读出来: 「对于任意生命周期 'a, 给两个至少活得和 'a 一样长的 string slice, 这个函数返回一个也至少活得和 'a 一样长的 string slice」。标注本身不延长任何值的生命周期; 它只承诺输入与输出之间的关系。'static、对泛型加生命周期 bound、higher-ranked trait bounds (HRTBs)、for<'a> 语法都留到 3.5.2 / 3.5.4; 本课一个例子加一条「省略失败时怎么写」的规则就够。

unsafe, 提一次

unsafe 存在。unsafe { ... } 块在局部允许五种安全语言禁止的操作: 解引用裸指针、调用 unsafe fn、读 / 写可变 static、访问 union 字段、实现 unsafe trait。生产 Rust 把 unsafe 包成一个小的安全 API、把不安全表面收敛到少数经过审计的模块——Vec<T> 内部用 unsafe 写但对外是安全的公开 API, 这就是你要做 C 互操作、手写 lock-free 队列、调用 kernel-bypass NIC 驱动时该用的模式。细节留到 3.5.4 Interop & Production Rust。本模块全程不写 unsafe

行业背景与过渡

国内 PingCAP TiKV、蚂蚁链、字节火山引擎以及部分私募的 Rust 代码里, 三个 ownership 相关的 code-review 反馈反复出现: 第一, 「这里应该取 &str 不要 String」——避免不必要的克隆; 第二, 「这里返回 &T 不要 T.clone()」——让生命周期做克隆原本掩盖的事; 第三, 「这里的可变借用作用域太长, 拆成两块」——主动配合借用检查器把每个 &mut 的生命周期收窄。上面的 worked example 把这三类各演示了一次。

Lesson 4 在你刚学到的基础上搭复合类型工具箱: structenumOption<T>Result<T, E>、trait、以及把错误链式传播的 ? 操作符。所有权依然贯穿每个例子, 只是层级更高——因为你已经知道 let t = s;&mut x 究竟意味着什么。

练习

Exercise

写一个函数 fn concat_with_sep(parts: &[&str], sep: &str) -> String, 把一组字符串切片用分隔符连起来, 返回新分配的 String (例如 concat_with_sep(&["a", "b", "c"], ", ") 返回 "a, b, c")。(a) 函数必须正好两个参数, 且 parts 必须是 &[&str] (借用的切片, 里面的元素也是借用的字符串切片) 而不是 Vec<String>。(b) 返回值必须是拥有型 String, 不能是 &str。(c) 用 String::new() 起累加器, 配 for i in 0..parts.len() 循环、push_str、最后一个元素后不要追加分隔符的判断。(d) 在 main 里用字面量切片 &["alpha", "beta", "gamma"] 与分隔符 " | " 调用并打印结果。(e) 再写一个一行的错误版本 fn concat_bad(parts: &[&str]) -> &str { parts[0] }——在函数体上方加一行注释解释: 为什么借用检查器接受这个签名 (输出的 &str 借自 parts, 而 parts 借自调用方), 但为什么类似地返回 String::new().as_str() 会编译失败。

提示
关于 (c): 先 let mut out = String::new();, 然后 for i in 0..parts.len() { out.push_str(parts[i]); if i + 1 < parts.len() { out.push_str(sep); } }, 最后 out (无分号即返回)。
提示
关于 (e): 单输入引用、输出引用与之共享生命周期; 若返回 String::new().as_str(), 输出的 &str 借自一个表达式结束就被 drop 的临时 String, 返回的引用比被引用的值活得更久, 编译器拒绝。