所有权(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道找错题