Ramblings of an aging IT geek
← Ramblings of an aging IT geek
rust

the borrow checker was right and i was tired

A long evening lost to a borrow checker error, and how surrendering to its design instead of fighting it produced better code than I started with.

Code on screen with a compiler error highlighted

I lost an evening to cannot borrow as mutable because it is also borrowed as immutable last week, and I want to write down how it went, because the shape of the fight is always the same and the lesson is always the same and I keep needing to relearn it. The short version: the borrow checker was right, I was tired, and the code I ended up with was better than the code I was trying to force through.

the code that would not compile

I had a cache. A HashMap of computed values, and a method that looked it up and, if it was missing, computed the value, inserted it, and returned a reference. Innocent enough:

struct Cache {
    map: HashMap<String, Vec<u8>>,
}

impl Cache {
    fn get_or_compute(&mut self, key: &str) -> &Vec<u8> {
        if let Some(v) = self.map.get(key) {
            return v;
        }
        let computed = expensive(key);
        self.map.insert(key.to_string(), computed);
        self.map.get(key).unwrap()
    }
}

This does not compile, and the error is the classic one. The self.map.get(key) in the if let borrows self.map immutably, and that borrow is considered live across the whole if because the returned reference could escape. Then self.map.insert(...) needs a mutable borrow, and you cannot have both. The compiler is protecting you from a real hazard: if insert reallocated the map, any reference handed out earlier would dangle. It is not being awkward for sport.

A developer leaning back from a desk of monitors

how the fight usually goes

The first stage of grief is denial, and in Rust denial looks like reaching for clone(). I cloned the Vec<u8> out of the map and returned that. It compiled. It also defeated the entire point of the cache, because I was now copying potentially large buffers on every hit. That is not a fix, that is paying the borrow checker a bribe and calling it a deal.

The second stage is unsafe. I will admit I typed unsafe into the editor, looked at it, and felt the specific shame of a man about to reach for raw pointers to dodge a problem the compiler was correctly flagging. I deleted it. If your answer to a borrow error is unsafe, you had better be certain the borrow checker is wrong, and at eleven at night you are not certain of anything.

The third stage is rage at non-lexical lifetimes, which were supposed to make exactly this kind of thing work. NLL did make a lot of borrow patterns compile that used to be rejected, and it is genuinely better than it was a year ago. But the conditional-return-a-reference-then-mutate pattern is the one case it still cannot see through, because the borrow really does have to stay live across the branch. So this was not NLL's fault either. It was mine, for asking for a thing that is hard to make sound.

losing gracefully

The graceful loss is to accept that the method wants to be two operations, not one, and to stop trying to do the lookup and the insert in a single borrow. The entry API exists precisely for this:

impl Cache {
    fn get_or_compute(&mut self, key: &str) -> &Vec<u8> {
        self.map
            .entry(key.to_string())
            .or_insert_with(|| expensive(key))
    }
}

entry takes a single mutable borrow, decides whether to compute, inserts if needed, and hands back a reference, all inside one operation the compiler can reason about. No second lookup, no clone, no unwrap. It is shorter than the version I was fighting, it is faster than the cloning version, and it computes the value lazily only on a miss. The borrow checker was not blocking my good design. It was blocking my bad one and nudging me towards the API the standard library already provides for exactly this.

The only cost is that key.to_string() allocates even on a cache hit, because entry needs an owned key. There are tricks to avoid that, raw entry APIs and the like, but for my case the allocation is noise next to the work I was caching, so I left it. Know when the win is worth chasing and when it is gold-plating.

A whiteboard sketch of boxes and arrows

the lesson, again

Here is what I keep relearning, written down so future-me can ignore it again. When the borrow checker stops me, it is almost never being pedantic. It is telling me that the thing I am trying to express has a soundness problem, and the friction is the language refusing to let me write a bug I have not noticed yet. Nine times out of ten the fix is not to fight harder, it is to restructure the operation so the borrows do not overlap, and nine times out of ten that restructured version is cleaner than what I started with.

The trap is tiredness. When you are fresh you read the error, you think "ah, the lookup and the insert want to be one thing", and you reach for entry. When you are tired you read the error, you feel attacked, and you reach for clone or unsafe to make the angry compiler stop talking. So my actual rule now is procedural, not technical: if I am fighting the borrow checker past about the third attempt, I stop, I make tea, and I come back to it. The problem is almost always that I have stopped reading the error and started arguing with it. Lose gracefully, take the better design the compiler was steering you towards, and go to bed.