44. Inheritance, and measuring the tree-walker
Drip gets its last feature, a class that builds on another class. Then you time the finished interpreter against a bytecode VM, and see why walking a tree is slow.
- Explain how a Drip method call finds an inherited method through the superclass chain.
- Trace a super call, and explain why super is stored in an environment instead of found through this.
- Read the tree-walker vs VM benchmark honestly, including the rows that aren’t speed measurements.
- Name three sources of a tree-walker’s overhead and connect them to Part II.
A dog is an animal
class Animal {
init(name) { this.name = name; }
speak() {
print this.name + " makes a sound";
}
}
class Dog < Animal {
speak() {
super.speak();
print this.name + " barks";
}
}
Dog("Rex").speak();
// Rex makes a sound
// Rex barks
Dog has no init, yet Dog("Rex") sets name. And Dog’s speak
replaces Animal’s, but can still call it.
Inheritance: one more place to look
A family recipe book. If your own notebook doesn’t have a recipe, you check your parent’s book, then your grandparent’s. A recipe in your own notebook wins over the older one with the same name.
class Dog < Animal stores Animal as Dog’s superclass. Finding a
method then becomes a walk up that chain:
findMethod(name: string) {
return this.methods.get(name)
?? this.superclass?.findMethod(name);
}
That’s all inheritance needs. Dog("Rex") finds init in Animal
and binds it to the new Dog instance.
The superclass is an ordinary expression, evaluated when the class
statement runs, so it has to hold a class: let A = "x"; class B < A {}
fails at runtime with Superclass must be a class. One mistake is caught
earlier: class A < A {} is a resolver error, A class can't inherit from itself.
Inheritance lets a class reuse another class’s methods. The class it builds on is its superclass. Looking a method up checks the class’s own methods first and then walks the superclass chain, so a subclass overrides a method by defining one with the same name.
super: decided where it’s written
super.speak() means “the speak above the class this method was
written in”, called on the same this. A tempting shortcut is to take
this’s class and ask for its superclass. It breaks with three levels:
class A { m() { print "A.m"; } }
class B < A {
m() { print "B.m"; super.m(); }
}
class C < B {}
C().m();
Running C().m() reaches B.m with this a C instance. “The
superclass of this’s class” is B, so super.m() would call B.m
again, which would call B.m again, until the stack overflows. The
right answer, A, depends on where the method was written. That’s
lexical scope again, so the interpreter uses the same trick it used for
this: an environment. When a class with a superclass is declared, it
wraps the methods in one holding super:
if (sup) {
this.env = new Environment(this.env);
this.env.define('super', sup);
}
// ...methods capture this.env...
if (sup) this.env = this.env.enclosing!;
The resolver adds a matching scope, so super gets a distance like any
variable, and this is always exactly one environment closer:
case 'super': {
const d = this.locals.get(e)!;
const sup = this.env.getAt(d, 'super');
const me = this.env.getAt(d - 1, 'this');
const m = sup.findMethod(e.method.lexeme);
if (!m) throw new RuntimeError(/*...*/);
return m.bind(me);
}
class A {m() { print "A.m"; }}class B < A {m() {print "B.m";super.m();}}class C < B {}C().m();
super.name finds the method name starting from the superclass of
the class the current method was written in, and binds it to the
current this. Drip stores that superclass in an environment created
when the subclass is declared, so it is found by scope depth, like any
variable. Using super outside a class, or in a class with no
superclass, is a resolver error.
class Animal {
init(name) { this.name = name; }
speak() {
print this.name + " makes a sound";
}
hello() { print "I am " + this.name; }
}
class Dog < Animal {
speak() {
super.speak();
print this.name + " barks";
}
}
let d = Dog("Rex");
d.hello();
d.speak();Measuring the tree-walker
Drip is finished. How fast is it? The companion code has a second Drip
implementation, the bytecode VM you’ll build in Part IV, and a harness
that runs both on the benchmark programs in corpus/benchmark/
(adapted, like the whole test corpus, from Crafting
Interpreters[1]). fib computes fib(35)
recursively; zoo and method_call call methods in loops.
The harness starts a fresh Node process for each program and engine,
and times the whole run(src): scanning, parsing, resolving and
running. These numbers are from this book’s benchmark run, in
companion/ts/results/drip-bench-2026-10-31.md: Node 22.23.4 on a
shared cloud VM, and the file records one run per program. By
chapter 24’s standards, that’s a first look, not a verdict.
Table view
| program | tree-walker ms | VM ms | speed-up |
|---|---|---|---|
| binary_trees | 19018 | 13078 | 1.45× |
| equality | 15533 | 16501 | 0.94× |
| fib | 23695 | 5685 | 4.17× |
| instantiation | 2742 | 3570 | 0.77× |
| invocation | 2962 | 2042 | 1.45× |
| method_call | 4106 | 1085 | 3.78× |
| properties | 11853 | 2781 | 4.26× |
| string_equality | 13874 | — | VM error |
| trees | 53136 | 21327 | 2.49× |
| zoo | 8358 | 1749 | 4.78× |
| zoo_batch | 10027 | 10011 | 1× |
From companion/ts/results/drip-bench-2026-10-31 (.md and .json): Node v22.23.4, V8 12.4.254.21-node.57, Intel(R) Xeon(R) Processor @ 2.10GHz, 2026-10-31. Best of N, each in a fresh process. Includes compile time. Shared cloud VM. Runs recorded per program: 1.
Read it carefully:
- Most programs run several times faster on the VM. The biggest wins are on programs full of calls, fields and methods.
zoo_batchisn’t a speed measurement. It loops until 10 seconds of clock time have passed, then prints how many batches it finished. Both engines take about 10 seconds by design. The useful number is the batch count, and the results file doesn’t record it.string_equalityhas no VM time. The VM refused to compile it: its bytecode allows at most 256 constants per chunk, and this program has more. A limit, not a slowness.instantiationandequalitycame out slower on the VM. A faster design isn’t faster at everything, and one run on a shared machine can’t tell you why. Part IV looks closer.
Why walking a tree is slow
Take one tiny piece of fib: n - 2. The CPU can subtract in a single
instruction. Here’s what the tree-walker does:
evaluate(binary) switch on kind
evaluate(variable n) switch on kind
lookUp: locals.get(node) Map lookup
getAt(0): ancestor loop
values.get("n") Map lookup
evaluate(literal 2) switch on kind
nums(...) type checks
subtract the real work
Three recursive calls, three dispatches on kind, two hash-map lookups
and two type checks, for one subtraction. That surrounding bookkeeping is
the interpreter’s overhead, and there are three big sources of it.
Dispatch on every node. Each node costs a call and a switch to
find out what it is, every time it runs, though the answer never
changes.
Pointer-chasing. The tree is a web of separate objects joined by references, and so is the environment chain. Each step loads the next pointer, and the step after can’t start until that load arrives. That’s chapter 20’s pointer-chasing: dependent loads are far slower than walking an array in order[2].
Environments made of Maps. Every variable read is at least one hash
lookup, by name (globals take two, has then get). Map lookups are
fast for a general table[3], but nothing like reading
a value at a known position. And every call allocates a new
Environment with a new Map, every method read allocates a bound
function plus another environment, and every return throws an object
to unwind the host stack.
Interpreter overhead is the work an interpreter does that the program didn’t ask for: dispatching on node kinds, following pointers between nodes and environments, looking names up in tables, allocating environments and checking types. In a tree-walker it often costs far more than the operations themselves.
Part IV attacks each source. The tree becomes bytecode: a flat array
of small instructions, read in order. Locals live in numbered stack
slots instead of Maps, so the resolver’s distances become slot numbers.
A return just pops a frame; nothing is thrown. Engines took the same
road: V8 runs your JavaScript first in Ignition, a bytecode interpreter,
before its faster tiers take over hot code[4].
Your turn
Square inherits from Shape, but Square(3).show() fails with Undefined property 'name'. Fix Square’s init so the program prints square and then 9. Don’t change Shape.
class Shape {
init(name) { this.name = name; }
show() {
print this.name;
print this.area();
}
}
class Square < Shape {
init(side) {
this.side = side;
}
area() {
return this.side * this.side;
}
}
Square(3).show();A tiny tree-walker in plain JavaScript. Nodes are { kind: "num", value }, { kind: "add", left, right } or { kind: "mul", left, right }. Write evaluate(node, stats) that returns the value and adds 1 to stats.visits for every node it evaluates. Then you can see how many visits a few operations cost.
function evaluate(node, stats) {
if (node.kind === 'num') {
return node.value;
}
const l = evaluate(node.left, stats);
const r = evaluate(node.right, stats);
if (node.kind === 'add') return l + r;
return l * r;
}What you learned
- Inheritance:
class B < AmakesAthe superclass, andfindMethodwalks up the chain. A subclass overrides by reusing a name. superis decided by where a method is written. Drip keeps the superclass in an environment made at class declaration, sosuperis found by scope depth, andthisis one environment closer.- The VM beat the tree-walker on most benchmark programs, in one run on
one machine.
zoo_batchruns for a fixed time, so its time says nothing about speed. - Interpreter overhead: node dispatch, pointer-chasing through the tree and environments, and Map-based environments that allocate on every call.
That’s Drip: a whole language, about a thousand lines of TypeScript. Next, Part IV rebuilds it as a compiler and a virtual machine, and removes that overhead one piece at a time.
Check your understanding
0/6- 1.
In Drip,
class Dog < Animal {}has no methods. What doesDog("Rex")do? - 2.
A method written in class
B(withclass B < A) runs on an instance ofC, whereclass C < B. What doessupermean inside it? - 3.
Which of these is a resolver error, reported before anything runs?
- 4.
Why is
zoo_batch’s time in the benchmark not a speed measurement? - 5.
Which is NOT one of the tree-walker’s sources of overhead described in this chapter?
- 6.
How does pointer-chasing (chapter 20) show up in the tree-walker?
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] Sergey Slotin. Memory Latency, in Algorithms for Modern Hardware. algorithmica.org (open online book). en.algorithmica.org/hpc/cpu-cache/latency/ (checked 2026-10-31)
- [3] MDN contributors. Map. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map (checked 2026-10-31)
- [4] Toon Verwaest, Leszek Swirski, Victor Gomes, Olivier Flückiger, Darius Mercadier, Camillo Bruni (2023). Maglev - V8’s Fastest Optimizing JIT. V8 blog (v8.dev), 5 December 2023. v8.dev/blog/maglev (checked 2026-10-31)