162. Inheritance in Rust
TypeScript inherits in one line: copy the superclass’s method table into the subclass’s. In drip-rs both tables sit in the same arena, and reading one while writing the other is exactly what the borrow checker forbids. The fix costs one clone, once per class.
- Follow class B < A through OP_INHERIT and OP_METHOD in drip-rs’s listing.
- Explain why drip-rs clones the superclass’s table before copying it, and why that clone can’t start a collection.
- Map the compiler’s Vec of ClassState and its synthetic super token to the TypeScript compiler.
- Write OP_INHERIT for a small VM, with tests.
Two classes, one copy
class A {
hi() { print "A.hi"; }
me() { print "A.me"; }
}
class B < A {
hi() {
print "B.hi";
super.hi();
}
}
B().hi();
B().me();
drip-rs prints B.hi, A.hi, A.me. The part of the listing that
declares B, from drip-rs --disassemble:
0015 5 OP_CLASS 6 'B'
0017 | OP_DEFINE_GLOBAL 6 'B'
0019 | OP_GET_GLOBAL 7 'A'
0021 | OP_GET_GLOBAL 8 'B'
0023 | OP_INHERIT
0024 | OP_GET_GLOBAL 9 'B'
0026 9 OP_CLOSURE 11 <fn hi>
0028 | local 1
0030 | OP_METHOD 10 'hi'
0032 10 OP_POP
0033 | OP_CLOSE_UPVALUE
And super.hi(); inside B.hi is OP_GET_LOCAL 0, OP_GET_UPVALUE 0,
OP_SUPER_INVOKE (0 args) 1 'hi'. All of it is chapter 59’s design. I
disassembled the same file with the TypeScript VM’s disassembleChunk,
and the two listings were identical. The Rust differs in one
line of the VM and in how the compiler remembers which class it is in.
The line that won’t compile
A child starting a recipe binder photocopies every page of the parent’s binder first, then writes their own versions over some of them. Later, nobody has to walk to the parent’s house to read a recipe.
OP_INHERIT in TypeScript:
(this.peek(0) as ObjClass).methods
.addAll(superclass.methods);
The same shape in Rust reads one class from the heap while writing
another. On a toy heap with Index and IndexMut impls (chapter 153),
rustc 1.97 says (trimmed):
error[E0502]: cannot borrow `*h` as
mutable because it is also borrowed
as immutable
33 | let from = &h[sup].methods;
| - immutable borrow
34 | h[sub].methods.extend(
| ^ mutable borrow occurs here
35 | from.iter().map(...));
| ---- immutable borrow later used
h[sub] is *h.index_mut(sub): it borrows the whole heap, not one
slot[2]. The borrow checker can’t know that
sub and sup are different handles, so it can’t allow a write to one
while a reference into the other is alive. drip-rs’s arm, from
src/vm.rs:
OpCode::Inherit => {
let Value::Class(superclass) =
self.peek(1)
else {
return Err(self.runtime_error(
"Superclass must be a class."));
};
let subclass = as_class(self.peek(0));
// Copy-down inheritance: methods are
// copied once, now.
let methods = self.heap[superclass]
.methods.clone();
self.heap[subclass].methods
.add_all(&methods);
self.pop();
}
Table derives Clone, so clone() copies its count and its two
Vecs of keys and values. The shared borrow ends at the semicolon, and
then the mutable one is free to start.
What the clone costs
The clone is Rust memory, not a Drip object. It never goes through
Heap::alloc, so it can’t start a collection halfway through, and
nothing on the stack needs protecting. It is dropped at the end of the
arm. And it happens once per class declaration that has a superclass,
not once per method call.
The superclass is checked with a let ... else, because the name
after < can hold anything at run time. For let s = "x"; on line 1
and class C < s {} on line 2, both VMs print
Superclass must be a class. and [line 2] in script, and exit with 70. The
subclass is opened with as_class, which calls unreachable! on
anything else: the compiler always puts the class it just made there.
Order does the rest. OP_INHERIT runs before any of B’s
OP_METHODs, so an override simply overwrites the copied entry.
Copy-down inheritance copies every entry of the superclass’s methods
table into the subclass’s table once, when the class is declared, before
the subclass’s own methods are added, so overrides win. After that a
method lookup on the subclass is one probe in its own table. In drip-rs
both tables live in the class arena, so OP_INHERIT clones the
superclass’s Table first and then adds it to the subclass. This is
correct only because a Drip class can’t gain methods after its
declaration.
class Base {
m() { print "Base.m"; }
}
class Derived < Base {
m() { super.m(); }
}
class Other {
m() { print "Other.m"; }
}
let d = Derived();
d.m();
Base = Other;
d.m();
let g = Derived().m;
g();super in the compiler
The TypeScript compiler keeps nested classes in a linked list,
{ enclosing, hasSuperclass }, and holds the current one in cc while
it compiles the superclass clause, setting cc.hasSuperclass = true
later. drip-rs uses a stack, classes: Vec<ClassState>, like its stack
of FunctionStates (chapter 152). Holding &mut ClassState while
calling self.variable(..) would be the E0499 of chapter 155, so it
reaches in again when it needs to:
self.begin_scope();
self.add_local("super");
self.define_variable(0);
self.named_variable(class_name, false);
self.emit_op(OpCode::Inherit);
self.classes.last_mut()
.expect("in a class")
.has_superclass = true;
add_local("super") stores a &'static str where a &'src str goes.
That is allowed, because 'static outlives 'src. The same goes for
Token::synthetic("this", line) and Token::synthetic("super", line),
which super_ passes to named_variable, just as TypeScript’s
synthetic('super') does. Its checks come first:
match self.classes.last() {
None => self.error("Can't use \
'super' outside of a class."),
Some(class)
if !class.has_superclass =>
{
self.error("Can't use 'super' in \
a class with no superclass.");
}
Some(_) => {}
}
Calling self.error inside the arm is fine: class is last used in the
guard, so the borrow of self.classes has ended.
At run time, OP_GET_SUPER pops the superclass and calls chapter 161’s
bind_method, with this on top of the stack as the receiver.
OP_SUPER_INVOKE pops it and calls invoke_from_class. Both open it
with as_class.
Copy-down trades memory for lookups: every subclass carries its own copy
of everything it inherits, and in return no lookup walks a chain. clox
makes the same trade, and depends on the same fact, that classes are
closed once declared[1]. The corpus checks it from
the outside: inheritance/ and super/ hold 27 programs, and drip-rs
passes all of them, under the stress collector too.
Your turn
Write inherit, this small VM's OP_INHERIT. The classes live in one Vec, as drip-rs's live in one arena.
1. The superclass is peek(1). If it isn't a Value::Class, return Err("Superclass must be a class."). 2. The subclass is peek(0) (always a class). 3. Copy every method of the superclass into the subclass. The superclass must keep its own. 4. Pop the subclass.
The TypeScript one-liner, writing self.classes[sub] while borrowing self.classes[sup], won't compile.
use std::collections::HashMap;
#[derive(Clone, Copy, Debug, PartialEq)]
enum Value {
Num(f64),
Class(usize),
}
type Methods =
HashMap<&'static str, usize>;
struct ClassObj {
methods: Methods,
}
struct Vm {
stack: Vec<Value>,
classes: Vec<ClassObj>,
}
type Run = Result<(), String>;
const BAD_SUPER: &str =
"Superclass must be a class.";
impl Vm {
fn peek(&self, d: usize) -> Value {
let n = self.stack.len();
self.stack[n - 1 - d]
}
// OP_METHOD: add or overwrite.
fn method(
&mut self,
c: usize,
name: &'static str,
f: usize,
) {
let cl = &mut self.classes[c];
cl.methods.insert(name, f);
}
// OP_INHERIT: the superclass is at
// peek(1), the subclass at peek(0).
fn inherit(&mut self) -> Run {
todo!()
}
}What you learned
OP_INHERITcopies the superclass’s methods down into the subclass, once, before the subclass’s ownOP_METHODs run.- Reading
heap[sup]while writingheap[sub]is E0502: indexing borrows the whole heap. drip-rs clones the table first. - The clone is Rust memory, so it can’t start a collection.
- The compiler keeps a
Vec<ClassState>and reaches back in withlast_mut(), instead of holding a reference as TypeScript holdscc. "super"and"this"are&'static strs, accepted wherever a&'src stris expected.
Check your understanding
0/6- 1.
When does drip-rs copy a superclass’s methods into a subclass?
- 2.
What does rustc 1.97 report for
h[sub].methods.extend(...)whilelet from = &h[sup].methods;is still used? - 3.
Why is the
clone()in theInheritarm safe with respect to the garbage collector? - 4.
class B < Aoverrideshi. Why doesB().hi()call B’s version? - 5.
drip-rs’s compiler sets
has_superclasswithself.classes.last_mut().expect("in a class").has_superclass = true;. Why not keep a&mut ClassStatefrom the start, as TypeScript keepscc? - 6.
In the reassign example,
Base = Other;and thend.m()still printsBase.m. Why?
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)