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 selflocks 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.
| Invariant | The one place |
|---|---|
| Every instruction has a span | Chunk::emit — and code/lines are private |
| Every root is enumerated | enumerate_roots — all nine sets, plus a test asserting the count |
| Every child is traced | trace_children — one exhaustive match |
| Every list adjusts identically | compile_exprlist — exactly five call sites |
| Every mutation triggers a barrier | one 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 matching | A missing case is a compile error, not a production bug |
| No accidental tag/payload mismatch | The enum cannot be read as the wrong variant |
Drop for cleanup | Correct on every path, including ? |
| Bounds checking | A handle bug is an error, not a use-after-free |
Result everywhere | The no-panic property is checkable, with #[deny(unwrap_used)] |
No unsafe in the default build | A real, checkable claim about a whole bug class |
Where Rust Makes This Harder
| Why | The workaround | |
|---|---|---|
| Object graphs | Ownership is exactly what a cyclic mutable graph does not have | Handles (pattern 1) |
| Re-entrancy | &mut self cannot be held across a callback | Narrow the borrow (pattern 3) |
| Two mutable objects at once | The borrow checker refuses | Split borrows, or copy out |
| Self-referential structures | Not expressible safely | Indices |
| Computed goto | No labels-as-values, no guaranteed TCO | match; measure before caring |
Recursive Drop | A deep Box chain overflows the stack in a destructor | Bound 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
- Why is a stale handle better than a dangling pointer, and what does the generation counter cost?
- Where does the RAII guard pattern appear five times? What is the rule?
- Give the three legitimate ways past a borrow conflict, and why
RefCellis not one. - Why is
_ => unreachable!()in a treematcha bad trade? - Why is
u32::MAXa better jump placeholder than0? - Why are
Chunk'scodeandlinesprivate? - Give two invariants no compiler can check, and the friction that makes violating them visible.
- Name two places Rust makes runtime work easier and two where it makes it harder.
Next: Primary Sources.