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

i lost an argument with the borrow checker, and it was right

A small Rust refactor where the borrow checker refused my design, and the design it pushed me towards turned out to be the better one.

A terminal full of rustc errors, the usual Tuesday

I spent most of an evening last week trying to convince rustc that I was right and it was wrong. I had a struct that held a cache, and a method that wanted to look something up in the cache and, on a miss, mutate the cache to insert the computed value. Straightforward. I have written that pattern in a dozen languages without a second thought.

fn get(&mut self, key: &str) -> &Value {
    if let Some(v) = self.cache.get(key) {
        return v;
    }
    let v = self.compute(key);
    self.cache.insert(key.to_string(), v);
    self.cache.get(key).unwrap()
}

The borrow checker did not like this at all. The if let borrows self.cache immutably and that borrow lives as long as the returned reference, so the insert later in the function is a mutable borrow overlapping a live immutable one. I knew it was fine. The early return means the two paths never actually overlap at runtime. I knew that. The compiler, on the pre-NLL mental model I was apparently still carrying, did not.

So I did the thing we all do. I tried to outsmart it. I cloned the value to dodge the lifetime, which works but allocates on every hit, defeating the point of a cache. I tried wrapping it in Rc, which is the kind of thing you reach for when you are angry rather than thinking. I briefly considered unsafe, looked at myself in the reflection of the monitor, and stopped.

Then I sat back and asked the question I should have asked first: what is the borrow checker actually objecting to? It is objecting to a method that conditionally returns a reference into a thing it also conditionally mutates, with the compiler unable to prove the two never coincide. That is not pedantry. That is a genuinely awkward shape, the classic problem the entry API exists to solve.

fn get(&mut self, key: &str) -> &Value {
    self.cache
        .entry(key.to_string())
        .or_insert_with(|| compute(key))
}

One borrow, mutable, for the whole operation. No overlap to reason about because there is only one. It is shorter, it is faster, and it has no early-return cleverness to get wrong later. I had been fighting to preserve a design that was worse, and the only thing stopping me was a tool that had quietly understood the problem better than I had.

This keeps happening to me with Rust, and I have stopped resenting it. The borrow checker is not an obstacle between me and my correct program. It is a fairly blunt collaborator that is, annoyingly often, pointing at a real awkwardness in the shape of what I am asking for. The trick is to lose the argument early and gracefully, before you have spent an evening defending a position that was never worth holding.

The diff after I gave up and let the compiler win

The lesson I keep relearning: when rustc says no, the first question is not "how do I make it say yes". It is "what does it know that I don't". Most evenings, it knows quite a lot.