如何访问可变成员而不破坏借用规则?

解读

在国内一线互联网/芯片/自动驾驶公司的 Rust 面试中,这道题常被用来区分“写过几段 Rust 代码”与“真正理解借用检查器”。
面试官真正想听的是:

  1. 你能否在不产生可变借用重叠的前提下,安全地拿到多个可变成员的引用;
  2. 你能否在编译期就证明这些引用不会指向同一块内存;
  3. 你能否在性能零损耗的前提下完成上述操作,而不是退回到运行时检查或克隆。
    如果候选人直接回答“用 RefCell”或者“拆成多个结构体”,通常会被追问“还有更零成本的做法吗?”——这就引出了**“拆分借用(splitting borrow)”**这一核心思路。

知识点

  1. 独占可变引用(&mut T)的语义:同一作用域内只能存在一个,且不能与任何不可变引用共存。
  2. Rust 的“字段级借用精度”:编译器可以区分结构体内部不同字段,允许对多个字段同时建立互不重叠的可变引用
  3. 拆分借用(Splitting Borrow)
    • 手动解构:let Struct { a, b } = &mut self;
    • 自建 getter 返回 &mut 不同字段;
    • 标准库工具:slice::split_at_mut、iter_mut、Option::as_mut、RefCell::borrow_mut(运行时检查,非零成本)。
  4. unsafe 的替代方案
    • 使用 raw pointer 自行保证无重叠,但面试中必须给出充分不变量说明与单元测试方案,否则会被判“为炫技而牺牲安全”。
  5. 模式匹配与 NLL(Non-Lexical Lifetime):Rust 2018 以后,NLL 让字段级借用的生命周期精确到最后一次使用,大幅降低人工拆分的心智负担。
  6. 常见反模式
    • 同时调用 &mut self 的多个方法,导致二次可变借用;
    • 把 &mut self 传入闭包后又去访问其他字段;
    • 在迭代器里“偷偷”缓存 &mut 导致别名。

答案

“零成本且不破坏借用规则”的标准做法只有一句话:利用 Rust 的字段级借用精度,手动把 &mut self 拆成多个不重叠的 &mut 字段
示例代码(面试现场可直接手写):

struct Cache {
    map: std::collections::HashMap<u32, String>,
    hits: u64,
    misses: u64,
}

impl Cache {
    // 需求:同时修改 map 和 hits,但编译器不允许两个 &mut self 共存
    fn get_or_insert(&mut self, k: u32) -> &str {
        // 1. 先拆分借用
        let Self { map, hits, misses } = self;
        // 2. 各自使用互不影响的可变引用
        match map.get(&k) {
            Some(v) => {
                *hits += 1;
                v
            }
            None => {
                *misses += 1;
                let v = map.entry(k).or_insert_with(|| k.to_string());
                v
            }
        }
    }
}

关键点讲解(面试时主动说出来):

  • 解构赋值把 &mut self 一次性拆成三个互不重叠的 &mut 字段,生命周期由 NLL 精确控制;
  • 没有运行时开销,也没有 RefCell 的计数成本;
  • 如果字段间有逻辑耦合,可以封装成私有 helper,返回 (&mut A, &mut B) 元组,对外仍保持零成本抽象

若面试官追问“字段数量多、层级深怎么办?”——可补充:

  1. slice::split_at_mut 把大数组切成多段,每段给不同线程/任务;
  2. iter_mut 遍历 Vec,编译器自动保证每次迭代拿到的 &mut 不重叠;
  3. 对递归结构体,可以写 unsafe 的 split_mut 方法,内部用裸指针+偏移量,但必须在文档里给出不变量:同一节点不会两次出现,并配套 miri 测试与单元测试,确保评审放心。

拓展思考

  1. 自引用结构体的困境:当字段 A 持有指向字段 B 的指针时,即使拆分借用也无法通过编译,此时需要 Pin + 投影(projection) 或者 unsafe 的 ouroboros 库,面试中可展示你对 Pin 语义的理解。
  2. 并发场景:想把两个字段同时丢给不同线程,可用 crossbeam::scope + split_at_mut,或者 ** rayon 的 par_iter_mut**,底层仍是零成本拆分借用。
  3. API 设计启示:对外暴露的方法如果返回 &mut self,会强制调用者串行化;更好的做法是返回零大小标记类型(如 Cursor)或拆分句柄,让调用者继续并行访问不同子字段,这在数据库内核、图形渲染管线中尤为常见。
  4. 与 C/C++ 对比:传统做法靠“指针 + 手动约定”实现字段级并发,Rust 在编译期就能给出等价证明,这正是国内安全关键行业(车载 OS、航天嵌入式)愿意用 Rust 重写底层模块的核心原因。