Rust的 所有权(ownership) 规则追踪哪个位置负责某个值、责任何时移动,以及何时执行析构。
赋值与按值调用会移动非Copy值。即使看不出有堆数据发生位移,后续读取也可能失败;只取出一个字段,还会让结构体变得只能部分使用。
把每个操作标成取得、读取或修改,再分别用T、&T或&mut T表达契约。只有程序确实需要另一个值时才克隆。
是什么,为什么存在
Rust把每个值交给一个拥有位置,可以是局部绑定、结构体字段、集合槽位,或其他能保存值的位置。所有者负责让值保持有效,并在正常离开作用域时执行析构。编译器会追踪这份责任,无需垃圾回收器,也无需运行时的所有者标记。
可以用三条规则建立工作模型:每个值都有所有者;普通值在同一时刻只有一个位置拥有;所有者离开作用域时,值被丢弃。Rc<T>等共享所有权类型没有破坏这个模型,它们只是把被拥有的值变成计数句柄,再由句柄API决定内部值何时结束。
对于没有实现Copy的类型,赋值或按值函数调用通常会转移所有权。这就是 移动语义(move semantics) 。在编译器看来,源位置随后处于未初始化状态;要再次使用它,必须先在所有控制流路径上赋予新值。
借用(borrowing) 提供临时访问,而不更换所有者。共享引用&T允许读取,&mut T则在其有效区域内允许独占修改。别名与生命周期规则由其他主题展开;在这里,引用的作用是让API避免转移所有权。
给String赋值、把领域对象传给辅助函数、迭代集合、解构值或释放守卫时,都会遇到这些规则。阅读Rust代码时,与其盯着字节位于哪里,不如先问下一步哪个位置有权使用这个值。
工作原理
所有权分析以位置和程序点为单位。在let next = current;中,两个名称都是位置。若值不是Copy,next变成已初始化,current变成已移动;编译器不会为之后的访问插入运行时检查。
移动描述的是权限与责任,不要求机器层面真的复制数据。移动String会转移由指针、长度和容量组成的值,不会克隆UTF-8缓冲区。优化时,编译器甚至可能消除这次小型物理传递,但旧位置不能再读取的规则不变。
以下几种形式都会发生相同的转移:
- 赋值把右侧的非
Copy值移动到目标位置。 - 按值实参把值移动到形参中。
- 按值返回把结果移动到调用方的目标位置。
- 向集合插入元素会把元素移动到集合中。
- 消费型模式会移动它按值绑定的字段。
源位置可以在之后重新初始化。执行let mut name = String::from("old"); let saved = name;后,为name赋一个新的String,这个位置便可再次使用。新赋值不会找回已移动的值,原值仍由saved拥有。
Copy与Clone回答不同问题
Copy是一个标记trait,表示Rust可以隐式复制该值。整数、布尔值、共享引用,以及字段都满足条件的结构体是常见例子。带析构函数的类型不能实现Copy,因为静默复制会让唯一清理责任变得含糊。
Clone是显式操作,其含义由类型定义。克隆String会分配独立缓冲区;克隆Rc<T>则为同一分配创建另一个拥有句柄。看到.clone()只能说明代码请求了复制,无法单凭它判断成本,也无法判断结果是否共享状态。
| 表达式 | 非Copy值 | Copy值 | 操作后的源位置 |
|---|---|---|---|
let b = a; | 移动 | 隐式复制 | 移动后无效,复制后仍有效 |
consume(a) | 移动到形参 | 隐式复制到形参 | 取决于是否实现Copy |
inspect(&a) | 共享借用 | 共享借用 | 仍拥有该值 |
a.clone() | 显式克隆 | 显式克隆 | 仍拥有该值 |
drop(a) | 移动到drop | 复制后丢弃副本 | 取决于是否实现Copy |
不要根据值位于栈还是堆来猜测Copy。共享引用即使指向堆分配也可以是Copy;固定大小的值若拥有资源或定义了Drop,也可以不是Copy。trait实现才是契约。
借用让责任留在调用方
类型为T的形参取得所有权,可以保存、转移或销毁该值。&T形参可以临时读取,&mut T可以临时修改,而调用方始终是所有者。选择范围最窄且准确的形式,调用点才会如实表达后续行为。
字符串API能清楚表现这个区别。只读取文本的辅助函数通常接收&str,而不是String或&String。组件若要在调用结束后继续保存文本,就需要拥有的String,或者用生命周期关系证明借用仍然有效。
不兼容的移动或修改开始前,借用必须结束。不过,现代Rust会根据实际使用位置推导有效区域,而不只看右花括号。如果共享引用已经完成最后一次使用,同一代码块中之后的可变借用可以合法。这也是诊断会同时指出先前借用,以及让借用保持有效的后续使用的原因。
Drop结束所有权路径
已初始化的所有者离开丢弃作用域时,Rust会运行其析构函数,再销毁它拥有的值。把值的作用域与资源清理相连,就是 资源获取即初始化(Resource Acquisition Is Initialization,RAII) 。堆缓冲区、文件、套接字、锁守卫和普通字段都适用。
std::mem::drop函数并不是特殊语法。它按值接收参数且不返回内容,所以所有权在调用内部结束。调用drop(&guard)只会丢弃复制出来的引用,守卫本身仍然存活,这是常见的锁生命周期错误。
示例
下面四个示例依次演示完整移动、复制、临时借用与字段提取。每个程序都用Rust 1.98.0在本地编译并运行,紧随其后的文本块是程序的标准输出。
移入函数再返回
dispatch取得一个Parcel,修改后再返回。调用点通过遮蔽继续使用同一个领域名称,但第二个绑定拥有的是返回值,并没有让第一个绑定复活。
#[derive(Debug)]
struct Parcel {
id: u32,
state: String,
}
fn dispatch(mut parcel: Parcel) -> Parcel {
parcel.state = String::from("dispatched");
parcel
}
fn main() {
let parcel = Parcel {
id: 17,
state: String::from("packed"),
};
let parcel = dispatch(parcel);
println!("parcel {}: {}", parcel.id, parcel.state);
}parcel 17: dispatched当操作代表状态转移,或必须临时保存这个值时,取得再返回所有权很合适。如果目标只是在调用期间原地修改,使用&mut Parcel就不必交出再收回责任。
对比隐式Copy与显式Clone
Window的两个字段都满足条件,因此它可以实现Copy。Route含有String,所以必须显式调用派生的Clone;克隆出的路线拥有自己的字符串缓冲区。
#[derive(Copy, Clone)]
struct Window {
start: u8,
end: u8,
}
#[derive(Clone)]
struct Route {
name: String,
window: Window,
}
fn main() {
let morning = Window { start: 8, end: 10 };
let copied = morning;
let original = Route {
name: String::from("north"),
window: morning,
};
let mut rerouted = original.clone();
rerouted.name.push_str("-express");
println!(
"windows: {}-{} {}-{}",
morning.start, morning.end, copied.start, copied.end
);
println!(
"original: {} {}-{}",
original.name, original.window.start, original.window.end
);
println!(
"rerouted: {} {}-{}",
rerouted.name, rerouted.window.start, rerouted.window.end
);
}windows: 8-10 8-10
original: north 8-10
rerouted: north-express 8-10赋值复制了Window,所以morning与copied都仍然有效。修改rerouted.name不会改变original.name,因为派生的Route::clone会克隆每个字段,包括字符串拥有的数据。
通过借用读取和修改
label只需要共享访问,reserve需要独占可变访问。两个函数都不保存库存,因此两次调用前后,所有权始终留在main中。
#[derive(Debug)]
struct Inventory {
warehouse: String,
units: u32,
}
fn label(inventory: &Inventory) -> String {
format!("{}:{}", inventory.warehouse, inventory.units)
}
fn reserve(inventory: &mut Inventory, units: u32) -> bool {
if units > inventory.units {
return false;
}
inventory.units -= units;
true
}
fn main() {
let mut inventory = Inventory {
warehouse: String::from("CDG"),
units: 5,
};
println!("before: {}", label(&inventory));
println!("reserved: {}", reserve(&mut inventory, 2));
println!("after: {}", label(&inventory));
}before: CDG:5
reserved: true
after: CDG:3第一个println!使用的共享借用在最后一次使用后结束,因此下一行可以开始可变借用。label返回拥有型String是另一个设计决定:format!创建了新文本,并没有暴露库存内部的视图。
移动一个字段并安全取出另一个
移动first.address是一次 部分移动(partial move) 。之后不能再使用完整的first,但未受影响的Copy字段仍然可用。Option::take采用另一种提取方式,会在原字段中留下None。
#[derive(Debug)]
struct Delivery {
address: String,
attempts: u8,
note: Option<String>,
}
fn main() {
let first = Delivery {
address: String::from("12 Rue Ada"),
attempts: 1,
note: None,
};
let address = first.address;
println!("send {address} after {} attempt", first.attempts);
let mut second = Delivery {
address: String::from("8 Rust Lane"),
attempts: 2,
note: Some(String::from("side door")),
};
let note = second.note.take();
println!("note: {note:?}");
println!("remaining: {second:?}");
}send 12 Rue Ada after 1 attempt
note: Some("side door")
remaining: Delivery { address: "8 Rust Lane", attempts: 2, note: None }第一次提取没有为address留下替代值,所以之后只能分别访问剩余字段。第二个配送记录仍是完整的有效值,因为take()留下了合法的Option::None;外围类型实现Drop时,这种模式尤其有用。
陷阱
把每次赋值都当作移动
修复方法: 检查具体类型是否实现Copy。实现后,赋值会隐式复制;否则,除非表达式明确借用或克隆,都应按移动追踪。
在报错行添加clone
修复方法: 检查第一次移动以及被调用方的契约。临时读取就借用,调用方不再使用就移动,责任需要交回就返回所有权;只有设计确实需要两个值或句柄时,才保留clone()。
只读辅助函数却取得所有权
修复方法: 普通文本读取接收&str,其他只读值接收&T。只有函数要保存、转移、转换或有意销毁输入时,才接收T。
部分移动后使用完整值
修复方法: 只需观察时匹配引用。必须提取时,可以消费并解构整个值,或者把字段放进Option<T>,再用take()保持容器完整。
丢弃引用而不是守卫
修复方法: 把拥有型守卫传给drop(guard),或者把它放进更窄的代码块。启用编译警告,Rust会提示对引用调用drop不会影响它指向的值。
初始化状态属于位置
编译器不会为对象或变量名永久贴上“已移动”标签,而是追踪每个程序点上某个位置是否已经初始化。移动让源位置变为未初始化,把新值赋给同一位置则可以再次初始化它。
控制流让这件事更精确。如果一个分支移动某个值而另一个分支没有,汇合点后的代码只有在编译器能证明所有到达路径都有合法状态时才能使用它。在条件中移动后,即使开发者认为某个运行时事实能保证安全,只要类型分析无法表达这个事实,后续访问仍不会通过。
方法接收者遵循同一组规则。接收self的方法会消费对象,&self借用对象进行读取,&mut self则独占借用对象进行修改。如果生成代码在方法调用后意外失去对象,应先检查接收者类型,再考虑克隆。
索引表达式与解引用也描述位置,但安全代码不一定能通过借用访问直接移出值。例如,索引一个借用的向量只能访问元素,并不授予取走其中String的权限。remove、pop和swap_remove等集合方法定义了合法转出元素且让集合保持已初始化的方式。
闭包可以通过借用或按值方式捕获位置,move要求按值捕获。这不表示闭包内的每个捕获值都只能使用一次:Copy捕获会复制,拥有型非Copy捕获才会移动。闭包主题会继续解释函数体如何决定Fn、FnMut与FnOnce行为。
部分移动只保留可证明的字段
结构体由不同的字段位置组成。移动一个非Copy字段不会抹去未受影响的字段,所以仍能单独读取或移动它们;失去的是把结构体作为完整值使用的能力。
模式会逐字段选择移动、复制或借用。按值绑定String会移动它,绑定实现Copy的整数会复制它,使用ref或匹配引用则会创建借用。match ergonomics规则还可能根据被匹配值的上下文自动增加借用,因此应查看每个绑定的类型,不要只根据写法猜测。
结构体更新语法遵循相同的字段规则。在Record { id: new_id, ..old }中,显式字段来自新表达式,old的Copy字段被复制,其余非Copy字段被移动。因此,即使语法看起来像构造,旧值也可能进入部分移动状态。
实现Drop的类型会限制字段移出,因为析构函数接收&mut self,可能依赖完整的类型不变量。Rust会拒绝导致析构函数之后收到不完整值的部分提取。消费完整值的方法,或用Option::take替换字段,都能保留析构函数可以处理的状态。
这个限制也提供了设计线索。如果调用方经常需要在包装器结束前提取一个资源,应提供into_inner(self)之类的消费方法,或保存一个显式空状态。为了绕过一次字段移动错误而使用unsafe,会把责任从编译器手中拿走,通常还会让预期生命周期更难理解。
析构遵循作用域与所有权路径
局部绑定通常按声明的逆序丢弃;结构体字段则在类型自身的Drop::drop返回后,按声明顺序丢弃。代码不应让互不相关的字段析构函数形成隐藏的正确性依赖。清理顺序属于契约时,应在拥有类型中明确表达。
提前return、break与?运算符仍会正常离开作用域,因此对应路径上已经初始化的局部值会被销毁。panic采用栈展开时,Rust也会在展开过程中丢弃仍存活的栈值。进程中止、std::process::exit与泄漏的所有权路径则不承诺执行清理。
引用计数改变了最终边界,但不会让边界变得模糊。克隆Rc<T>或Arc<T>会创建另一个强所有者,内部值在最后一个强所有者结束时结束。因此,强所有者形成的环可以无限期保留值;Weak用来标记能够观察、却不让目标继续存活的链接。
释放点很重要时,可以显式调用drop(value),锁守卫就是典型例子。更窄的代码块往往能更清楚地表达同一边界,并避免之后意外访问。必须执行的提交、刷新与网络确认应使用可返回错误的显式方法,不要依赖通常无法报告失败的析构函数。
诊断位置看起来离原因很远时,可以按以下顺序追踪:
- 找到该位置最后一次确定已初始化的程序点。
- 标出第一次按值赋值、传参、接收者调用、返回、模式绑定或集合插入。
- 判断失败操作需要所有权、共享访问还是可变访问。
- 修改签名或操作顺序,准确表达这种需求。
- 代码通过编译后,再检查所有克隆与丢弃位置。
编译器建议只是局部修复,不是领域规范。编译器能指出借用或克隆在类型上可行,却无法决定凭据是否应被复制、队列项是否应被消费,也无法决定句柄是否必须在异步等待前释放。这些所有权决策必须由API说明。
所有权修复会改变契约
多种修改都能消除同一条诊断,但含义并不相同。借用保留当前所有者,移动结束源位置的使用,克隆按照Clone创建另一个值,共享所有权则让生命周期取决于多个句柄。
| 修复方式 | 调用后的责任 | 要回答的问题 |
|---|---|---|
传入&value | 调用方仍是所有者 | 被调用方是否只临时读取? |
传入&mut value | 独占访问结束后,调用方仍是所有者 | 被调用方是否修改但不保存? |
传入value | 被调用方取得所有权 | 调用方是否已经用完该值? |
传入value.clone() | 调用方和被调用方各得到一个值或句柄 | 领域是否要求复制或共享? |
把T改为&T可能只是把问题推入存储状态,因为被调用方或许确实需要保存输入。增加Arc<T>可以让值继续存活,但也会改变所有权图,而且不会自动让修改变得安全。只有新契约符合数据预期生命周期,修复才是正确的。
测试应验证契约,而不只是证明代码可以编译。借用调用后继续使用值;在API层验证消费型调用不会留下意外复用;还要检查两个克隆会按设计独立修改,还是共享内部状态。
4个问题 · 2道输出预测题 · 1道找错题