153. Heap objects in an arena
In the TypeScript VM, closure.fn.name.chars is three pointer hops. In drip-rs it is three trips to the heap: heap[closure], heap[function], heap.str(name). This chapter builds the Heap that answers them, with one arena per kind of object and one generic alloc for all eight.
- Read drip-rs’s Heap struct and map it to the TypeScript heap’s object list.
- Explain how the HeapObject trait lets one generic alloc and one heap[handle] serve every object type.
- Turn TypeScript field chains like inst.klass.name into heap lookups.
- Fix the borrow error that comes from reading one object while writing another.
Three hops
When the TypeScript VM prints a closure, it follows three object references (chapter 50):
case 'closure': return formatValue(v.fn);
// ...which prints `<fn ${v.name.chars}>`
v.fn is a pointer to the function, .name a pointer to a string,
.chars the text. drip-rs has no pointers between objects. Chapter 138
showed why: every object lives in an arena, and a reference is a
Gc<T>, a typed slot number. So each hop becomes a trip to the heap.
Adapted from src/value.rs:
Value::Closure(c) => {
let func = heap[c].function;
match heap[func].name {
Some(name) => write!(f, "<fn {}>",
heap.str(name)),
None => f.write_str("<script>"),
}
}
This chapter is about the Heap behind those square brackets.
A warehouse with one aisle per kind of parcel. Your ticket says which shelf. You never carry a parcel around; you carry the ticket, and every time you want to look inside, you walk to the aisle. The kind of parcel printed on the ticket tells you which aisle without anyone checking.
One arena per kind
Here is the heap, adapted from src/gc.rs:
pub struct Heap {
pub strings: Arena<ObjString>,
pub functions: Arena<ObjFunction>,
pub natives: Arena<ObjNative>,
pub upvalues: Arena<ObjUpvalue>,
pub closures: Arena<ObjClosure>,
pub classes: Arena<ObjClass>,
pub instances: Arena<ObjInstance>,
pub bound_methods:
Arena<ObjBoundMethod>,
interned: Table,
// ...GC bookkeeping
}
The TypeScript heap keeps one linked list of every object, threaded
through a next field in each object’s header, and each object says
what it is in a type field. drip-rs’s objects have neither. The arena
an object sits in says what it is, and sweeping walks the arenas, so no
list is needed. Mark bits live outside the objects too (chapter 159).
What is left is just the fields. Compare a closure:
interface ObjClosure extends ObjHeader {
type: 'closure'; fn: ObjFunction;
upvalues: (ObjUpvalue | null)[];
}
pub struct ObjClosure {
pub function: Gc<ObjFunction>,
pub upvalues: Vec<Gc<ObjUpvalue>>,
}
There is no null in the Rust upvalue list. The TypeScript VM
allocates the closure first and fills its upvalues in afterwards. drip-rs
captures them into a Vec first and only then allocates the closure,
so the list is never half-filled (chapter 158).
One alloc for eight kinds
The TypeScript heap has eight constructors, newClass, newClosure and
so on, and a table of sizes keyed by type. drip-rs has one generic
alloc. It works because every object type implements a trait that
says which arena is its own:
pub trait HeapObject: Sized {
fn arena(heap: &Heap) -> &Arena<Self>;
fn arena_mut(heap: &mut Heap)
-> &mut Arena<Self>;
fn size(&self) -> usize {
size_of::<Self>()
}
// ...and which mark bits are its own
}
impl HeapObject for ObjClass {
fn arena(heap: &Heap) -> &Arena<Self> {
&heap.classes
}
// ...
}
pub fn alloc<T: HeapObject>(
&mut self, object: T) -> Gc<T> {
self.bytes_allocated += object.size();
T::arena_mut(self).insert(object)
}
ObjString overrides size to add the length of its text, which is how
the collector knows a long string costs more. Six of the eight
impls are identical apart from names, so gc.rs stamps them out with
a small macro_rules! macro: a template that writes the same code for
each type it is given.
Then indexing. The standard Index trait is what heap[handle] calls:
container[index] is shorthand for *container.index(index)
[2]. drip-rs implements it once, for every
handle type:
impl<T: HeapObject> Index<Gc<T>> for Heap {
type Output = T;
fn index(&self, h: Gc<T>) -> &T {
&T::arena(self)[h]
}
}
IndexMut is the same with &mut, and it is what makes
self.heap[function].arity += 1 in the compiler work. Step through
printing a closure:
An object handle is a small Copy value that stands for a heap
object: in drip-rs a Gc<T>, the object’s slot number in the arena for
T. You read the object with heap[handle] and change it with
heap[handle].field = .... The handle’s type picks the arena at compile
time, through the HeapObject trait. Handles never own anything, so
values, objects and the VM can all hold copies.
use std::marker::PhantomData;
use std::ops::{Index, IndexMut};
// A handle: a typed slot number.
struct Gc<T> {
index: u32,
marker: PhantomData<fn() -> T>,
}
impl<T> Clone for Gc<T> {
fn clone(&self) -> Self {
*self
}
}
impl<T> Copy for Gc<T> {}
struct Str {
chars: String,
}
struct Class {
name: Gc<Str>,
}
// One arena per kind of object.
#[derive(Default)]
struct Heap {
strings: Vec<Str>,
classes: Vec<Class>,
}
trait HeapObject: Sized {
fn arena(h: &Heap) -> &Vec<Self>;
fn arena_mut(h: &mut Heap)
-> &mut Vec<Self>;
}
impl HeapObject for Str {
fn arena(h: &Heap) -> &Vec<Self> {
&h.strings
}
fn arena_mut(h: &mut Heap)
-> &mut Vec<Self> {
&mut h.strings
}
}
impl HeapObject for Class {
fn arena(h: &Heap) -> &Vec<Self> {
&h.classes
}
fn arena_mut(h: &mut Heap)
-> &mut Vec<Self> {
&mut h.classes
}
}
impl Heap {
fn alloc<T: HeapObject>(
&mut self,
object: T,
) -> Gc<T> {
let arena = T::arena_mut(self);
arena.push(object);
let i = arena.len() - 1;
Gc {
index: i as u32,
marker: PhantomData,
}
}
}
impl<T: HeapObject> Index<Gc<T>>
for Heap
{
type Output = T;
fn index(&self, h: Gc<T>) -> &T {
let i = h.index as usize;
&T::arena(self)[i]
}
}
impl<T: HeapObject> IndexMut<Gc<T>>
for Heap
{
fn index_mut(&mut self, h: Gc<T>)
-> &mut T {
let i = h.index as usize;
&mut T::arena_mut(self)[i]
}
}
fn main() {
let mut heap = Heap::default();
let chars = String::from("Point");
let s = Str { chars };
let name = heap.alloc(s);
let c = Class { name };
let class = heap.alloc(c);
// TypeScript: klass.name.chars
let n = heap[class].name;
println!("{}", heap[n].chars);
// IndexMut: change it in place.
heap[n].chars.push_str("3D");
let n = heap[class].name;
println!("{}", heap[n].chars);
// Uncomment for E0502:
// heap[heap[class].name]
// .chars.push('!');
}Read one, write another
All eight arenas live in one Heap, and heap[h] borrows the whole
Heap. So reading one object while writing another doesn’t compile,
even when they are different objects. Here is a first try at
Inherit, which copies the superclass’s methods into the subclass:
let from = &self.heap[superclass].methods;
self.heap[subclass].methods.add_all(from);
rustc 1.97 (trimmed):
error[E0502]: cannot borrow `self.heap`
as mutable because it is also
borrowed as immutable
19 | let from = &self.heap[superclass]...
| --------- immutable
| borrow occurs here
20 | self.heap[subclass].methods.add_...
| ^^^^^^^^^ mutable borrow occurs here
The fix is chapter 138’s rule, copy out then write. A handle is copied for free; a whole method table is cloned:
let methods = self.heap[superclass]
.methods.clone();
self.heap[subclass].methods
.add_all(&methods);
Nesting has the same trap. heap[heap[class].name] is fine for
reading, but writing through it fails, and rustc’s help suggests a
local. Uncomment the last line of the program above to see it.
heap[c] costs little. alloc and Index are generic, so rustc
compiles a separate copy for each object type (chapter 132). In the copy
for Gc<ObjClosure>, T::arena(self) is simply &self.closures. No
type tag is checked at run time. What remains is the arena’s own work: a
bounds check on the Vec, and a check that the slot isn’t None. That
second check calls use_after_free, which is marked #[cold] and never
returns. It names the type in its panic message with
std::any::type_name, whose exact text the standard library doesn’t
promise[3]. It is for people reading a
crash, not for code to compare.
Your turn
Here is a two-arena heap in drip-rs's style. Gc<T> is a typed slot number, each arena is a Vec, and the HeapObject trait says which arena belongs to which type. Str already implements it.
1. Implement HeapObject for Class. 2. Write alloc: push the object onto its arena and return a handle to its slot. 3. Implement Index<Gc<T>> and IndexMut<Gc<T>> for Heap, so heap[c].arity reads a class and heap[c].arity += 1 changes it.
Each kind counts its slots from 0 on its own: the first class is slot 0 even if strings came first.
use std::marker::PhantomData;
use std::ops::{Index, IndexMut};
struct Gc<T> {
index: u32,
marker: PhantomData<fn() -> T>,
}
impl<T> Clone for Gc<T> {
fn clone(&self) -> Self {
*self
}
}
impl<T> Copy for Gc<T> {}
struct Str {
chars: String,
}
struct Class {
name: Gc<Str>,
arity: u32,
}
#[derive(Default)]
struct Heap {
strings: Vec<Str>,
classes: Vec<Class>,
}
trait HeapObject: Sized {
fn arena(h: &Heap) -> &Vec<Self>;
fn arena_mut(h: &mut Heap)
-> &mut Vec<Self>;
}
impl HeapObject for Str {
fn arena(h: &Heap) -> &Vec<Self> {
&h.strings
}
fn arena_mut(h: &mut Heap)
-> &mut Vec<Self> {
&mut h.strings
}
}
impl HeapObject for Class {
fn arena(h: &Heap) -> &Vec<Self> {
todo!()
}
fn arena_mut(h: &mut Heap)
-> &mut Vec<Self> {
todo!()
}
}
impl Heap {
// Store an object in its arena.
fn alloc<T: HeapObject>(
&mut self,
object: T,
) -> Gc<T> {
todo!()
}
}
impl<T: HeapObject> Index<Gc<T>>
for Heap
{
type Output = T;
fn index(&self, h: Gc<T>) -> &T {
todo!()
}
}
impl<T: HeapObject> IndexMut<Gc<T>>
for Heap
{
fn index_mut(&mut self, h: Gc<T>)
-> &mut T {
todo!()
}
}drip-rs's Inherit instruction copies every method of the superclass into the subclass, both of which live in the same arena. Write inherit: add sup's methods to the end of sub's, keeping sub's own. Leave sup unchanged.
The obvious version, borrowing sup's list while writing sub's, won't compile. Copy out first.
use std::ops::{Index, IndexMut};
#[derive(Clone, Copy)]
struct Gc(usize);
struct Class {
methods: Vec<String>,
}
struct Heap {
classes: Vec<Class>,
}
impl Index<Gc> for Heap {
type Output = Class;
fn index(&self, h: Gc) -> &Class {
&self.classes[h.0]
}
}
impl IndexMut<Gc> for Heap {
fn index_mut(&mut self, h: Gc)
-> &mut Class {
&mut self.classes[h.0]
}
}
// Add sup's methods to sub's.
fn inherit(
heap: &mut Heap,
sub: Gc,
sup: Gc,
) {
todo!()
}What you learned
- drip-rs’s Heap holds one arena per object type. Objects have no
header: no
type, nonext, no mark bit. - An object handle
Gc<T>stands for an object.heap[handle]reads it andheap[handle].field = ...writes it. - The
HeapObjecttrait tells generic code which arena belongs to each type, so oneallocand oneIndeximpl serve all eight. - A TypeScript chain like
v.fn.namebecomes a chain of lookups, with each handle copied out on the way. heap[h]borrows the whole heap: to read one object and write another, copy or clone what you need first.
Check your understanding
0/6- 1.
In drip-rs, what does
heap[c]do whenc: Gc<ObjClosure>? - 2.
Which of these does a drip-rs
ObjInstancecontain? - 3.
Why does drip-rs’s heap have one generic
alloc<T: HeapObject>rather than eight functions like TypeScript’snewClass? - 4.
Which version compiles?
// A let from = &heap[sup].methods; heap[sub].methods.add_all(from); // B let m = heap[sup].methods.clone(); heap[sub].methods.add_all(&m); - 5.
What is the run-time cost of choosing the arena in
heap[h]? - 6.
A collector bug frees a class that is still in use. What happens the next time drip-rs reads it in a debug or stress run?
References
- [1] Steve Klabnik, Carol Nichols, Chris Krycho (2026). The Rust Programming Language (3rd edition). No Starch Press; free online at doc.rust-lang.org/book. doc.rust-lang.org/book/ (checked 2026-10-31)
- [2] The Rust Project Developers. Trait std::ops::Index (Rust standard library documentation). Rust standard library API documentation; source in library/core/src/ops/index.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/ops/trait.Index.html (checked 2026-10-31)
- [3] The Rust Project Developers. Function std::any::type_name (Rust standard library documentation). Rust standard library API documentation; source in library/core/src/any.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/any/fn.type_name.html (checked 2026-10-31)