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

the borrow checker was right and i was wrong, again

A weekend spent fighting Rust's borrow checker over a shared cache, and the moment I realised it was telling me my design was bad.

A close-up of code on a screen

I lost an evening to the borrow checker this weekend, and I want to be honest about how it went, because the honest version is more useful than the triumphant one. I did not outsmart it. It beat me, repeatedly, until I gave up trying to win and instead changed the thing I was asking for. That is the whole story, really, but the details are where the lesson lives.

The task was small. I have a little tool that walks a directory tree, parses a pile of config files, and builds an index so I can answer questions like "which files reference this name". I wanted a shared cache of parsed files so I did not re-read the same thing twice, and I wanted bits of the program to hold onto references into that cache while also, occasionally, adding new entries to it. If you have written any Rust at all you can already hear the checker sharpening its knives.

the first three attempts

My first instinct was the naive one. A HashMap, hand out & references to the values, carry on. This compiles right up until you try to insert into the map while a reference into it is still live, at which point you get the error everyone gets:

error[E0502]: cannot borrow `self.cache` as mutable because
it is also borrowed as immutable

Fine. I know this dance. My second attempt was to clone my way out of trouble. Just .clone() the parsed file out of the cache so the borrow ends immediately. This works, it compiles, and it is also quietly horrible, because the whole point of the cache was to avoid copying these things around. I had solved the compiler's complaint by defeating my own design goal. I noticed, felt clever for noticing, and deleted it.

The third attempt was where I dug myself in properly. I reached for Rc<RefCell<…>>, because that is what you reach for when you want shared mutable state and the borrow checker is in your way. And it does work. But I ended up with Rc<RefCell<HashMap<String, Rc<ParsedFile>>>> and a sprinkling of .borrow() and .borrow_mut() calls, and the type signatures started to look like someone had sat on the keyboard. Worse, I had now moved the borrow checking from compile time to runtime. The RefCell will happily compile and then panic at runtime if I accidentally hold a mutable and immutable borrow at the same time. I had not removed the problem. I had hidden it somewhere it could hurt me later.

A tangle of abstract programming shapes

the bit where i listened

At about eleven o'clock I stopped and asked myself what the compiler was actually objecting to. Not "how do I make this error go away", but "what is it telling me is wrong". And the answer was simple. It did not like that I wanted to read from a structure and grow it at the same time, with overlapping lifetimes, because that is genuinely a recipe for a dangling reference in a language without a garbage collector. The borrow checker was not being pedantic. It was pointing at a real hazard that, in C, I would have shipped without noticing and debugged six months later.

So I changed the shape of the problem. Two phases instead of one. First phase: walk the tree, parse everything, fill the cache completely. No reads during this phase, only writes. Second phase: the cache is now immutable, so hand out as many & references as I like, freely, no RefCell, no Rc, no runtime panics. The index-building code became boring. The signatures shrank back to something a human can read:

struct Index<'a> {
    cache: &'a HashMap<PathBuf, ParsedFile>,
    by_name: HashMap<String, Vec<&'a ParsedFile>>,
}

That 'a is doing real work, and the compiler is now happy, because I have promised it that the index never outlives the cache it points into, which is true. The whole thing got faster too, because I deleted all the reference counting and interior mutability that I had bolted on to fight a fight I should not have picked.

what i actually learned

The pattern I keep relearning is this: when the borrow checker fights you hard, it is usually right, and the move is not to find a cleverer way to express the same flawed design. It is to ask whether you can separate reading from writing in time, so that you are never doing both at once on the same data. Most of the cases where I have reached for Rc<RefCell<…>> in anger turned out to be cases where I could have done one phase of mutation and then frozen the thing.

I will not pretend Rc<RefCell<…>> is never the answer. Graphs, shared ownership across threads, observer-ish patterns, sometimes you genuinely need it and that is what it is for. But it is the heavy artillery, and I had reached for it to kill a fly. The graceful way to lose to the borrow checker is to notice you are losing, work out what it is protecting you from, and then change the question so you both get what you want. I went to bed with less code, no RefCell, and a tool that runs quicker. Losing gracefully, it turns out, is mostly just admitting the other one had a point.