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

Losing Gracefully To The Borrow Checker

I spent an evening trying to win an argument with the Rust borrow checker before realising it had a point and my data model was wrong.

A code editor showing Rust source

The borrow checker and I had a disagreement on Sunday evening, and I lost. This is, I have learned, the correct outcome. Most of the times I have "won" an argument with the borrow checker, by reaching for clone() or Rc<RefCell<_>> or, in my darker moments, a raw pointer, I was actually just deferring the loss to a future me with less context and more annoyance.

The thing I was building was a small graph of nodes where each node wanted to hold a reference back to its parent. In every other language I know, you reach for a parent pointer without thinking. In Rust, that innocent little parent: &Node is a declaration of war.

the shape of the fight

The first version looked roughly like this, and it does not compile:

struct Node<'a> {
    name: String,
    parent: Option<&'a Node<'a>>,
    children: Vec<Node<'a>>,
}

The lifetimes start out reasonable and then metastasise. The moment you try to actually mutate the tree, add a child, reparent a node, the borrow checker correctly points out that you're trying to hold a shared reference to a thing whilst also taking a mutable borrow of the structure that owns it. You can't have both, and that isn't Rust being fussy. That's an actual aliasing hazard that would be a use-after-free in C the day you got the lifetime wrong.

An abstract illustration of linked data structures

I spent a good hour trying to lifetime my way out of it. Added 'a everywhere. Added a second lifetime. Started reading forum threads from 2019 with the particular energy of a man who has decided the compiler is simply mistaken. It was not mistaken. The bidirectional ownership I'd casually drawn on paper genuinely doesn't have a sound representation as plain references, because "everyone owns and borrows everyone" is not a thing memory can be.

losing properly

The graceful loss is to stop fighting and change the data model. Self-referential trees in Rust have a well-trodden answer: don't store pointers, store indices into a flat arena.

struct Arena {
    nodes: Vec<Node>,
}

struct Node {
    name: String,
    parent: Option<usize>,
    children: Vec<usize>,
}

Now parent is a usize index into the Vec, not a borrow. There's no lifetime to thread, no aliasing to prove, and mutation is just indexing. It looks less elegant than a pointer, and there's a real cost: an invalid index is a logic bug rather than a compile error, so you've traded one class of safety for convenience. But the arena pattern is common enough that nobody reading it will be surprised, and crates like petgraph exist precisely so you usually don't have to write even this.

The conversion took fifteen minutes. The hour before it was me refusing to accept the answer.

the actual lesson

When the borrow checker won't let you express something, the useful question is not "how do I make it allow this" but "what is it telling me about my design". Most of the time the answer is that I've drawn an ownership diagram that doesn't survive contact with mutation. The graph wanting parent pointers wasn't a Rust limitation. It was me modelling a doubly-owned structure and being surprised that doubly-owned structures are exactly the thing that causes use-after-free everywhere else.

I still reach for clone() sometimes, when the data is small and the clarity is worth more than the copy. That's a legitimate trade, not a defeat. The defeat is the Rc<RefCell<_>> I reach for at 11pm to silence the compiler without understanding why it complained. That one always comes back.

Losing gracefully, here, means letting the compiler redesign your data structure for you, and being grateful it did it at compile time rather than in production at 3am.