先说明一下,这里的 Rust 是指那门让无数人又爱又恨的系统级编程语言,不是那款让你在荒岛上撸树盖房子的生存游戏。很多朋友第一次在搜索引擎里找 Rust 资料,结果搜出来一堆游戏攻略,我当年就干过这事。
说回正题。如果你写过一阵子 Rust,大概率经历过这种场面:代码逻辑明明是对的,编译器却死活不让你过。"cannot borrow self as mutable more than once at a time"——这行错误几乎是每个 Rust 开发者的成人礼。我接触 Rust 这些年,从用 RefCell 到处兜底,到后来学会用借用分割这一套组合拳,代码的优雅程度完全是两个档次。
这篇文章我想把"借用分割"这个技巧从头到尾拆开讲清楚。它本质上是一种突破借用限制的精确访问策略:当你手握一个结构体的同时需要操作它的多个字段或多个部分时,借用分割能让你绕过"整个对象只能被一个可变借用占据"的粗粒度限制,让编译器理解你只是想同时访问其中的不同部分。这篇文章适合刚学完 Rust 所有权和借用基本规则、正在被编译器折磨的新手,也适合已经在写业务代码但总感觉哪里别扭、想优化代码结构的进阶玩家。
1. 借用分割到底在解决什么问题
1.1 借用检查器的"粗粒度偏见"
先说一个可能让人困惑的点:借用检查器不是不懂多个字段。它其实聪明得很,而是你写代码的方式没用对。
Rust 的所有权系统里有三条铁律:一个值同时只能有一个可变借用;可以有多个不可变借用;借用不能比所有者活得更久。这三条规则保证了内存安全,但也带来一个很实际的问题——当你手上只有一个对象引用时,你往往只能"整体访问"它,没法同时改动它的多个部分。
举个最常见的例子。假设你在写一个网络服务,有个结构体同时管理配置和连接状态:
rust复制struct Service {
config: Config,
connections: Vec<Connection>,
}
impl Service {
fn handle_request(&mut self, req: Request) -> Response {
// 想同时读 config 和改 connections
let timeout = self.config.timeout; // 不可变借用
self.connections.push(Connection::new(req.addr)); // 可变借用
// ...
}
}
上面这段代码其实能编译,因为借用检查器会收缩作用域,self.config.timeout 的借用用完后就被释放了。麻烦的是更复杂的场景,比如你需要同时持有一个不可变借用和一个可变借用:
rust复制fn update(&mut self, key: &str) -> Option<&Value> {
let old = self.cache.get(key)?; // 借了 self.cache 不可变
self.hits += 1; // 想借 self.hits 可变 -> 编译器炸了
Some(old)
}
原因很直白:old 借用了 self.cache,而 self.hits += 1 借用了整个 self 的可变引用,冲突。但仔细想一下,cache 和 hits 是两块完全不同的内存,同时访问根本不会有问题。问题只在于编译器觉得你借的是"整个 self",而实际上你只想借 self 里的一个字段——这就是借用限制的本质。
1.2 精确访问为什么是关键
借用分割的核心思路就八个字:拆分借用,精确访问。
你需要的不是"同时可变借用整个对象",而是"同时可变借用对象的几个不同部分"。Rust 的借用检查器其实是能理解路径的——它知道 self.a 和 self.b 是不同的内存位置。当你直接通过字段路径访问时,它可以精确到"这个借用只覆盖 self.a 这块区域"。但如果字段被封装在方法里、被闭包捕获、或者被数组/切片包了一层,编译器就没法那么精确了,就会退回整体借用的保守策略。
所以这个技巧的全部意义,就是老老实实把精确访问用出来。这句话听着简单,实际操作有不少门道。下面我按从简单到复杂的顺序,把几种核心方法掰开揉碎讲一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四种最常用的借用分割方法,逐个上手
2.1 字段级分割:最基础也最容易被忽略
直接通过结构体字段的路径来借用,是最原始、最自然的借用分割方式。
rust复制struct Stats {
name: String,
count: u32,
total: u64,
}
impl Stats {
fn record(&mut self, delta: u64) -> u64 {
// 同时可变借用 self.count 和 self.total
// 同时不可变借用 self.name
let name_len = self.name.len();
self.count += 1;
self.total += delta;
name_len as u64
}
}
这种写法的核心是:编译器对结构体字段的借用是"路径敏感"的。self.name 和 self.count 是不同路径,所以可以同时借用。这个能力在 Rust 里叫做 split borrows,属于语言的基础能力。
但字段级分割有个明显的前提:你必须能直接访问到具体字段。一旦字段被 pub(crate) 封住、或者你只能通过方法访问,这条捷径就断了。
还有个很多人踩过的坑是数组。数组 [T; N] 里的不同下标,编译器也能分开借用。但 Vec 不行——注意,Vec 的索引 vec[0]、vec[1] 走的是 Index trait 的方法调用,编译器看到的是一个不可变借用 &vec 和一个可变借用 &mut vec 的前后叠加,本质冲突。这就是为什么索引容器需要专门的 split_at_mut 这类 API,我在 2.3 里详细讲。
2.2 方法级借用:把粒度缩小到"方法边界"
当你没法直接碰字段时(比如字段是私有的),第二个思路是把操作拆到更小的方法里,让每个方法只借用它真正需要的字段。
rust复制struct Service {
cache: HashMap<String, Value>,
hits: u64,
}
impl Service {
fn bump_hits(&mut self) {
self.hits += 1;
}
fn get_cached(&self, key: &str) -> Option<&Value> {
self.cache.get(key)
}
}
写成这样之后,调用方可以这样分割:
rust复制fn handle(&mut self, key: &str) -> Option<&Value> {
let old = self.get_cached(key)?; // 不可变借用 self.cache
self.bump_hits(); // 可变借用 self.hits
Some(old)
}
等等,这个能编译吗?问题的关键在于 get_cached 返回的 &Value 的生命周期跟 &self 绑在一起,理论上 old 还在借用整个 self,此时调用 bump_hits 应该冲突才对。
我一开始也这么想,后来发现 Rust 的借用检查器比这细腻。它结合了 NLL(Non-Lexical Lifetimes,非词法生命周期) 机制:一个借用的真正存活区域,是最后一次使用它的地方,而不是从创建到作用域结束的整个区间。上面那段代码里,old 在 Some(old) 处被返回了,那它的生命周期就得一直延续到函数返回点,这没问题。但 bump_hits() 的借用发生在 old 创建之后、最终返回之前。如果 get_cached 借的是整个 self,那么 bump_hits 的可变借用确实会撞上。
关键在 get_cached 的内部实现——它只借了 self.cache。编译器的借用检查会"跟踪"到方法内部的具体字段吗?答案是:Rust 的借用检查器对函数体内部的借用是路径敏感的,但对函数签名是函数级的。当你调用 self.get_cached(key) 时,方法签名写的是 &self,所以调用点看到的借用范围是整个 self,而不是 self.cache。
如果你的 get_cached 真的只操作 self.cache,又想在一个函数里保持 old 的同时调用 bump_hits,那么上面写法的确会报错。这里有一个颇有意思的细节,Rust 编译器在借用检查时会做"重借用(reborrow)"优化,但重借用的作用有边界。解决方案通常是下面这样,把两个操作合并到一个更细粒度的方法里:
rust复制impl Service {
fn get_and_bump(&mut self, key: &str) -> Option<&Value> {
let old = self.cache.get(key)?;
self.hits += 1;
Some(old)
}
}
你看,在同一函数体内,编译器能精确到 self.cache 和 self.hits。一旦跨越函数边界,你就得自己把粒度控制在方法层。这就是方法级借用分割的核心心法:把"同时需要多个字段的操作"写进同一个方法体里,或者让它返回后再变。
2.3 切片的 split_at_mut 系方法:容器里的精确访问
前面提到 Vec 和数组切片没法直接对下标做分割借用。但标准库给了我们一个非常趁手的工具——split_at_mut。
rust复制fn process_parts(buf: &mut [u8]) {
let (left, right) = buf.split_at_mut(4);
for b in left.iter_mut() {
*b += 1;
}
for b in right.iter_mut() {
*b *= 2;
}
}
这个函数把切片在索引 4 处一切两半,同时拿到两个可变借用。听起来很违反借用规则,但它是安全的,因为 split_at_mut 在内部用了 unsafe 代码,同时向调用方保证了这两个部分在内存上不重叠。
类似的还有 chunks_mut、chunks_exact_mut、split_first_mut、split_last_mut 等一整套 API:
| 方法 | 作用 | 适用场景 |
|---|---|---|
split_at_mut |
按下标切成两半 | 二分处理一段数据 |
split_first_mut |
拆出第一个元素和剩余部分 | 链表式遍历、队列操作 |
split_last_mut |
拆出最后一个元素和剩余部分 | 栈操作、从尾部处理 |
chunks_mut |
按固定大小切成多段 | 批量处理、SIMD 优化 |
chunks_exact_mut |
按固定大小切多段,忽略末尾不足部分 | 性能敏感的批量处理 |
windows_mut(不稳定) |
滑动窗口可变借用 | 需要窗口内多元素改动的算法 |
哪种场景下你会真的需要 split_at_mut?我举一个很实在的例子:双端同时操作同一个队列。
rust复制fn drain_both_ends(v: &mut Vec<i32>) -> (i32, i32) {
let (first, rest) = v.split_first_mut().expect("empty");
let last = rest.last_mut().expect("only one element");
(*first, *last)
}
这个场景如果用索引 v[0] 和 v[v.len()-1] 直接写,会直接撞上借用冲突。用 split_first_mut 先拆出首元素和剩余部分,再从剩余部分里取末尾,就完全合法了。
2.4 内部可变性:最后的兜底方案
如果借用分割实在做不出来,才轮到 RefCell、Cell、Mutex 这类内部可变性工具上场。它们把"运行时借用检查"带进了编译期无法证明安全的场景。
rust复制use std::cell::RefCell;
struct Service {
cache: RefCell<HashMap<String, Value>>,
hits: Cell<u64>,
}
fn handle(&self, key: &str) -> Option<Value> {
let cache = self.cache.borrow_mut();
self.hits.set(self.hits.get() + 1);
cache.get(key).cloned()
}
上面这段代码可以过编译,因为 borrow_mut() 返回的是 RefMut,编译器只把它看成对 self.cache 的一次调用,不再跟 self.hits 冲突。
但代价也很明显:RefCell 把编译期的借用检查推迟到了运行期,如果运行时出现了同时两个可变借用,程序直接 panic。性能上也有额外开销(引用计数)。很多老手的基本判断是:先试试借用分割,实在做不到,再考虑内部可变性;能用 Cell 不用 RefCell,能用 RefCell 不用 Mutex。
Cell 和 RefCell 的区别值得注意:Cell<T> 适合 T: Copy 的类型,通过 get/set 直接取值,没有借用检查;RefCell<T> 支持非 Copy 类型,通过 borrow/borrow_mut 在运行时检查借用规则。
3. 实操:从编译失败到借用分割落地,完整走一遍
这一节我带大家跑一个稍微复杂一点的实例。场景是我之前做的一个数据上报模块:维护一批连接,定期统计每个连接收发字节数,同时要响应外部请求拉取当前统计快照。为了演示借用分割,我故意把需求设计成"同一个函数里既要改状态,又要保持旧值引用"这种最容易炸的形态。
先定义一个结构体:
rust复制struct Tracker {
conns: Vec<Conn>,
total_bytes: u64,
last_update: Instant,
}
struct Conn {
id: u32,
rx: u64,
tx: u64,
}
需求是说:当某个连接收到新数据时,更新该连接的收发计数,同时更新总字节数,然后返回"该连接更新前的收发状态"给调用方用于日志。第一次写出来大概率是这样的:
rust复制impl Tracker {
fn on_data(&mut self, id: u32, rx: u64, tx: u64) -> (u64, u64) {
let conn = self.conns.iter_mut().find(|c| c.id == id).unwrap();
let old = (conn.rx, conn.tx); // 这里直接取值,后面不再借用 conn
conn.rx += rx;
conn.tx += tx;
self.total_bytes += rx + tx; // 问题在这
(old.0, old.1)
}
}
看一眼就明白:conn 是 self.conns 的可变借用,还在用;self.total_bytes 是 self 的可变借用,冲突。编译器给的那一行经典错误,我又一次碰到了。
方案 A,字段级直接拆。把 self.conns 和 self.total_bytes 的借用分别写在同一个函数体里,靠编译器路径敏感特性让它通过:
rust复制impl Tracker {
fn on_data(&mut self, id: u32, rx: u64, tx: u64) -> (u64, u64) {
let conn = self.conns.iter_mut().find(|c| c.id == id).unwrap();
let old = (conn.rx, conn.tx);
conn.rx += rx;
conn.tx += tx;
// 注意:这里 conn 的借用还没结束(虽然 Rust 可以提前结束借用)
self.total_bytes += rx + tx;
(old.0, old.1)
}
}
等等,这能通过吗?取决于 NLL 的精确程度:conn 最后一次被使用是 conn.tx += tx,之后的 self.total_bytes += ... 就没有冲突了,因为 conn 的借用已经结束。上面这版代码在目前的 Rust 编译器上确实能过。这其实就体现了 NLL 的威力:它会分析每条语句之间的借用依赖,而不是整个块级作用域。
如果我把需求改一下:"返回旧状态的同时,还要返回连接数"——那 self.conns.len() 就会跟 conn 冲突了。这类更深的场景,可以拆一个内部方法:
方案 B,把核心更新逻辑塞进一个单独的方法里,把返回值提前计算好。让方法边界替你切割借用:
rust复制impl Tracker {
fn on_data(&mut self, id: u32, rx: u64, tx: u64) -> (u64, u64, usize) {
let old = self.update_conn(id, rx, tx);
let count = self.conns.len();
(old.0, old.1, count)
}
fn update_conn(&mut self, id: u32, rx: u64, tx: u64) -> (u64, u64) {
let conn = self.conns.iter_mut().find(|c| c.id == id).unwrap();
let old = (conn.rx, conn.tx);
conn.rx += rx;
conn.tx += tx;
self.total_bytes += rx + tx;
old
}
}
这样在 update_conn 内部,self.conns 和 self.total_bytes 的同函数体路径敏感借用可以共存;on_data 调用完 update_conn 之后,可变借用就释放了,再去读 self.conns.len() 毫无压力。
方案 C,如果字段私有到外部无法直接拆,而你又不想把它拆太碎,可以定义一个小结构体,把"统计计数"单独打包:
rust复制struct ByteCounters {
rx: u64,
tx: u64,
}
struct Conn {
id: u32,
counters: ByteCounters, // 把计数器包一层
}
struct Tracker {
conns: Vec<Conn>,
total_bytes: u64,
last_update: Instant,
}
这样做的好处是:你可以用方法来表示"对计数器整体的一个可变借用",一段代码里同时拿多个连接的计数器去做聚合,封装性更好:
rust复制fn record_all(&mut self, updates: &[(u32, u64, u64)]) {
for (id, rx, tx) in updates {
if let Some(conn) = self.conns.iter_mut().find(|c| c.id == *id) {
conn.counters.rx += rx;
conn.counters.tx += tx;
}
self.total_bytes += rx + tx;
}
}
这里的要点在于 ByteCounters 作为一个独立类型,把 conn.rx 和 conn.tx 的借用合并成了 conn.counters 的一个借用,在一些更复杂的借用交互中,这种粒度反而容易让编译器理解。
回到真实项目的经验,我想强调的是:方案 B 的"方法切分"是实际工程里最高频的解法。你不需要炫技式的字段全拆,也不需要急着上 RefCell,先看能不能把"需要同时可变借用的几个字段"的逻辑沉到同一个方法体里,让方法边界成为借用安全的分界线。
调试过程中另外一个常见问题是:返回值带着借用,这会让整个函数都被"借住"。
rust复制impl Tracker {
// 错误示范:返回了内部引用,导致后续没法可变借用 self
fn get_conn_mut(&mut self, id: u32) -> Option<&mut Conn> {
self.conns.iter_mut().find(|c| c.id == id)
}
}
一旦调用方拿到 &mut Conn,它就把 self.conns 的可变借用一直延长到最后一次使用。我就是因为这个,self.total_bytes 在任何地方都改不动了。最后的解法是尽量让返回内部引用的方法粒度小一点,或者改成返回数据副本。数据量小的情况下,复制一份根本不值得为了节省一次 clone 去纠结借用,代码可读性和心智负担的收益更大。这是我踩过坑之后的心里话。
4. 常见问题与排查技巧实录
4.1 局部变量遮蔽与元组解构里的借用分割陷阱
很多人在 match 或 if let 解构结构体时也会撞到借用问题。
rust复制let mut stats = Stats { name: String::from("x"), count: 0, total: 0 };
match &mut stats {
Stats { count, total, .. } => {
*count += 1;
// 这里想用 stats.name,以为不会冲突
let len = stats.name.len(); // error: 不能在一段可变借用期间借用 stats.name
*total += len as u64;
}
}
这个案子比较微妙。match &mut stats 里的 count、total 通过模式匹配拆出来,它们的可变借用来自 &mut stats,借用范围覆盖整个 match 分支。这时你想在分支内部通过 stats.name 再来一次不可变借用,做不到,因为 stats 整体被那个 &mut 借住了。解决办法是:不要用 &mut stats 做模式匹配,直接分字段操作:
rust复制stats.count += 1;
let len = stats.name.len();
stats.total += len as u64;
如果字段多,还可以考虑先把 name.len() 取出来,再去 match &mut stats:
rust复制let name_len = stats.name.len();
match &mut stats {
Stats { count, total, .. } => {
*count += 1;
*total += name_len as u64;
}
}
这里的核心教训是:模式匹配一次性借用了整个结构体的一部分,但借用检查是按"整体路径"标记的。在 match 分支内想混用 "通过 match 拿到的字段借用" 和 "通过原始变量拿到的字段借用",大概率会冲突。
4.2 闭包捕获导致借用范围异常扩大
闭包是借用分割的一大天敌。原因很简单:闭包会按它用到的变量捕获借用,但一旦你把闭包传给 Option、Vec 或者 Box 存储起来,借用就跟着闭包的生命周期走,你根本不知道它什么时候结束。
举个例子,我想把"两个字段相加的逻辑"做成一个闭包,拿来做批量处理:
rust复制let mut a = 0u32;
let mut b = 0u32;
let mut f = || {
a += 1;
b += 2;
a + b
};
let x = f();
a += 10; // error: 闭包还在借用 a
连 a += 10 都过不去,因为闭包 f 捕获了 &mut a 和 &mut b,只要 f 还没被销毁,两个变量都处于被可变借用状态。
处理办法:要么让闭包参数化,把需要改的字段作为参数传进去:
rust复制let mut a = 0u32;
let mut b = 0u32;
let mut f = |x: &mut u32, y: &mut u32| {
*x += 1;
*y += 2;
*x + *y
};
let x = f(&mut a, &mut b);
a += 10; // 可以,借用只持续到 f(&mut a, &mut b) 调用结束
要么把闭包改成普通函数,作用域更可控。我的建议偏向后者,因为闭包捕获在借用分割中的行为很反直觉,普通函数至少能让你看到每个借用从哪来到哪去。
4.3 重借用(reborrow)的妙用与陷阱
重借用是 Rust 里一个让借用分割能衔接起来的底层机制。简单说,&mut *ptr 或 &*ptr 可以从一个已有的可变引用上"重新借"一个借用,并且这个新借用会临时接管原借用的一部分能力。
rust复制fn update_all(head: &mut Node) {
// 先重借用 head 来访问值
head.value += 1;
// 再重借用 head.next,不影响下一步
if let Some(next) = head.next.as_mut() {
next.value += 2;
}
}
这里 head.next.as_mut() 本质上就是一个基于 &mut head 的重借用,它借的是 head.next,而不是整个 head。所以后续还能继续操作 head.value。
但重借用的一个常见陷阱是:它也具有生命周期,你重借出来的可变引用一旦被存储或者返回,原引用就被"暂停"了。上面链表的例子如果再复杂一点,比如想在 match head.next.as_mut() 分支内继续用 head,就可能出问题。解决办法是先把需要的值取出来,或者改变数据结构的组织方式。
4.4 别等到编译不过再去想借用分割
写了这么多,最后给一个工程上的实用心得:设计结构体字段时就要考虑借用边界。
如果你明确知道有一个操作需要同时改两个字段,最好先把这两个字段的访问设计成同一层级的方法,或者干脆把它们组成一个子结构体。等到编译器报错再去拆,往往会逼你做出权宜之计——比如给不需要 RefCell 的字段套上 RefCell。这种"先跑到编译不过再重构"的代价,远大于设计阶段多花五分钟想清楚。
我在自己的项目里习惯遵守三条经验法则:
- 单个函数内如果需要同时可变借用两个以上字段,考虑把核心逻辑下沉到一个方法里,让方法边界成为借用边界。
- 返回
&mut引用的方法尽量少暴露,这是因为一旦调用方持有返回的借用,外部对原结构体的任何访问都会被阻塞。可以考虑返回索引、ID,让调用方二次查找。 - 数据量小、拷贝便宜的字段,直接用 return + clone 的方式切断借用链,别硬撑引用。工程可读性和迭代速度,往往比那一次 clone 的开销重要得多。
4.5 借用错误信息的阅读方法
Rust 编译器报借用错误时,会直接标明 borrow 的起始位置和冲突位置。我一开始总是盯着最后几行看,结果经常云里雾里。后来总结出一个小技巧:从 "first mutable borrow occurs here" 往回找,看那个借用的生命周期到哪一行才算结束。 如果你能看到 NLL 给出的 span 标注,基本就能定位到是哪个变量占着茅坑不拉屎。
再配合 rust-analyzer 的内联提示(inlay hints),编辑器里可以直接看到每个借用变量的生命周期范围,调试体验会好很多。还有 clippy 会帮你检查一些不必要的 RefCell 用法,比如 clippy::borrow_interior_mutable_const 这类 lint,建议在 CI 里默认开上。
最后再分享一个小经验
我做 Rust 项目这几年,最直观的感受是:借用分割不是语法糖,它更像是你在跟编译器解释"我的数据访问模式"。你越是把结构体的字段边界设计得清晰,越是把一个操作集中到方法内部而不是在调用方到处拼接,借用检查器就越是配合。反过来,如果你总想着用 unsafe 或者 RefCell 绕过检查,代码的危险程度会随项目复杂度指数上升。我现在写新模块,落地前会先画一遍"哪些方法需要同时访问哪些字段"的脑内草稿,这个习惯至少帮我省下了一半的编译报错时间。希望这篇文章能让你少走点弯路,至少下次再看到 cannot borrow 的时候,能多一个"是不是该拆一下借用"的思路。
