Viscosity
Review
Part X · Drip in Rust · Day 39

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.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • 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.

Intuition

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:

Step throughPrinting a closure: three trips to the heap
Three arenas of the drip-rs heap, with the slots each lookup readsprint add;Value::Closure(Gc(1))closuresArena<ObjClosure>0script1function: Gc(1)upvalues: []functionsArena<ObjFunction>0scriptname: None1arity: 2name: Some(…)stringsArena<ObjString>0"init"1"clock"2"add"Each row is one Arena<T> inside the Heap; boxes are slots.
The stack holds a Value::Closure. Its payload is only a handle, Gc<ObjClosure> with index 1. To print `<fn add>`, drip-rs needs the function’s name, two objects away.
1/6
Definition

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.

Try itA two-arena heap with one generic alloc
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('!');
}
Open in Rust Playground ↗

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.

Under the hood

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

ExerciseThe heap’s front door

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!()
    }
}
Open in Rust Playground ↗
Extra practiceCopy the methods down

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!()
}
Open in Rust Playground ↗

What you learned

  • drip-rs’s Heap holds one arena per object type. Objects have no header: no type, no next, no mark bit.
  • An object handle Gc<T> stands for an object. heap[handle] reads it and heap[handle].field = ... writes it.
  • The HeapObject trait tells generic code which arena belongs to each type, so one alloc and one Index impl serve all eight.
  • A TypeScript chain like v.fn.name becomes 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. 1.

    In drip-rs, what does heap[c] do when c: Gc<ObjClosure>?

  2. 2.

    Which of these does a drip-rs ObjInstance contain?

  3. 3.

    Why does drip-rs’s heap have one generic alloc<T: HeapObject> rather than eight functions like TypeScript’s newClass?

  4. 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. 5.

    What is the run-time cost of choosing the arena in heap[h]?

  6. 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. [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. [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. [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)