AtlasLang: Evaluating the Tree Directly
The shortest path from a parsed program to a running one is to walk the tree and evaluate as you go. It is where most languages start, it is the version whose correctness is easiest to argue, and it is the version AtlasLang deliberately did not ship — for reasons worth knowing.
What is the least machinery that will actually run a parsed program?
The typed AST as the executable form, plus an environment: a stack of scopes mapping symbols to values. Nothing is lowered, nothing is linearised, and there is no separate instruction set. The tree that the checker annotated *is* the program, and the interpreter's state is a position in that tree plus the environment stack — which is why this is the representation whose correctness is easiest to argue about.
A tree-walking evaluator may assume everything earlier phases established: the tree is well-formed, every identifier resolves to a declaration, every expression has a type, and every operator is defined for its operand types. It owes exactly one thing in return — that it evaluates subexpressions in the order the language specifies. AtlasLang specifies left-to-right for binary operands and short-circuit for && and ||, so an evaluator that evaluated the right operand of && unconditionally would be wrong even though every individual operation it performed was correct.
Key points
- A tree-walking interpreter is one recursive function over node kinds, and each case is a direct statement of what a construct means.
- Its correctness is provable by structural induction, which is a claim no lowering-and-optimizing pipeline can make as cheaply.
- It assumes everything earlier phases established and owes exactly one thing: evaluation order, including short-circuit behaviour.
- AtlasLang deliberately has none, because a tree cannot be analysed — no linear form, no CFG, no SSA, and therefore no optimizer.
- The performance gap is real but is the symptom; the cause is that the tree does not answer the questions optimization needs asked.
- The typed AST panel is exactly the representation such an evaluator would consume, so you can hand-evaluate any program and compare with the Execution panel.
- Variable access by resolved symbol rather than by source name is what makes shadowing correct in the interpreter, just as it is in the lowering.
One function, one switch
A tree-walking interpreter is a recursive function over node kinds. eval(Number) returns its value. eval(Binary) evaluates the left child, evaluates the right child, applies the operator. eval(Identifier) looks the symbol up in the environment. eval(If) evaluates the condition and then evaluates one of the two blocks. eval(While) evaluates the condition and the body in a loop. That is the entire design, and for a language the size of AtlasLang it is perhaps two hundred lines.
The property that makes it valuable is not brevity but *transparency*. Each case is a direct statement of what the corresponding construct means, so the implementation reads as a definition of the language rather than as a strategy for executing it. When you want to know what a % b does, there is one place to look and it says so. That is why interpreters are how nearly every language starts, why they are the reference implementation when there is a faster one alongside, and why a specification written as an evaluator is a thing people do on purpose.
The recursion is also the argument for its correctness. Evaluating a node assumes only that evaluating its children is correct, so the whole evaluator is provable by structural induction over the tree — which is not a claim you can make about a pipeline that lowers to IR, optimizes and then executes bytecode.
1function evalExpr(e, env) {2 switch (e.kind) {3 case 'Number': case 'String': case 'Bool':4 return e.value5 case 'Identifier':6 return env.lookup(e.symbol) // resolved by the checker, not by name7 case 'Unary':8 return apply(e.op, evalExpr(e.operand, env))9 case 'Binary':10 if (e.op === '&&') // control flow, not arithmetic11 return evalExpr(e.left, env) ? evalExpr(e.right, env) : false12 return apply(e.op, evalExpr(e.left, env), evalExpr(e.right, env))13 case 'Call':14 return callFn(e.callee, e.args.map(a => evalExpr(a, env)), env)15 }16}Two lines carry the whole legality condition. Identifier looks up by e.symbol — the resolved symbol, never the source name — which is what makes shadowing work. And && is written as a conditional rather than as a call to apply, because evaluating both operands and then combining them would evaluate the right-hand side of a short-circuit operator that must not run.
What AtlasLang did instead, and why
AtlasLang has no tree-walking interpreter. compile() lowers the typed tree to three-address IR, converts to SSA, optimizes, emits stack bytecode and runs it on a virtual machine. The evaluator above is the version this module describes and the implementation deliberately does not contain.
The reason is that a tree-walker would have made most of this domain unteachable. There would be no IR panel, because nothing is lowered. No CFG, no SSA, no phi nodes, because control flow stays as nested tree nodes and is never a graph. No optimizer, because there is no linear form to rewrite. No register allocation and no assembly, because nothing is ever assigned to a machine resource. The pipeline explorer would have four panels instead of twelve, and eight of the modules in this guide would have nothing to point at.
That is a teaching motivation, but it maps exactly onto the real one. Languages abandon tree-walkers for the same reason: the tree is not a representation you can analyse or transform usefully. You cannot ask "which value flows to this use" of a tree without building something else first, and every optimization worth having needs that question answered. Speed is the symptom; the missing representation is the cause.
The performance story is worth stating plainly anyway. A tree-walker spends most of its time on overhead that has nothing to do with the program: a virtual call or a switch dispatch per node, a pointer chase to reach each child, an environment lookup per variable reference, and a boxed value per intermediate result. A bytecode VM replaces the tree traversal with a linear instruction fetch and replaces named lookups with slot indices, which removes most of that — the subject of [[atlaslang-bytecode]] and [[interpreter-performance]].
| Tree-walking | AtlasLang as built | |
|---|---|---|
| Executable form | The typed AST itself | Stack bytecode over local slots |
| Variable access | Environment lookup by symbol | Slot index — an array offset |
| Control flow | Nested tree nodes; host recursion and loops | Numeric jump targets in a flat instruction list |
| Can it be analysed? | Not usefully — no linear form, no CFG | Yes: blocks, dominance, SSA, data flow |
| Can it be optimized? | Only by rewriting the tree | Eight passes to a fixed point |
| Implementation size | Roughly one recursive function | Ten files |
| Correctness argument | Structural induction over the tree | One argument per stage, plus per-pass legality conditions |
Where you can still see the tree being the program
Even without an interpreter in the implementation, the typed AST is on screen at /compilers/pipeline, and it is exactly the representation the evaluator above would consume. Every field the evaluator needs is populated: symbol on each identifier, so a lookup goes to the right declaration; type on each expression, so the operator to apply is determined; and the tree structure itself, which is the evaluation order.
That is the useful exercise. Type a small program, look at the Typed AST panel, and evaluate it by hand top to bottom. Then look at the Execution panel and compare the printed output. If they agree — and they will — you have demonstrated the thing that matters: the tree-walker and the compiled pipeline are two implementations of the same language, and the whole of the pipeline between them exists to make the second one analysable and fast, not to make it mean something different.
The place where hand-evaluation is most instructive is short-circuit evaluation. Load the shortcircuit example — if (a != 0 && 10 / a > 1) with a zero — and walk the tree. The left operand is false, so a correct evaluator never touches the division. Now look at the IR panel and see that the compiler expressed the same rule by lowering && into two blocks and a branch. Two implementations, one semantics, and the semantics came from the tree.
How it works
The steps, in the order the compiler takes them.
- Evaluate an expression by switching on its node kind and recursing into children in the order the language specifies.
- Literals return their value; identifiers look up the resolved symbol in the environment stack.
- Binary operators evaluate both operands and apply — except
&&and||, which are written as conditionals so the right operand can be skipped. - Statements are executed in sequence: a
Letevaluates its initializer and binds, anAssignevaluates and rebinds, aPrintevaluates and emits. - An
Ifevaluates the condition and then executes one block; aWhilere-evaluates the condition each iteration. - Entering a block pushes a scope onto the environment; leaving it pops, which is what restores an outer binding after a shadowing one.
- A call binds arguments into a fresh scope and executes the body, using the host language's stack for the call stack — including, unavoidably, its stack depth limit.
- A
returnpropagates out of the body, usually as a host-language exception or a sentinel, because a tree walk has no jump.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- Evaluating both operands of
&&before combining them, soa != 0 && 10 / a > 1divides by zero whenais zero — the program is wrong and every individual operation was right. - Looking variables up by source name instead of by resolved symbol, so an inner
let xand an outerxshare storage and the wrong value is read after the block ends. - Deep recursion in the interpreted program exhausting the *host* stack, producing a host-level stack overflow rather than a language-level error the user can act on.
- Evaluating a
Whilecondition once and caching it, or re-entering a block without pushing a fresh scope, both of which produce loops that behave subtly wrongly rather than obviously so. - A
returnimplemented by returning a sentinel that some statement handler forgets to propagate, so the function continues executing after it returned. - Performance that is acceptable on examples and unusable on real input, discovered late, because a tree-walker's overhead is per node and therefore invisible on small programs.
When it helps
- Starting a language, where a working evaluator on day two is worth more than an architecture on day thirty.
- A reference implementation next to a faster one, where the tree-walker is what you differential-test the optimizing path against — see
[[differential-testing]]. - Configuration languages, template languages and small DSLs, where programs are short and simplicity outlives speed.
- Explaining semantics: an evaluator case is the clearest possible statement of what a construct means, which is why specifications are sometimes written as one.
When it hurts
- Anything needing optimization, because the tree is not a representation you can usefully analyse and every transformation has to be a tree rewrite.
- Hot loops and long-running programs, where per-node dispatch, pointer chasing and environment lookups dominate the actual work.
- Languages with deep recursion, where the interpreted call stack is the host call stack and the limit is not yours to set.
- Teaching the middle and back ends of a compiler, which is the specific reason AtlasLang does not have one.
What it costs
Every one of these is paid by something.
- A tree-walker buys an implementation you can read in an afternoon and pays with an execution model that cannot be analysed, which forecloses every optimization at once.
- Using the host language's call stack buys a trivial implementation of function calls and pays by making the interpreted stack depth limit someone else's decision.
- Environment lookup by symbol buys correct scoping with no lowering step and pays a lookup per variable reference, which is one of the largest per-node costs.
- Keeping the tree as the executable form buys perfect correspondence with the source — every runtime error can point at a real span — and pays the entire performance gap.
- Skipping it, as AtlasLang did, buys eleven more representations to teach and to optimize, and pays with ten files instead of one and a correctness argument that has to be made stage by stage.
What else you could do
What a different compiler or language does instead, and when that is better.
- Compile to bytecode and interpret that, which is what AtlasLang does: a linear instruction stream, slot-indexed locals and a dispatch loop —
[[atlaslang-bytecode]]and[[bytecode-compiler]]. - A closure-compiling interpreter, which walks the tree once and produces a nested closure per node, removing the dispatch switch while keeping the tree shape. A real and underused middle ground.
- Threaded code, where each node becomes a pointer to its handler and dispatch is an indirect jump rather than a switch — see
[[dispatch-loop]]. - Compiling to native code ahead of time, which removes the interpreter entirely at the cost of everything in the back half of this domain —
[[aot-compilation]]. - A partial evaluator or a JIT built on top of a tree-walker, which is how several production language implementations got fast without abandoning the evaluator as the semantics of record.
See it for yourself
The flag, dump or tool that shows you this directly.
/compilers/pipeline— the Typed AST panel is exactly what a tree-walking evaluator would consume, withsymbolandtypepopulated on every node.- Hand-evaluate a small program from that panel and compare with the Execution panel; they agree, which is the point.
- Load the
shortcircuitexample and walk it by hand, then look at the IR panel to see the same rule expressed as two blocks and a branch. - For a real one:
python -c "import ast; print(ast.dump(ast.parse(open('f.py').read())))"prints the tree CPython compiles from, andpython -m dis f.pyprints what it compiled it to. - Ruby before 1.9 is the canonical shipped tree-walker; its replacement by YARV is a well-documented account of exactly this trade.
Plausible wrong readings
Stated the way a confident engineer states them.
- "AtlasLang runs by walking the tree." It does not. It lowers to IR, optimizes and runs bytecode on a VM. This lesson describes the version it deliberately does not have.
- "Tree-walking is the beginner version." It is the version whose correctness is easiest to argue and is frequently kept as the reference implementation for exactly that reason.
- "Interpreters are slow because interpretation is slow." A tree-walker is slow because of per-node dispatch, pointer chasing and name lookups. A bytecode interpreter is also an interpreter and is far faster.
- "You could optimize the tree instead of lowering it." You can rewrite a tree, and some compilers do. What you cannot do is ask it which definition reaches a use without building a different representation first.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
The simplest way to run a parsed program is to walk its tree and do what each node says: a number node yields its number, a plus node evaluates both sides and adds, an if node evaluates its condition and runs one branch. That is a complete implementation, it is short, and it is where most languages begin. AtlasLang skipped it because a tree is not something you can analyse or optimize.
practical
If you are building a language, write this first. You will have something running in a day, and it becomes the reference you test everything else against. Two things to get right from the start, because both are silent when wrong: look variables up by the symbol the resolver produced rather than by name, and write && and || as conditionals rather than as operator applications. Then decide, based on how large real programs get, whether you ever need more.
advanced
The tree-walker is worth keeping even after you have something faster, and not for nostalgia. It is the reference semantics: when the optimizing path disagrees with it, the optimizing path is wrong, and having a second implementation makes that a testable proposition rather than an argument. That is the whole basis of [[differential-testing]] applied to your own compiler — generate programs, run both, compare, and any divergence is a real bug with a minimal reproducer attached. Several production language teams maintain exactly this, and the cost of keeping a two-hundred-line evaluator in sync with the language is small against the cost of a miscompilation that nothing detected.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
src/compilers/sim/ lowers to IR and runs bytecode on a stack VM. The evaluator in this lesson is written as pseudocode to describe the alternative honestly rather than to describe the code. Everything said about the typed AST, symbols and short-circuit lowering is true of the implementation; the evaluator itself is not in it.return unwinding, host stack-depth protection and an environment that is faster than a chain of hash maps. Those four make a toy evaluator into a usable one and roughly quadruple its size.If you were asked this in an interview
- What is the one obligation a tree-walking evaluator has that its individual node cases do not obviously imply?
- Why did AtlasLang lower to IR rather than walk the tree, and what would have been unteachable otherwise?
- You have a tree-walker and you now want an optimizer. What has to be built first, and why?
Connections
- Programming Languages & Runtime Internals — Value representation, boxing and the cost of an environment lookup at run timeA tree-walker's performance is decided almost entirely by how values are represented and how variable lookup is implemented — boxing, tagged unions, inline caches on environments. Those are runtime concerns; this lesson stops at why the tree cannot be optimized instead.