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

the borrow checker was right and i was being stupid

A weekend wrestling Rust's borrow checker over a graph structure, and the moment I realised it was catching a real aliasing bug rather than being pedantic.

A code editor open on a screen

I spent Saturday losing an argument with the borrow checker, and the humbling part is that it won on the facts. I was trying to model a small graph: nodes that hold references to their neighbours, mutated in place. In any language I'd reached for before, you write that in five minutes and find out it was wrong at 3am in production six weeks later. Rust just declines, immediately, and makes you feel the wrongness before you've shipped it.

The pattern I wanted was the classic one: &mut to a node while also holding references to other nodes in the same collection. The compiler said no, you cannot have a mutable borrow and other borrows of the same Vec alive at once, and it said it in the polite, slightly headmasterly way it has. My first three reactions were, in order: this is too strict, the compiler is wrong, and oh.

The "oh" is the good bit. The reason it won't let you hold &mut self.nodes[i] while also reading self.nodes[j] is that, in the general case, i and j could be equal, and now you've got aliasing mutable access, which is exactly the class of bug that produces use-after-free and data races. It wasn't being pedantic. It was refusing to let me write the bug I was about to write.

An abstract diagram of nodes and edges

The graceful loss looks like this. You stop trying to hold references into the collection and start holding indices instead. Nodes refer to neighbours by usize index, not by reference. The collection owns the storage, and you reach into it only at the moment you need to, for as short a time as you can manage.

struct Graph {
    nodes: Vec<Node>,
}

struct Node {
    value: i32,
    edges: Vec<usize>, // indices, not references
}

impl Graph {
    fn sum_neighbours(&self, i: usize) -> i32 {
        self.nodes[i].edges.iter()
            .map(|&j| self.nodes[j].value)
            .sum()
    }
}

It feels like a workaround the first time. It isn't. It's the compiler nudging you towards a design where ownership is unambiguous, and the "arena of indices" pattern turns out to be how a lot of serious Rust graph and ECS code is written anyway. The thing I was fighting was my own habit of pointer-soup data structures carried over from C, where the language trusts you and the bugs are silent.

Where I genuinely needed shared mutable access (a cache shared between two parts of the program) the answer was Rc<RefCell<T>>, accepting the runtime borrow check and the small cost that comes with it. The honest framing: Rust gives you compile-time checking for free and runtime checking when you ask for it, and the borrow checker's whole job is to make you decide which one you're paying for, on purpose, rather than by accident.

I lost the argument. I also ended up with a cleaner design than the one I came in with, no segfaults, and a structure I actually understand. If that's losing, I'll take it. The compiler errors are still long and the first read of a lifetime annotation still makes me squint, but I've stopped assuming the checker is wrong. It usually isn't. It was me.