所有权(ownership) 让每个 Rust 值由一个变量或位置负责;所有者离开作用域时,值会按规则析构。
对非 Copy 值进行赋值或按值传参通常会移动所有权,原绑定随后不能再使用;随手添加 clone() 虽能通过编译,却可能改变成本与资源语义。
先决定函数需要取得、读取还是修改值,再分别使用 T、&T 或 &mut T。确实需要独立副本时才调用 clone(),并检查每条析构路径。
是什么,为什么存在
所有权是 Rust 描述值由谁负责、何时转移责任以及何时结束值的一组语言规则。这里的值不只代表堆内存,也可能拥有文件、套接字、锁守卫或其他需要清理的资源。编译器在编译期检查规则,类型的析构逻辑则在运行时离开相应作用域时执行。
每个值都有一个当前所有者,同一份普通拥有型值不能被两个变量同时独立拥有。赋值、按值传参或按值返回可能把责任从一个位置转给另一个位置,这叫作 移动语义(move semantics) 。移动后拒绝旧绑定,能够让同一资源只由一条拥有路径负责释放。
所有者离开作用域时,Rust 会调用值的析构逻辑,然后释放它拥有的资源。这个模式是 资源获取即初始化(Resource Acquisition Is Initialization,RAII) :资源生存期绑定到拥有它的值,而不是依靠垃圾回收器稍后寻找不可达对象。释放时机通常由控制流与作用域直接决定。
并非每次赋值都会让源绑定失效。实现 Copy 的类型在赋值和按值传参时会隐式复制,例如整数、布尔值以及只含 Copy 字段的许多结构体。拥有堆缓冲区的 String 不实现 Copy,所以赋值默认移动;需要另一份字符串数据时,可以显式调用 clone()。
借用(borrowing) 允许函数临时访问值而不取得所有权。&T 表示共享借用,&mut T 表示独占可变借用。所有权回答“谁最终负责结束这个值”,借用回答“谁可以在某段时间访问这个值”。
你会在函数签名、集合迭代、模式匹配、闭包捕获和线程边界中不断遇到所有权。它不是一条要求“永远不要复制”的性能规则,而是一种资源责任模型。先把责任表达准确,再用分析与测量决定是否需要减少复制或分配。
工作原理
理解所有权时,应区分绑定、值与资源。变量名绑定到一个值,值又可能拥有别处的资源;例如 String 值管理一段可增长的 UTF-8 缓冲区。移动 String 会转移管理缓冲区的责任,不会创建第二份字符串内容。
Rust 教材常把基本规则概括为下面三条:
- 每个值都有一个所有者。
- 同一时刻只能有一个所有者。
- 所有者离开作用域时,值会被丢弃。
这三条描述默认的单一所有权。Rc<T> 与 Arc<T> 等类型通过自身 API 建模多个拥有句柄,但“最后一个强所有者负责结束值”的责任仍然明确。共享所有权的具体计数和线程规则属于智能指针主题。
移动是一种静态状态变化
对非 Copy 值执行 let next = current; 后,next 成为所有者。编译器把 current 视为已移动,而不是等到运行时检查它是否还能使用。只要所有控制流路径都能证明绑定重新获得了一个值,就可以再次给旧变量赋值并使用新值。
按值参数也会移动值,因为被调用函数的参数是新的拥有位置。按值返回则把返回值移动给调用方。编译器会消除许多仅为表达语义的搬运,因此语言层面的移动不等于“复制整个堆对象”。
移动可以只发生在结构体的一个字段上。此后未移动字段仍可分别使用,但整个结构体通常不能再作为完整值使用。实现 Drop 的类型受到更严格限制,因为析构函数必须看到完整、有效的 self。
Copy 与 Clone 是不同契约
Copy 是一个没有方法的标记 trait。类型实现它后,普通赋值保持源绑定可用;这种复制不能运行自定义代码。实现 Drop 的类型不能同时实现 Copy,因为隐式复制会让“哪一份负责析构”变得含糊。
Clone 提供显式的 clone() 方法,并允许类型定义如何创建逻辑副本。克隆 String 会复制字符数据;克隆 Rc<T> 只会增加共享所有者计数;克隆自定义类型的语义取决于它的实现。看到 .clone() 时,必须根据接收者类型判断成本和所有权含义。
Copy 也不是“存储在栈上”的同义词。共享引用和一些包含指针的值可以实现 Copy,而某些固定大小的栈上值因为拥有资源或实现 Drop,不能实现 Copy。判断依据是类型契约,不是你猜测的存储位置。
| 操作 | 对非 Copy 的 T | 对 Copy 的 T | 所有权含义 |
|---|---|---|---|
let b = a; | 移动,a 失效 | 复制,a 仍有效 | b 得到一个值 |
consume(a) | 所有权移入函数 | 复制参数值 | 参数按值接收 |
inspect(&a) | 共享借用 | 共享借用 | 调用方保留所有权 |
a.clone() | 显式创建副本 | 显式创建副本 | 语义由 Clone 实现决定 |
drop(a) | 立即消费并析构 | 消费复制出的参数值 | drop 本身只是按值接收 |
借用保留所有权边界
函数只需在调用期间读取值时,通常接收 &T。需要修改、但不需要保留值时,通常接收 &mut T。函数需要存储、转交或最终销毁值时,按值接收 T 才准确表达责任变化。
借用有自己的有效区间,引用不能比所有者活得更久,冲突的共享访问与可变访问也不能重叠。编译器通常在引用最后一次使用后结束借用,而不是机械地延伸到右花括号。更完整的冲突规则和生命周期标注分别由相关主题展开。
Drop 完成责任交接
离开作用域时,Rust 自动调用 Drop::drop,调用方不能直接调用这个方法。需要提前结束值时,标准库的 drop(value) 按值取得它,让值在函数返回前完成析构。对引用调用 drop(&value) 只会结束那个可复制的引用,不会析构被引用值。
局部变量通常按声明的逆序丢弃,结构体字段按声明顺序丢弃。提前 return 和发生栈展开的 panic 也会丢弃已经初始化且仍存活的局部值。std::process::exit、中止进程和引用计数强环等路径可能绕过预期析构,因此所有权保证内存安全,并不保证每个析构函数必然运行。
示例
下面四个程序分别展示按值转移、Copy 与 Clone、借用以及确定性析构。它们都使用 Rust 1.98.0 的本地 cargo run 实际执行,输出块保留真实结果。
把所有权移入函数再返回
approve 按值接收 Ticket,所以调用时所有权从 main 移入函数。返回值又把所有权交还调用方;用同名绑定接住结果,可以把一次状态转换写成连续流程。
#[derive(Debug)]
struct Ticket {
id: u32,
status: String,
}
fn approve(mut ticket: Ticket) -> Ticket {
ticket.status = String::from("approved");
ticket
}
fn main() {
let ticket = Ticket {
id: 42,
status: String::from("pending"),
};
let ticket = approve(ticket);
println!("ticket {}: {}", ticket.id, ticket.status);
}ticket 42: approved第一个 ticket 在调用后已移动,第二个同名绑定拥有返回的值。这种遮蔽不会恢复旧值,只是让新所有者沿用领域中合适的名称。如果 approve 不需要保留或返回票据,改成接收 &mut Ticket 可以让调用方始终持有所有权。
比较隐式复制与显式克隆
整数实现 Copy,所以 retry_limit 在赋值后仍可使用。Shipment 包含 String,不能实现 Copy;派生的 Clone 则允许明确创建一份独立货运记录。
#[derive(Clone, Debug)]
struct Shipment {
route: String,
priority: u8,
}
fn main() {
let retry_limit = 3;
let copied_limit = retry_limit;
let original = Shipment {
route: String::from("Paris-Lyon"),
priority: 1,
};
let mut rerouted = original.clone();
rerouted.route = String::from("Paris-Dijon");
rerouted.priority = 2;
println!("limits: {retry_limit}, {copied_limit}");
println!("original: {original:?}");
println!("rerouted: {rerouted:?}");
}limits: 3, 3
original: Shipment { route: "Paris-Lyon", priority: 1 }
rerouted: Shipment { route: "Paris-Dijon", priority: 2 }修改克隆值不会修改原值,因为两个 String 各自拥有缓冲区。这个结论来自 Shipment 的派生 Clone 逐字段克隆;不能把它推广为所有 .clone() 都会深拷贝。对于共享指针,克隆通常创建另一个拥有句柄。
用借用避免转移
note_count 只读订单,因此接收 &Order;add_note 需要修改订单,因此接收 &mut Order。两个函数都不保留订单,所有权从始至终留在 main。
#[derive(Debug)]
struct Order {
number: String,
notes: Vec<String>,
}
fn note_count(order: &Order) -> usize {
order.notes.len()
}
fn add_note(order: &mut Order, note: &str) {
order.notes.push(note.to_owned());
}
fn main() {
let mut order = Order {
number: String::from("A-17"),
notes: vec![String::from("paid")],
};
println!("{} has {} note", order.number, note_count(&order));
add_note(&mut order, "packed");
println!("{} has {} notes", order.number, note_count(&order));
}A-17 has 1 note
A-17 has 2 notes第一次共享借用在 println! 结束后不再使用,所以随后的可变借用合法。note 是借入的 &str,但订单需要在函数返回后保存内容,因此 to_owned() 在真正的持有边界创建 String。这次分配是接口语义的一部分,不是为了安抚编译器。
观察作用域与显式 drop
Traced 的析构函数打印名称,使丢弃顺序可见。内部的 _buffer 在代码块结束时自动丢弃;connection 则通过 drop 提前结束。
struct Traced(&'static str);
impl Drop for Traced {
fn drop(&mut self) {
println!("drop {}", self.0);
}
}
fn main() {
let connection = Traced("connection");
{
let _buffer = Traced("buffer");
println!("inside scope");
}
println!("after inner scope");
drop(connection);
println!("after explicit drop");
}inside scope
drop buffer
after inner scope
drop connection
after explicit dropdrop(connection) 消费该值,因此后面不能再使用 connection。真实类型的析构函数通常释放资源而不打印;输出只是把时间点显式化。需要释放锁守卫时也应丢弃守卫本身,不能丢弃指向守卫的引用。
陷阱
移动后继续使用旧绑定
修复: 检查被调用方是否需要保留或销毁值。只读改用 &T,原地修改改用 &mut T,确实转交责任就让调用方不再使用旧绑定,或由函数返回所有权。只有契约需要两个独立值时才克隆。
用 clone 消除每个所有权错误
修复: 为每次克隆写出“新副本由谁持有、为何必须独立”。先尝试缩短借用、移动值或修改参数类型;保留的克隆应通过类型语义和必要时的测量验证,而不是用笼统的“克隆很便宜”解释。
根据存储位置猜测 Copy
修复: 查看类型是否实现 Copy,以及所有字段是否允许该实现。自定义类型只有在隐式复制符合领域语义时才应派生 Copy;如果复制需要显式确认、分配或计数更新,就使用 Clone 或保留移动语义。
忽略部分移动
修复: 只需读取时在模式中使用 ref 或匹配引用。确实要取走字段时,可以重构为完整解构,或把字段建模为 Option<T> 并用 take() 留下有效状态;不要通过不安全代码绕过析构不变量。
把析构当成必达事件
修复: 让文件、锁与事务由尽可能小的词法作用域管理,并避免把关键持久化动作只放在 Drop 中。为引用图标出拥有边,给非拥有反向边使用 Weak;对正常关闭流程显式提交或刷新,再把析构作为异常路径的保障。
部分移动与 Drop
结构体字段分别拥有各自的值,所以没有实现 Copy 的字段可以单独移动。移动后,编译器会追踪哪些字段仍已初始化;你可以单独读取未移动字段,却不能再借用或移动整个结构体。这种分析发生在编译期,没有给结构体添加运行时“半有效”标志。
模式可以用 ref 借用字段而不是移动字段。匹配 &value 也能让模式默认处理借用内容。选择方式应对应后续责任:只观察就借用,需要取得字段就明确留下一个仍满足类型不变量的状态。
实现 Drop 的类型不能安全地任意移出字段,因为编译器仍要把完整 &mut self 交给析构函数。常见设计是在字段中保存 Option<T>,通过 take() 把它替换为 None 后取得原值。这样析构函数看到的仍是类型允许的有效状态。
ManuallyDrop<T> 与不安全指针可以改变自动析构行为,但也把防止重复释放和遗漏释放的责任交给实现者。普通业务代码不应为了绕开一次移动错误而使用它们。先调整拥有结构或公开一个消费整个值的方法。
所有权不保证无泄漏
Rust 的安全保证关注无效内存访问,而不是保证所有分配最终都被回收。程序可以有意调用 mem::forget,也可以用强 Rc 或 Arc 边形成环;这些情况可以泄漏而不产生悬垂引用。泄漏会消耗内存或让文件等资源长期保持打开,但它本身不需要触发未定义行为。
引用计数图中,强边表达让目标继续存活的所有权,弱边只表达可选访问。父节点拥有子节点、子节点仅导航到父节点时,反向边通常应使用 Weak。如果领域本身允许一般环,应考虑集中式所有者、索引式 arena 或显式拆环协议,而不是假设引用计数会检测环。
析构也不应承担必须成功的业务提交。Drop::drop 不能向调用方正常返回错误,panic 展开期间的第二次 panic 还可能中止进程。数据库提交、文件刷新或网络确认应有显式返回 Result 的方法,析构只负责不会失败或只能尽力而为的收尾。
API 签名就是所有权契约
接收 T 表示函数取得一个完整值,并可以保存、转交或丢弃它。这个签名不保证函数一定长期保留值,但调用方必须按责任已经转移来编程。消费型构建器和线程入口常用这种形式。
接收 &T 表示只在借用有效期内共享访问,通常最适合只读查询。接收 &mut T 表示调用期间独占访问并允许修改,但函数仍不拥有 T。引用所指数据若要在调用后保存,就必须受输出生命周期约束,或在边界创建拥有型结果。
返回 T 把新值或接收到的值交给调用方。返回 &T 则要求结果能追溯到仍有效的输入、字段或静态数据;生命周期标注只表达关系,不会延长所有者。拿不准时,先用一句话写出“谁负责最终丢弃结果”,答案通常会决定返回拥有值还是借用值。
impl Into<String> 等转换型参数可以让调用方传入多种表示,但它仍在函数内部建立一个拥有型 String。这种便利适合明确的持有边界,不应掩盖只读函数本可接收 &str 的事实。泛型也不会取消分配或移动,只是把具体转换交给调用方类型决定。
评审 API 时,可以为每个参数标记 take、read 或 mutate,为每个返回值标记 new owner 或 borrowed from。然后检查实现是否与签名一致:只读函数不应索要所有权,承诺保留值的函数不能只存短期引用,返回借用的函数不能引用局部临时值。这张小表通常比在报错后添加克隆更快找到设计问题。
阅读所有权诊断
所有权错误通常在后续使用点暴露,但根因位于更早的移动或借用点。编译器会同时标出值首次移动的位置与非法使用的位置。阅读顺序应从第一次责任变化开始,而不是只修改最后一条红线。
“value moved here” 表示某次表达式按值取得了非 Copy 值。函数调用时先查看形参类型,方法调用时还要查看接收者是 self、&self 还是 &mut self。宏可能隐藏实际调用,因此必要时检查宏展开或底层 API 签名。
“cannot move out of” 通常表示代码只有借用访问权,却试图取得内部值。例如,从 &Record 直接返回一个 String 字段会要求移动该字段。接口若只需观察,就返回 &str;调用方确实需要拥有结果时,才克隆字段或重新设计所有权边界。
修复时可以按这个顺序追踪:
- 找到该值最后一次确定已初始化的位置。
- 标记第一次移动、共享借用或可变借用。
- 判断报错处需要的是所有权、读取能力还是修改能力。
- 修改拥有关系、函数签名或操作顺序,让能力与需求一致。
- 重新检查所有克隆与显式
drop,确认没有掩盖原问题。
诊断中的建议是局部可行操作,不一定是最合适的 API 设计。编译器可能建议借用,也可能建议克隆,但它不知道值是否应该跨线程保存、是否代表唯一令牌,或复制是否会破坏业务身份。最终修复必须同时满足类型规则与领域契约。
| 诊断线索 | 先检查的位置 | 常见设计问题 |
|---|---|---|
use of moved value | 更早的赋值或按值调用 | 调用后仍假设调用方拥有值 |
borrow of moved value | 移动位置与后续借用 | 参数不必要地接收 T |
cannot move out of | 当前访问来自 &T 还是 &mut T | 试图从借用容器取得拥有字段 |
cannot move out of type ... which implements Drop | 类型的析构不变量 | 字段提取没有留下有效状态 |
一次改动可能让首个错误消失,却把责任问题移动到另一层。例如,把形参从 String 改成 &String 会保留调用方所有权,但公共只读接口通常应进一步接受 &str。编译通过之后,还要检查签名是否给调用方留下了不必要的具体容器约束。
析构边界与控制流
作用域结束不是只有右花括号一种形式。显式 return、break 和 ? 运算符都可能提前离开某个拥有值的区域,Rust 会丢弃路径上不再存活的已初始化值。由此,锁守卫和临时文件可以利用词法作用域在多条返回路径上保持一致清理。
条件分支只丢弃该执行路径实际初始化的值。编译器用确定初始化分析阻止读取未初始化绑定,也避免为从未创建的值运行析构。循环每次迭代产生的局部值通常在该次迭代结束时丢弃,除非所有权被移动到循环外的容器或返回值中。
panic 使用栈展开策略时,会沿栈丢弃已经构造的局部值。若构建配置选择 panic=abort,进程会直接中止而不执行展开清理。库代码不应假定调用方一定采用哪种 panic 策略来完成必须成功的持久化操作。
下面的表格区分普通控制流与不会保证栈析构的路径:
| 路径 | 当前栈帧的普通局部析构 | 设计含义 |
|---|---|---|
| 到达作用域末尾 | 会 | RAII 的常规路径 |
return 或 ? 提前返回 | 会 | 适合释放守卫与临时资源 |
| 展开式 panic | 会 | 析构不能再次 panic |
panic=abort 或 std::process::abort | 不会 | 不可依赖进程内清理 |
std::process::exit | 不会 | 先显式完成必要关闭 |
| 强引用环仍有计数 | 不会结束环内值 | 修正拥有图或主动拆环 |
显式 drop(value) 可缩短资源生存期,但大括号形成的窄作用域通常更容易审查。窄作用域同时限制变量可见性,防止后续代码误用已经释放或本应不可访问的资源。只有控制流确实更清楚时,才需要把 drop 当作时序标记。
字段的丢弃顺序有时会影响类型设计。例如,一个字段的析构若访问另一个字段管理的外部设施,声明顺序就可能成为隐含耦合。更稳妥的设计是在拥有类型自己的 Drop 实现中显式完成需要顺序的无失败清理,并让各字段析构相互独立。
临时值的析构点受表达式和语句规则影响,不能只凭视觉嵌套猜测。锁方法链、match 条件和 if let 中创建的守卫尤其需要检查实际生存区间。时序影响并发正确性时,使用命名绑定和明确作用域,使释放点在代码审查中可见。
测试析构时,优先验证可观察资源状态,而不是只断言打印顺序。可以在受控测试中用计数器或弱引用确认最后一个所有者消失,也应覆盖提前返回路径。进程中止行为需要子进程级测试,不能期待测试框架在同一进程内继续运行。
4个问题 · 2 道输出预测题 · 1 道找错题