Skip to content

← Ownership II: Borrowing and the Borrow Checker step 15 of 24

Medium Primitives

Split borrows: the checker reasons about places, not variables

You are given a struct with three parallel fields and a method that has to read one while writing another. It does not compile.

pub struct Inventory {
    pub items: Vec<String>,
    pub counts: Vec<i64>,
    pub log: Vec<String>,
}

pub fn run(items: Vec<String>, counts: Vec<i64>, ops: Vec<(String, i64)>)
    -> (Vec<i64>, Vec<String>)

run builds an Inventory from items and counts with an empty log, applies every (name, n) op through Inventory::restock, and returns (counts, log).

restock(name, n) scans items from the front. For every position whose item equals name, it adds n to the corresponding count and appends one log line. If no position matched, it appends a miss line instead.

The log format is exact, character for character:

hit:  "<name> <signed n> -> <new count>"     e.g.  "bolt +3 -> 8"
                                                   "bolt -2 -> 3"
                                                   "w +0 -> 3"
miss: "<name> miss"                          e.g.  "gizmo miss"

The sign is always written, including +0. In Rust that is the {:+} format specifier: format!("{name} {n:+} -> {total}").

items ["bolt","nut"], counts [5,1], ops [("bolt",3)]
    -> counts [8,1], log ["bolt +3 -> 8"]

items ["a","a"], counts [1,2], ops [("a",1)]
    -> counts [2,3], log ["a +1 -> 2", "a +1 -> 3"]     <- every match, not the first

The wall

error[E0502]: cannot borrow `*self` as mutable because it is also borrowed as immutable
   |
   |         for (i, item) in self.items.iter().enumerate() {
   |                          ---------------- immutable borrow occurs here
   |                 self.note(format!("{item} ..."));
   |                 ^^^^^^^^^ mutable borrow occurs here

This is where intermediate learners stall — sometimes for weeks. “Why can’t I call my own helper method while I’m looking at one of my own fields?”

The crucial fact: the checker CAN do this. The signature cannot say it.

Delete the helper and inline its body, and the same code compiles:

for (i, item) in self.items.iter().enumerate() {
    if item == name {
        self.counts[i] += n;                     // fine
        self.log.push(format!("{item} ..."));    // fine
    }
}

Nothing changed about which memory is touched. What changed is that the borrow checker can now see the individual places. Within one function body it tracks self.items, self.counts and self.log as three separate places and happily lets a shared loan of the first coexist with exclusive loans of the other two. This is the “places, not variables” rule from item 3.3, doing exactly what it says.

The moment you call self.note(...), that visibility is gone. note‘s signature says &mut self. Not “&mut self.log“ — there is no such type. So at the call site the checker must assume the callee may touch any part of self, and an exclusive loan of the whole struct collides with the shared loan of self.items that the loop is holding.

So the limitation is interprocedural specifically. It is not that the analysis is weak; it is that the type system has no way to write down “this method only touches these two fields”. Knowing that turns an inexplicable rejection into a named gap with known workarounds — which is a completely different feeling.

The fix, which is not discoverable

Destructure self at the top of the method:

let Inventory { items, counts, log } = self;

self is a &mut Inventory, so match ergonomics (item 3.11) gives you three independent locals of type &mut Vec<String>, &mut Vec<i64>, &mut Vec<String>. They are separate variables now, so the checker keeps them apart with no difficulty at all, and you write log.push(...) instead of self.log.push(...).

Nobody guesses this. It is taught or it is not known, which is why it is spelled out here rather than left as an exercise.

Two other standard routes, for your toolbox:

  • Move the helper off self. A free function fn note(log: &mut Vec<String>, line: String) takes exactly what it needs, and the call site borrows exactly one field.
  • Two-phase compute-then-mutate. Collect the log lines into a local Vec while scanning, then append them afterwards. Item 3.6, step 3.

The roadmap, clearly labelled as roadmap

There is an in-flight language experiment called view types that would let a signature name the fields it touches:

fn note(&mut self.{ log }, line: String)     // rust#155938, nightly, 2026

That would close this gap properly. It is #![feature]-gated on nightly, it is unusable in this course or in any stable code, and you should treat it as news rather than as a plan. The workarounds above are how this is done today and will remain valid regardless.

Remember the grade is compile + tests + clippy -D warnings.

Loading visualization…