161. Methods and initializers in Rust
A bound method is an instance and a closure tied together. In TypeScript that is an object with two references. In drip-rs it is two Copy fields in an arena slot, and calling it takes one line that Rust only accepts because those fields are Copy.
- Read drip-rs’s ObjBoundMethod and bind_method, and say why the receiver stays on the stack while the object is allocated.
- Explain why call_value destructures self.heap[bound] instead of copying the whole object.
- Follow a class call through init, and the compiler’s Initializer kind through emit_return.
- Write the bound-method and class arms of call_value for a small VM, with tests.
The cake, one more time
Chapter 59’s class, run by drip-rs:
class Cake {
init(f) { this.f = f; }
taste() { print "Mmm, " + this.f; }
}
let c = Cake("lemon");
c.taste();
let t = c.taste;
t();
print t;
It prints Mmm, lemon twice, then <fn taste>, exactly like the
TypeScript VM. drip-rs --disassemble shows the same split as in
chapter 59: c.taste() is one OP_INVOKE, while let t = c.taste; is
OP_GET_PROPERTY 13 'taste', which has to build a bound method. That
object, and the init call in Cake("lemon"), are this chapter.
A bound method is two handles
A remote control already paired with one television. Pressing a button (calling the method) works on that set (the receiver), whoever holds the remote and wherever they carry it.
The TypeScript object is { type: 'bound', receiver, method }. The
Rust one, from src/object.rs:
/// A method together with the instance
/// it was looked up on.
pub struct ObjBoundMethod {
pub receiver: Value,
pub method: Gc<ObjClosure>,
}
It lives in its own arena, heap.bound_methods, and a value refers to
it as Value::BoundMethod(Gc<ObjBoundMethod>). The receiver is kept as
a Value, as clox keeps it, which is exactly what a call writes back
onto the stack. When the collector traces one, it marks both fields. Printing one prints its method, <fn taste>.
GetProperty looks in the instance’s fields first (chapter 160). When
that misses, it calls bind_method, adapted from src/vm.rs:
fn bind_method(
&mut self, class: Gc<ObjClass>,
name: StrRef,
) -> Run {
let methods = &self.heap[class].methods;
let Some(method) = methods.get(name)
else {
return Err(
self.undefined_property(name));
};
let receiver = self.peek(0);
let bound = self.alloc(ObjBoundMethod {
receiver,
method: as_closure(method),
});
self.pop();
self.push(Value::BoundMethod(bound));
Ok(())
}
Two details. The table stores Values, so as_closure opens the
Value::Closure with a match and calls unreachable! on anything
else. TypeScript wrote method as ObjClosure, a promise the type
checker takes on trust. And the order matters: alloc may run a
collection (chapter 159), so the receiver stays on the stack, a root,
until the bound method exists. Only then is it popped.
Calling it: copy two fields out
call_value’s first arm, from src/vm.rs:
Value::BoundMethod(bound) => {
let ObjBoundMethod { receiver, method }
= self.heap[bound];
// The receiver goes in slot 0, where
// the method finds `this`.
self.set_peek(arg_count, receiver);
self.call(method, arg_count)
}
The tempting line is let b = self.heap[bound];. On a toy with a
Vec<Bound>, rustc 1.97 says (trimmed):
error[E0507]: cannot move out of index
of `Vec<Bound>`
17 | let b = bounds[0];
| ^^^^^^^^^ move occurs
because value has type `Bound`, which
does not implement the `Copy` trait
Indexing gives a place, and let b = place would move the object out
of the arena, leaving a hole[2]. The
destructuring pattern instead binds each field by value. Value and
Gc<T> are both Copy, so each field is copied and the object stays
put. TypeScript’s callee.receiver is just a reference read, so the
question never comes up there.
Then set_peek(arg_count, receiver) overwrites the callee’s own slot,
which call turns into the new frame’s slot 0. Inside the method,
this is GET_LOCAL 0, so it is the receiver.
A bound method in drip-rs is an ObjBoundMethod, holding a
receiver: Value and a method: Gc<ObjClosure>, in the heap’s
bound_methods arena, and a Value::BoundMethod handle to it.
bind_method makes one when a property read finds a method: it
allocates while the receiver is still on the stack, then replaces the
receiver with the handle. Calling one copies both fields out with a
destructuring pattern, puts the receiver in the callee’s slot, and calls
the closure.
Initializers
Calling a class is the Value::Class arm. Chapter 160 showed its first
half: allocate the instance while the class is still in the callee’s
slot, then overwrite that slot with it. The second half, from
src/vm.rs:
let methods = &self.heap[class].methods;
match methods.get(self.init_string) {
Some(init) => self.call(
as_closure(init), arg_count),
None if arg_count != 0 => {
let msg = format!(
"Expected 0 arguments \
but got {arg_count}.");
Err(self.runtime_error(msg))
}
None => Ok(()),
}
init_string is "init", interned once in Vm::new and listed in
VmRoots, so the lookup allocates nothing and the string can’t be
collected. get returns a copied Option<Value>, so the borrow in
methods ends before call needs &mut self. TypeScript’s two ifs
become one match with a guard, which says in one place what happens
in all three cases.
The compiler’s half: method() picks FunctionKind::Initializer when
the name is init, an enum variant where TypeScript compared the string
'initializer'. Then:
fn emit_return(&mut self) {
if self.state().kind
== FunctionKind::Initializer
{
// An initializer returns `this`,
// which is in slot 0.
self.emit_op_with(
OpCode::GetLocal, 0);
} else {
self.emit_op(OpCode::Nil);
}
self.emit_op(OpCode::Return);
}
FunctionKind is Copy, so self.state().kind is copied out and the
borrow ends before emit_op needs &mut self, the habit from chapter
155. return_statement does the same, let kind = self.state().kind;,
before it reports Can't return a value from an initializer.
class Cup {
init(n) { this.n = n; }
sip() { print this.n; }
}
let s = Cup("tea").sip;
s();
let c = Cup("milk");
print c.init("oat") == c;
print c.n;
print s;Invoke, briefly
c.taste() skips all of this. invoke opens the receiver with
let Value::Instance(instance) = self.peek(arg_count) else { ... },
checks the fields (a field holding a function wins), and otherwise calls
invoke_from_class, which finds the method and calls it with the
receiver already in slot 0. No bound method, no allocation, as in
chapter 59.
Every Drip method call still hashes its name into a table, in both VMs,
as in clox[1]. What Rust changed is the shape of
the objects. A bound method is not a JavaScript object with two
references the engine must trace. It is a 4-byte handle to 24 bytes in
an arena slot: a 16-byte Value and a 4-byte Gc, padded (I printed
size_of for both with rustc 1.97). In the
committed benchmark run
(results/drip-rs-bench-2026-10-31.md), method_call.drip took 316 ms
in drip-rs and 1085 ms in the TypeScript VM. Chapter 167 asks where
that difference comes from.
Your turn
This small VM is shaped like drip-rs: values are a Copy enum of handles, and objects live in Vecs. call(f, argc) records a call and its slot 0, and call_value is written. Write its two helpers:
- **
call_bound(b, argc)**: copy the receiver and method out ofself.bounds[b], write the receiver over the callee's slot (set_peek(argc, ..)), and call the method. - **
call_class(c, argc)**: pushcontoinstances(the new instance's index is the last one), and writeInstance(index)over the callee's slot. Then callinitif the class has one; else, with arguments, returnErr("Expected 0 arguments but got N."); else returnOk(()).
BoundObj is deliberately not Copy, like drip-rs's ObjBoundMethod.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Value {
Num(f64),
Class(usize),
Instance(usize),
Bound(usize),
Closure(usize),
}
// Not Copy, like drip-rs's
// ObjBoundMethod.
struct BoundObj {
receiver: Value,
method: usize,
}
struct ClassObj {
init: Option<usize>,
}
struct Vm {
stack: Vec<Value>,
bounds: Vec<BoundObj>,
classes: Vec<ClassObj>,
// The class of each instance.
instances: Vec<usize>,
// Each call: (closure, slot 0).
calls: Vec<(usize, usize)>,
}
type Run = Result<(), String>;
const NOT_CALLABLE: &str = concat!(
"Can only call functions ",
"and classes.",
);
impl Vm {
fn set_peek(
&mut self,
d: usize,
v: Value,
) {
let n = self.stack.len();
self.stack[n - 1 - d] = v;
}
// Record the call and its slot 0.
fn call(
&mut self,
f: usize,
argc: usize,
) -> Run {
let n = self.stack.len();
let base = n - argc - 1;
self.calls.push((f, base));
Ok(())
}
// The callee is argc slots below
// the top of the stack.
fn call_value(
&mut self,
callee: Value,
argc: usize,
) -> Run {
match callee {
Value::Bound(b) => {
self.call_bound(b, argc)
}
Value::Class(c) => {
self.call_class(c, argc)
}
Value::Closure(f) => {
self.call(f, argc)
}
_ => {
Err(NOT_CALLABLE.into())
}
}
}
fn call_bound(
&mut self,
b: usize,
argc: usize,
) -> Run {
todo!()
}
fn call_class(
&mut self,
c: usize,
argc: usize,
) -> Run {
todo!()
}
}What you learned
- A bound method in drip-rs is
ObjBoundMethod { receiver, method }in an arena, reached throughValue::BoundMethod. bind_methodallocates before it pops, so the receiver is a root if a collection runs.- Indexing the heap gives a place. Destructure it to copy the
Copyfields out;let b = self.heap[bound];would be a move (E0507). - Calling a bound method or a class overwrites the callee’s slot,
which becomes slot 0,
this. initis found with the internedinit_string, andFunctionKind::Initializermakes every return produce slot 0.
Check your understanding
0/6- 1.
What does drip-rs store in an
ObjBoundMethod? - 2.
In
bind_method, why isself.pop()called afterself.alloc(...)and not before? - 3.
let b = self.heap[bound];fails with E0507. Which line does drip-rs use instead? - 4.
A program calls
Point(1, 2)andPointhas noinit. What happens? - 5.
Why does
emit_returnstart withif self.state().kind == FunctionKind::Initializerand still compile, even though it then callsself.emit_op_with(...)? - 6.
Which source compiles to an instruction that allocates a bound method?
References
- [1] Robert Nystrom (2021). Crafting Interpreters. Free web edition at craftinginterpreters.com (© 2015–2021); source, text and test suite at github.com/munificent/craftinginterpreters. github.com/munificent/craftinginterpreters (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)