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

losing to the borrow checker, then thanking it

A morning spent arguing with Rust's borrow checker over a self-referential struct, and why the redesign it forced was the right answer all along.

Rust source code on a screen

I spent a morning trying to convince the borrow checker that I knew what I was doing. I did not, as it turned out, know what I was doing. This is the usual outcome and I've made my peace with it.

The shape I wanted was innocent enough: a struct that held a buffer, and also held a slice borrowing into that same buffer, so I could keep a parsed view alongside the bytes it pointed at. Tidy, in my head. In Rust this is the self-referential struct problem, and the borrow checker treats it the way a bouncer treats someone trying the same fake ID for the third time.

struct Parsed<'a> {
    data: Vec<u8>,
    view: &'a [u8], // borrows from data... in theory
}

The trouble is that view borrows data, but they live in the same struct, so the moment the struct moves, the reference is pointing at the old location. The borrow checker can't prove the lifetime holds, because it doesn't. I tried the obvious escalations: more lifetime annotations, then fewer, then a wrapper, then the small dishonesty of an index instead of a reference, then briefly the larger dishonesty of reaching for unsafe. That last one is the tell. When you find yourself opening unsafe to win an argument with the compiler, you've usually lost a different argument with your own design.

So I stopped fighting and listened to what it was actually telling me. The borrow checker wasn't being pedantic; it was pointing out that storing a buffer and a pointer-into-that-buffer together is genuinely fragile. One reallocation of the Vec and the slice is dangling. In C I'd have written exactly that, shipped it, and met it again six months later as a heisenbug. Rust simply refused to let me start.

The fix was to stop storing the borrow at all. Keep the owned data, and store offsets into it instead of a reference. When I need the view, I produce it on demand from the data and the ranges:

struct Parsed {
    data: Vec<u8>,
    span: std::ops::Range<usize>,
}

impl Parsed {
    fn view(&self) -> &[u8] {
        &self.data[self.span.clone()]
    }
}

No lifetimes to wrestle, no unsafe, no self-reference. The view is always valid because it's computed from owned data that can't have moved out from under it. It's also, annoyingly, clearer than what I originally wanted to write.

That's the bit that's taken me a while to appreciate. Losing to the borrow checker rarely means "Rust can't do this". It usually means the thing you were reaching for was unsound and the compiler caught it before you did. The graceful way to lose is to treat the rejection as a design review rather than an obstacle, and most mornings, once I've finished being cross about it, the redesign is just better. I still try the fake ID first, mind. Old habits.