Viscosity
Review
Part X · Drip in Rust · Day 41

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.

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

Intuition

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.

Step throughMaking and calling a bound method
The value stack beside the bound-method arena, while t = c.taste and t() runGET_GLOBAL 'c'stack0<script>1c: Cake instanceheap.bound_methodsemptyclass Cake: methodsinit → Closuretaste → ClosureA Value is a tag plus a handle; the objects live in arenas.
let t = c.taste; starts by pushing c. Slot 0 is the script’s own closure. No call has happened yet.
1/8
Definition

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.

Try itA bound method keeps its receiver, and init returns this
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.

Under the hood

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

ExerciseCalling bound methods and classes

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 of self.bounds[b], write the receiver over the callee's slot (set_peek(argc, ..)), and call the method.
  • **call_class(c, argc)**: push c onto instances (the new instance's index is the last one), and write Instance(index) over the callee's slot. Then call init if the class has one; else, with arguments, return Err("Expected 0 arguments but got N."); else return Ok(()).

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

What you learned

  • A bound method in drip-rs is ObjBoundMethod { receiver, method } in an arena, reached through Value::BoundMethod.
  • bind_method allocates before it pops, so the receiver is a root if a collection runs.
  • Indexing the heap gives a place. Destructure it to copy the Copy fields 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.
  • init is found with the interned init_string, and FunctionKind::Initializer makes every return produce slot 0.

Check your understanding

0/6
  1. 1.

    What does drip-rs store in an ObjBoundMethod?

  2. 2.

    In bind_method, why is self.pop() called after self.alloc(...) and not before?

  3. 3.

    let b = self.heap[bound]; fails with E0507. Which line does drip-rs use instead?

  4. 4.

    A program calls Point(1, 2) and Point has no init. What happens?

  5. 5.

    Why does emit_return start with if self.state().kind == FunctionKind::Initializer and still compile, even though it then calls self.emit_op_with(...)?

  6. 6.

    Which source compiles to an instruction that allocates a bound method?

References

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