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

the borrow checker was right and i was wrong, again

A week of arguing with Rust's borrow checker over a graph structure, and the slow realisation that every error it raised was pointing at a real bug in my design.

Code on a screen, a programming editor

The headline, so you don't have to read to the end to feel smug: the borrow checker was right every single time, and the thing I was "fighting" was my own muddled idea of who owned what. I spent the better part of the week between Christmas and New Year arguing with rustc about a graph, and rustc won every round, and I'm grateful for it now in a way I absolutely was not on day two.

I've been writing Rust on and off for a while, since well before 1.0, and I still hit this. You come in from a language where you can hold a reference to anything and mutate it whenever you fancy, and Rust quietly asks the question those languages let you ignore: who is responsible for this memory, and who is allowed to change it right now. Most of the time my answer is a confident "obviously me" followed by a compile error that reveals I had no idea.

the thing i was building

A directed graph. Nodes that point at other nodes, the most natural data structure in the world and the one Rust makes you actually think about. My first instinct was the one everybody's first instinct is:

struct Node {
    value: i32,
    edges: Vec<&Node>,
}

The compiler wants lifetimes on that reference, and the moment you start writing them down you discover you're trying to describe a structure where a node lives exactly as long as itself and also as long as everything pointing at it, which is a circular requirement the borrow checker is entirely correct to refuse. In a language with a garbage collector this compiles and runs and you never notice that the ownership question was real. Here it's a wall on day one.

I did the thing I always do, which is take it personally. I tried to lifetime my way out of it. I sprinkled <'a> everywhere like seasoning. I got deep enough into nested lifetime parameters that the error messages stopped being about my code and started being about my poor life choices.

losing gracefully

The turning point was admitting I was solving the wrong problem. I wasn't fighting the borrow checker. I was refusing to decide on an ownership model, and the borrow checker simply won't let you defer that decision the way other languages will.

Once I framed it as "decide who owns the nodes," the options got clear. The clean approach for a graph is to stop storing references between nodes at all and store indices into a central arena:

struct Graph {
    nodes: Vec<Node>,
}

struct Node {
    value: i32,
    edges: Vec<usize>, // indices into Graph::nodes
}

The Graph owns every node. Edges are just usize indices, plain Copy data with no lifetime and no ownership question to answer. To follow an edge you index back into the arena. It's not as pretty as a pointer chase, and yes you trade a compile-time guarantee for a runtime "is this index still valid," but it sidesteps the entire borrow war because nothing borrows anything across the structure.

A second view of source code

For the cases where I genuinely needed shared ownership and interior mutability, the other honest answer is to reach for Rc<RefCell<T>> and accept the reference counting and the runtime borrow checks that come with it. I'd been treating that as a defeat, as if using Rc meant I'd failed to write "real" Rust. It isn't. It's the right tool when ownership is genuinely shared, and pretending otherwise just means I spend three days writing lifetimes to avoid admitting the shape of the problem.

what the fight was actually about

Here's the bit that took me too long. Every error the borrow checker gave me was pointing at a real bug, even when there was no observable bug yet. The lifetime tangle on the reference-based graph wasn't the compiler being pedantic. It was the compiler telling me, correctly, that my design had a use-after-free shaped hole in it that I simply hadn't reached in execution. In C I'd have happily compiled that, run it, and met the hole eighteen months later in production with a corrupted heap and no idea why.

That reframing is the whole thing for me. The borrow checker isn't an obstacle between me and a working program. It's a very patient reviewer who refuses to approve the PR until I've answered a question I was trying to skip. When I treat it as an adversary, I write worse code and feel clever for forcing it through with lifetimes I don't understand. When I treat it as a reviewer, I get a design that's actually sound, and the compile error turns out to have saved me a debugging session I'll now never have.

So I lost the fight, and that was the point. I came out with an arena-indexed graph that's faster and simpler than the pointer soup I started with, a clearer head about when Rc<RefCell<T>> is the right call rather than the shameful one, and a renewed and slightly grudging respect for a compiler that knows more about my program than I do. Next time I'll try to lose more quickly. That's the only improvement on offer here. The checker was never going to start losing.