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.
- 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.
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.
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.
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.
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);
}
}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.
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
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!()
}
}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_peekputs the instance in the callee’s slot. GetPropertycopies the field’s value out, so the heap borrow ends at once; fields shadow methods.SetPropertyreads 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.
What does
Value::Instancehold in drip-rs? - 2.
When
Point()runs, why is it safe to callallocfor the instance before anything holds it? - 3.
In
GetProperty, an instance has a fieldgoand its class has a methodgo. What happens? - 4.
Why does drip-rs read the name and value into locals before
self.heap[inst].fields.set(..)? - 5.
After
p.x = 3;runsSET_PROPERTY, what is on top of the stack? - 6.
What does drip-rs count as an instance’s size when pacing collections?
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. 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)