TT Lab
Get started
Learn Learning paths Courses

Compilers — Build a Small Language from Start to Finish

Carrying the Environment from Declaration Time — Closures

Continue in TT Lab

In one line

A tree-walking interpreter is one function per AST node that says "when you meet this node, compute this." It finds names in an environment (a chain of name → value dictionaries, each pointing to the outer environment), and a function value carries along the environment at the time it was declared. This is a closure.

Why this was needed

Module 6's bytecode VM and module 8's machine code are fast, but they are far from defining meaning. A tree interpreter is the place where you can write rules most directly, such as "if the left side of && is false, do not compute the right side," "when a block ends, the inner names disappear," and "a function sees the variables of the place where it was declared." That is why this course takes the interpreter as the reference. The later VM and native code are correct only if, when they run the same program, they print the same lines as the interpreter.

But "write directly" does not mean "easy." If you write the interpreter in Python, Python's meanings sneak in.

If the side that defines the meaning lets such things slip, every later implementation is fitted to a wrong reference.

How it works

An environment is a chain. Each time you enter a block and each time you call a function, you create a new environment and make it point to its parent. Name lookup climbs the chain, and assignment changes the slot in the environment where that name lives — it does not create a new one in the current environment.

fn counter(start) {           counter(10) 을 부르면
  let n = start;                [호출 환경 {start:10, n:10}] → [전역 {counter, …}]
  fn next() { n = n + 1;        next 를 선언하는 순간, 함수 값 = (next 의 선언, 지금 환경)
              return n; }
  return next;                 counter 가 끝나도 호출 환경은 next 가 붙잡고 있어 살아남는다
}
let a = counter(10);           a() → 새 환경 {} → 부모는 붙잡아 둔 호출 환경 → n 을 11 로
print a(); print a();          11, 12 — 같은 칸을 바꾸기 때문에 이어진다

The key is to take as the parent not the environment at the time the function value is called but the environment at the time it was declared (static scope). If you take the calling environment as the parent (dynamic scope), a() cannot find counter's n, and if there is a global with the same name, it changes that one by mistake. It is also wrong for a closure to copy the environment — two functions holding the same environment (one that increments the value and one that reads it) would see different slots.

A return exits several layers at once. A return inside an if inside a while must go straight back to the call site. In Python, the simplest way is to throw one exception (ReturnSignal) and catch it at the call site. When the call ends, you must always restore the call depth, and it is easy to forget that on the path that exits through the exception (a place that needs finally).

Run-time errors at the operator's position. If b is 0 in a / b, it raises "runtime error: division by zero" at the line:column of the /. Types that semantic analysis skipped without knowing (a true passed in as a parameter) are also caught here. If more than 200 calls are alive at the same time, it stops with "stack overflow" — if it hits Python's own recursion limit first, you end up showing the user a Python error.

What it looks like in the field

What you will do in the next lab

In interp.py, you build the value rules (64-bit wrapping, division toward 0, separating bool from int), the environment chain, expression evaluation (short-circuit evaluation and run-time type checks), statements and blocks, if and while, functions with closures, return, and call depth, and run_source, which ties together parsing through execution. The grader compares the lines printed and the run-time errors against the reference on hundreds of random number pairs, the fixed programs, and 150 random programs.