Viscosity
Review
Part X · Drip in Rust · Day 40

160. Classes and instances in Rust

An instance in drip-rs is a class handle and a hash table, living in an arena slot. Reading a field is one lookup and a copy; writing one is a single line, once you have read everything you need first.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Map drip-rs’s ObjClass and ObjInstance to the TypeScript objects, and explain what the field table is.
  • Trace how calling a class makes an instance, and why the class stays on the stack meanwhile.
  • Explain the GetProperty and SetProperty arms, and the borrow error that shapes SetProperty.
  • Write property access for a small VM, with tests.

Fields in a table

class Point {}
let p = Point();
p.x = 3;
print p.x; // 3
print p;   // Point instance

A Drip instance is a bag of fields that you can add to at any time, as in chapter 43. Chapter 59 put that bag in the TypeScript VM as a hash table on each instance. drip-rs does the same, adapted from src/object.rs:

pub struct ObjClass {
    pub name: StrRef,
    pub methods: Table,
}

pub struct ObjInstance {
    pub class: Gc<ObjClass>,
    pub fields: Table,
}

The TypeScript field is called klass, because class is a reserved word in JavaScript. In Rust, class is not a keyword [2], so drip-rs uses the plain word.

Table is chapter 154’s table: open addressing, keys and values in two columns, keyed by StrRef. Field names are interned strings whose handle carries the hash, so looking up x never visits the string arena. The table is a plain Rust value inside the instance. Its two Vecs are owned by the instance and freed with it when sweep drops the object.

Intuition

A luggage tag tied to a notebook. The tag says which class the instance came from. The notebook has one line per field, and you can add a line whenever you like. Everyone holding the instance’s number opens the same notebook.

Making an instance

OpCode::Class allocates an ObjClass with its name and an empty method table, and pushes Value::Class(handle). Calling that value makes an instance, adapted from call_value:

Value::Class(class) => {
    let instance = self.alloc(ObjInstance {
        class, fields: Table::new() });
    self.set_peek(argc,
        Value::Instance(instance));
    // ...run `init` if the class has one
    // (chapter 161), else check argc == 0
}

alloc may collect first, and the new instance holds class. That is safe because the class is still on the stack, in the callee’s slot, the rule from chapter 159. Only then does set_peek overwrite that slot with the instance, which is where an initializer will find this.

Get and set

Adapted from the GetProperty and SetProperty arms:

OpCode::GetProperty => {
    let Value::Instance(inst) = self.peek(0)
    else { /* "Only instances have
               properties." */ };
    let name = self.read_string();
    if let Some(v) =
        self.heap[inst].fields.get(name)
    {
        self.pop();
        self.push(v);
    } else {
        let class = self.heap[inst].class;
        self.bind_method(class, name)?;
    }
}
OpCode::SetProperty => {
    let Value::Instance(inst) = self.peek(1)
    else { /* "Only instances have
               fields." */ };
    let name = self.read_string();
    let value = self.peek(0);
    self.heap[inst].fields.set(name, value);
    self.pop();
    self.pop();
    self.push(value);
}

fields.get returns Option<Value>, a copy, so the borrow of the heap ends before pop and push need &mut self. A field shadows a method of the same name, because fields are checked first; a missing field falls through to the class’s methods, which is chapter 161.

Step throughAn instance and its fields
The value stack, the class arena, the instance arena and the instance's fields table0000 OP_CLASS 0 'Point'stack<script>0Class(Gc(0))123heap.classes[0]name: "Point"methods: Table (empty)heap.instances[0]not allocated yetstdoutValues on the stack hold handles; the objects live in the arenas.
Class allocates an ObjClass with its interned name and an empty methods Table, and pushes Value::Class with the handle. DEFINE_GLOBAL then moves it into the globals table as Point.
1/6

The TypeScript SET_PROPERTY writes it in one line, inst.fields.set(readString(), this.peek(0)). On a toy Rust VM, the same one-liner:

self.insts[i].fields
    .insert(self.read_byte() as u32, v);

rustc 1.97 (trimmed):

error[E0499]: cannot borrow `*self` as
  mutable more than once at a time
16 | self.insts[i].fields
   | ---------- first mutable borrow
   |            occurs here
