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

the borrow checker was right and i was the bug

A medium-length account of losing an argument with Rust's borrow checker over a self-referential cache, and why the fix it forced was the better design.

A code editor open on a Rust source file

I lost an argument with the borrow checker this week, and as usual I lost it because I was wrong. The shape of the problem was familiar: I had a struct that held some data and also wanted to hold references into that same data, a little cache of pointers to bits of itself. Every language I learned before Rust would have let me do this and then let me corrupt memory later as a treat. Rust simply refused, and spent a paragraph of compiler output explaining why.

The thing I wanted looked roughly like this, and does not compile:

struct Index<'a> {
    text: String,
    words: Vec<&'a str>, // references into `text`
}

The borrow checker's objection is correct and, once you see it, obvious. If words borrows from text, and they live in the same struct, then moving the struct moves text, which invalidates every reference in words. A String's buffer can also reallocate, which would dangle the lot. There is no lifetime 'a I can write that makes this sound, because the thing being borrowed and the thing doing the borrowing have exactly the same lifetime and I'm asking one to outlive itself. I spent a genuinely embarrassing amount of time trying to annotate my way out of it before admitting the annotations weren't the problem. The design was.

Close-up of a terminal showing a compiler error

Once I stopped fighting, the fix was easy and better. Don't store references; store the data the references would have pointed at, in a form that owns itself. For my case that meant storing ranges instead of string slices, so the struct holds offsets into its own text rather than borrowed pointers into it.

struct Index {
    text: String,
    words: Vec<std::ops::Range<usize>>,
}

impl Index {
    fn word(&self, i: usize) -> &str {
        &self.text[self.words[i].clone()]
    }
}

Now nothing inside the struct borrows from anything else inside the struct. It moves freely, it has no lifetime parameter cluttering every signature that touches it, and the slice is computed on demand, valid for exactly as long as I hold the borrow and not a moment longer. If I genuinely needed shared self-reference there are tools for it: Rc, an arena, the ouroboros crate, raw pointers and a promise to behave. But I didn't need any of that. I needed to store an offset instead of a pointer, which is what I should have done from the start.

That's the pattern with the borrow checker, at least for me. The fight is never really with the compiler. It's with a design I've imported from a language that let me get away with things, and the compiler is just the first reader honest enough to tell me. Losing gracefully means noticing, sooner rather than later, that the error message is a code review and the reviewer is right.