Rust Patterns for Runtime Authors

Seven patterns this curriculum keeps reusing, and the places where Rust makes runtime work easier than C — and where it makes it harder.

If you take one page from this book into your next project, take this one.


1. Handles, Not Pointers

Problem: an object graph with cycles, freed selectively, in safe Rust.

#![allow(unused)]
fn main() {
pub struct GcRef<T> { index: u32, gen: u32, _t: PhantomData<fn() -> T> }
// Copy, 8 bytes, no lifetime. Dereference is a bounds-checked index plus a
// generation compare.
}

Appears in: the heap, table keys (stable object ids), ArticleRef into host storage, inline caches (which store an id, not a handle, so they are not GC roots).

Why it wins: a stale handle is a clean error, not undefined behavior. And it makes a future moving collector easy, because moving an object updates one slot rather than every reference — a benefit that was not the reason for the choice.

Generalizes to: any graph you would reach for Rc<RefCell<_>> for. slotmap and generational-arena are this pattern without the collector.


2. RAII Guards for Push/Pop Across ?

Problem: ? makes "the end of the function" a lie, so a hand-written pop is wrong on every early return.

#![allow(unused)]
fn main() {
let _guard = self.enter()?;      // Drop decrements
}

Appears in — five times, and noticing the fifth is the point: parser depth, evaluator scope, VM frames, GC temp roots, and the module loading set.

The rule: in Rust, cleanup that must happen on all paths goes in Drop, not at the end of the function.

The subtlety: a guard holding &mut self locks the whole struct for its lifetime. Hold a &Cell<usize> (or an index and a raw depth marker) instead. That compiler error is worth meeting once, deliberately.


3. Narrow the Borrow, Then Call

Problem: you hold &heap and need &mut heap; or the VM holds &mut self and must call host code that needs it too.

#![allow(unused)]
fn main() {
// WRONG
let f = self.heap.native(h)?;        // borrows self.heap
(f.func)(self, args)?;               // ERROR — and the compiler is RIGHT:
                                     // the callee could free the callee.

// RIGHT: take a cheap copy, END the borrow, then call.
let f: Rc<NativeFn> = self.heap.native(h)?.func.clone();
let args: Vec<Value> = self.stack[..].to_vec();     // Value is Copy
let results = f(&mut Ctx::new(self), &args)?;
}

Three legitimate escapes, in order: narrow the borrow; take-and-put-back; split the borrow (split_at_mut or disjoint index methods).

Not on the list: unsafe, and RefCell — which converts a compile error into a production panic, which is strictly worse.

Why Value: Copy is load-bearing: it is what makes "copy out what you need" cheap. That is one of the real reasons for ADR-004, and it is not about size.


4. Exhaustive match as a Migration Tool

Problem: adding a variant and finding every place that must handle it.

#![allow(unused)]
fn main() {
match e {
    Expr::Int { .. } => …,
    Expr::Binary { .. } => …,
    // NO `_` arm. Ever, in a tree or dispatch match.
}
}

The rule: never write _ => unreachable!() in a match over your own enum. You are trading a compile error for a runtime panic, and the compile error was the valuable one.

Appears in: every AST consumer, the VM's dispatch loop, trace_children, the disassembler, the validator.

This is where Rust is clearly better than C for this work. A C runtime finds a missing case at run time, in production, on the input nobody tested.


5. Poison Values That Cannot Be Mistaken for Real Ones

#![allow(unused)]
fn main() {
self.emit(Op::Jump(u32::MAX), span);      // NOT Jump(0)
}

0 is a valid jump to the top of the function: an unpatched jump becomes an infinite loop that runs. u32::MAX fails validation loudly.

The rule: a placeholder must be outside the domain of legal values, and something must check for it.

Appears in: jump patching, the multret sentinel (biased so it cannot collide with a real count), Span::EMPTY as a "someone forgot" detector.


6. One Function per Invariant

Problem: an invariant enforced in fifteen places drifts.

InvariantThe one place
Every instruction has a spanChunk::emit — and code/lines are private
Every root is enumeratedenumerate_roots — all nine sets, plus a test asserting the count
Every child is tracedtrace_children — one exhaustive match
Every list adjusts identicallycompile_exprlist — exactly five call sites
Every mutation triggers a barrierone Table::set (capstone project 3)

Privacy is the enforcement. Chunk's code and lines are private and emit is the only door, so a path that pushes an instruction without a span cannot be written. That is stronger than any assertion.


7. Structural Friction Where Proofs Are Impossible

Some invariants cannot be checked by a compiler. Make violating them require editing something a reviewer will see:

#![allow(unused)]
fn main() {
#[test]
fn every_vm_field_that_can_hold_a_value_is_rooted() {
    const ROOTED: &[&str] = &[ /* nine entries */ ];
    assert_eq!(ROOTED.len(), Vm::ROOT_SET_COUNT);    // bumped by hand, deliberately
}
}
#![allow(unused)]
fn main() {
#[test] fn all_threats_in_the_document_have_a_test() { … }
#[test] fn every_lua_divergence_is_documented() { … }
#[test] fn every_allow_indexing_slicing_has_a_safety_comment() { … }
}

None of these proves anything. All of them turn a silent omission into a visible decision, which is what you can actually buy. Cheap friction in the right place beats an elaborate mechanism in the wrong one.


Where Rust Makes This Easier

Why
Exhaustive matchingA missing case is a compile error, not a production bug
No accidental tag/payload mismatchThe enum cannot be read as the wrong variant
Drop for cleanupCorrect on every path, including ?
Bounds checkingA handle bug is an error, not a use-after-free
Result everywhereThe no-panic property is checkable, with #[deny(unwrap_used)]
No unsafe in the default buildA real, checkable claim about a whole bug class

Where Rust Makes This Harder

WhyThe workaround
Object graphsOwnership is exactly what a cyclic mutable graph does not haveHandles (pattern 1)
Re-entrancy&mut self cannot be held across a callbackNarrow the borrow (pattern 3)
Two mutable objects at onceThe borrow checker refusesSplit borrows, or copy out
Self-referential structuresNot expressible safelyIndices
Computed gotoNo labels-as-values, no guaranteed TCOmatch; measure before caring
Recursive DropA deep Box chain overflows the stack in a destructorBound the depth at construction

The honest summary: Rust makes the front end (patterns 4, 5, 6) noticeably easier than C, and the object graph (patterns 1, 3) noticeably harder — and the workaround for the hard part turns out to buy safety properties C cannot have at all.


Self-check

  1. Why is a stale handle better than a dangling pointer, and what does the generation counter cost?
  2. Where does the RAII guard pattern appear five times? What is the rule?
  3. Give the three legitimate ways past a borrow conflict, and why RefCell is not one.
  4. Why is _ => unreachable!() in a tree match a bad trade?
  5. Why is u32::MAX a better jump placeholder than 0?
  6. Why are Chunk's code and lines private?
  7. Give two invariants no compiler can check, and the friction that makes violating them visible.
  8. Name two places Rust makes runtime work easier and two where it makes it harder.

Next: Primary Sources.