17 |     .insert(self.read_byte() as u32..
   |      ------ ^^^^ second mutable borrow
   |      |           occurs here
   |      first borrow later used by call

The receiver, the field table, is borrowed mutably first, and then the argument wants &mut self too, to advance ip. drip-rs reads the name and the value into locals before it touches the table. It’s the same habit as chapter 156’s patch_jump: finish reading, then borrow to write.

Definition

In drip-rs, an instance is an ObjInstance in the heap’s instance arena: a Gc<ObjClass> handle to its class and a field table, a Table from interned names to values that the instance owns. Value::Instance(handle) refers to it. GetProperty reads a field with one table lookup, falling back to the class’s methods; SetProperty adds or overwrites one. Both check that the receiver is an instance first.

Try itRead the operands, then write
use std::collections::HashMap;

struct Inst {
    fields: HashMap<u8, f64>,
}

struct Vm {
    insts: Vec<Inst>,
    code: Vec<u8>,
    ip: usize,
}

impl Vm {
    fn read_byte(&mut self) -> u8 {
        self.ip += 1;
        self.code[self.ip - 1]
    }

    fn set_property(
        &mut self,
        i: usize,
        v: f64,
    ) {
        // Read every operand first...
        let name = self.read_byte();
        // ...then borrow the table.
        let inst = &mut self.insts[i];
        inst.fields.insert(name, v);
        // Uncomment for E0499:
        // self.insts[i].fields.insert(
        //     self.read_byte(), v);
    }
}

fn main() {
    let new = || Inst {
        fields: HashMap::new(),
    };
    let mut vm = Vm {
        insts: vec![new(), new()],
        code: vec![7, 7],
        ip: 0,
    };
    vm.set_property(0, 3.0);
    vm.set_property(1, 4.0);
    // Same name, different tables.
    for (i, inst) in
        vm.insts.iter().enumerate()
    {
        println!("inst {i}: {:?}",
            inst.fields);
    }
}
Open in Rust Playground ↗

When it goes wrong

The type checks come first, so the error names the operation. Each of these lines fails with exit code 70, and drip-rs and the TypeScript VM print the same text:

print p.z;  Undefined property 'z'.
n.x = 2;    Only instances have fields.
123.foo;    Only instances have
            properties.

I ran the first two in small programs, with p an instance and n a number. The third is the corpus program field/get_on_num.drip.

Under the hood

To the collector, an instance is two references, adapted from blacken: instance.class.trace(marks) and instance.fields.trace(marks), which marks every key and every value. Its size for pacing is size_of::<ObjInstance>(): 64 bytes, measured with rustc 1.97 on x86-64 Linux. That leaves out the field table’s buffers, which grow with the fields, so a wide instance counts for less than it holds. The TypeScript heap has the same blind spot, with a fixed 48. In the committed benchmark run, instantiation.drip took 628 ms on drip-rs and 3570 ms on the TypeScript VM, and properties.drip, which reads and writes fields in a loop, 795 ms and 2781 ms.

Your turn

ExerciseGetProperty and SetProperty

This VM has drip-rs's shape in small: values hold handles, heap is the instance arena, and each Instance owns a field table (a HashMap here, drip-rs's Table there). Write the two property instructions:

1. get_property(name). The receiver is on top of the stack. If it isn't an instance, return Err with NO_GET. If the instance has the field, replace the receiver with the field's value. If not, return Err(undef(name)), which reads Undefined property 'x'. (drip-rs would try the class's methods first; there are none here). 2. set_property(name). The stack ends with the receiver and then the value. If the receiver isn't an instance, return Err with NO_SET. Otherwise add or overwrite the field, and leave just the value on the stack.

Check the receiver before you change anything.

use std::collections::HashMap;

#[derive(Clone, Copy, Debug, PartialEq)]
enum Value {
    Nil,
    Num(f64),
    Inst(usize), // a handle
}
use Value::*;

type Name = &'static str;

struct Instance {
    fields: HashMap<Name, Value>,
}

const NO_GET: &str =
    "Only instances have properties.";
const NO_SET: &str =
    "Only instances have fields.";

fn undef(name: Name) -> String {
    let m = "Undefined property";
    format!("{m} '{name}'.")
}

struct Vm {
    stack: Vec<Value>,
    // The instance arena.
    heap: Vec<Instance>,
}

impl Vm {
    fn new() -> Vm {
        Vm {
            stack: Vec::new(),
            heap: Vec::new(),
        }
    }

    fn push(&mut self, v: Value) {
        self.stack.push(v);
    }

    fn pop(&mut self) -> Value {
        self.stack.pop().unwrap()
    }

    fn peek(&self, d: usize) -> Value {
        let n = self.stack.len();
        self.stack[n - 1 - d]
    }

    // What calling a class makes.
    fn new_inst(&mut self) -> Value {
        let fields = HashMap::new();
        let i = Instance { fields };
        self.heap.push(i);
        Inst(self.heap.len() - 1)
    }

    fn get_property(
        &mut self,
        name: Name,
    ) -> Result<(), String> {
        todo!()
    }

    fn set_property(
        &mut self,
        name: Name,
    ) -> Result<(), String> {
        todo!()
    }
}
Open in Rust Playground ↗

What you learned

  • An instance is an ObjInstance: a class handle and a field table it owns, keyed by interned names that carry their hash.
  • Calling a class allocates the instance while the class is still on the stack, then set_peek puts the instance in the callee’s slot.
  • GetProperty copies the field’s value out, so the heap borrow ends at once; fields shadow methods.
  • SetProperty reads its operands first, then borrows the table: the TypeScript one-liner is E0499 in Rust.
  • Both arms check the receiver’s type first, giving “Only instances have properties.” or ”… fields.”.

Check your understanding

0/6
  1. 1.

    What does Value::Instance hold in drip-rs?

  2. 2.

    When Point() runs, why is it safe to call alloc for the instance before anything holds it?

  3. 3.

    In GetProperty, an instance has a field go and its class has a method go. What happens?

  4. 4.

    Why does drip-rs read the name and value into locals before self.heap[inst].fields.set(..)?

  5. 5.

    After p.x = 3; runs SET_PROPERTY, what is on top of the stack?

  6. 6.

    What does drip-rs count as an instance’s size when pacing collections?

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. Keywords (The Rust Reference). The Rust Reference; source in src/keywords.md of github.com/rust-lang/reference. doc.rust-lang.org/reference/keywords.html (checked 2026-10-31)