Viscosity
Review
Part III · A tree-walking interpreter · Day 11

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.

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

Intuition

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.

Definition

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);
}
Step throughLooking up super
class A {
m() { print "A.m"; }
}
class B < A {
m() {
print "B.m";
super.m();
}
}
class C < B {}
C().m();
Environments on the left: the call, the this-environment, the super environment and the globals. Classes on the right: C inherits from B, which inherits from A, and the C instance.ENVIRONMENTSCLASSES (↓ superclass)class Am()globalsA
class A: an ordinary class with one method. Its methods capture the globals.
1/7
Definition

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.

Try itInherit, override, super
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.

How many times faster is the bytecode VM?
Tree-walker time ÷ VM time, same machine (longer is a bigger win for the VM)
zoo: tree-walker 8358 ms, VM 1749 ms, 4.78×zoo4.78×properties: tree-walker 11853 ms, VM 2781 ms, 4.26×properties4.26×fib: tree-walker 23695 ms, VM 5685 ms, 4.17×fib4.17×method_call: tree-walker 4106 ms, VM 1085 ms, 3.78×method_call3.78×trees: tree-walker 53136 ms, VM 21327 ms, 2.49×trees2.49×binary_trees: tree-walker 19018 ms, VM 13078 ms, 1.45×binary_trees1.45×invocation: tree-walker 2962 ms, VM 2042 ms, 1.45×invocation1.45×equality: tree-walker 15533 ms, VM 16501 ms, 0.94×equality0.94×instantiation: tree-walker 2742 ms, VM 3570 ms, 0.77×instantiation0.77×1× = same speedstring_equality: no bar, the VM failed to compile itzoo_batch: runs for a fixed 10 s, so no speed-up
Table view
programtree-walker msVM msspeed-up
binary_trees19018130781.45×
equality15533165010.94×
fib2369556854.17×
instantiation274235700.77×
invocation296220421.45×
method_call410610853.78×
properties1185327814.26×
string_equality13874—VM error
trees53136213272.49×
zoo835817494.78×
zoo_batch10027100111×

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.

The VM wins on most programs, by up to 4.78×, but not on all of them.

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_batch isn’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_equality has 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.
  • instantiation and equality came 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.

Definition

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.

Under the hood

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

ExerciseA square is a shape

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();
Extra practiceCounting the walk

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 < A makes A the superclass, and findMethod walks up the chain. A subclass overrides by reusing a name.
  • super is decided by where a method is written. Drip keeps the superclass in an environment made at class declaration, so super is found by scope depth, and this is one environment closer.
  • The VM beat the tree-walker on most benchmark programs, in one run on one machine. zoo_batch runs 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. 1.

    In Drip, class Dog < Animal {} has no methods. What does Dog("Rex") do?

  2. 2.

    A method written in class B (with class B < A) runs on an instance of C, where class C < B. What does super mean inside it?

  3. 3.

    Which of these is a resolver error, reported before anything runs?

  4. 4.

    Why is zoo_batch’s time in the benchmark not a speed measurement?

  5. 5.

    Which is NOT one of the tree-walker’s sources of overhead described in this chapter?

  6. 6.

    How does pointer-chasing (chapter 20) show up in the tree-walker?

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] 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. [3] MDN contributors. Map. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map (checked 2026-10-31)
  4. [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)