CS374: Build a Complete Interpreter in Python, Step by Step
Tutorial: Build a Complete Interpreter in Python, Step by Step
Learning Goals
By the end of this tutorial, you will have:
- Built a hand-written lexer that converts Mini source code into a typed token stream with line and column positions
- Built a recursive-descent parser that converts the token stream into a typed AST with one class per node type
- Implemented a tree-walking evaluator with a lexical environment chain that correctly handles nested
letbindings - Implemented first-class functions and closures so that inner functions capture and carry their enclosing environments
- Implemented recursive definitions (
letrec), error reporting, and a working REPL and file-runner
This tutorial walks you through every line of a complete interpreter for a small programming language called Mini. Mini supports integers, booleans, arithmetic, comparisons, let-bindings, conditionals, first-class functions, and recursive definitions. By the end you will have a working REPL and a file-runner, and you will understand how each piece connects to the theory covered in class.
What you will build, in order:
- A hand-written lexer (tokenizer)
- A recursive descent parser that produces an AST
- A tree-walking evaluator with proper lexical scoping
- First-class functions and closures
- Recursive definitions (
letrec) - Error reporting with line/column information
- A REPL and file-runner
The language design:
expr ::= NUMBER | BOOL
| IDENT
| expr op expr (op: + - * / < > <= >= == !=)
| "if" expr "then" expr "else" expr
| "let" IDENT "=" expr "in" expr
| "letrec" IDENT "=" expr "in" expr
| "fun" IDENT "->" expr
| expr expr (function application, left-associative)
| "(" expr ")"
Stage 1: The Lexer
1.1 Tokens
A token is the smallest meaningful unit in our language. Every token has a type and a value, plus source position for error reporting.
# tokens.py
from dataclasses import dataclass
from typing import Any, Optional
@dataclass
class Token:
type: str
value: Any
line: int
col: int
def __repr__(self):
return f"Token({self.type}, {self.value!r}, {self.line}:{self.col})"
# Token types
TK_NUMBER = "NUMBER"
TK_BOOL = "BOOL"
TK_IDENT = "IDENT"
TK_PLUS = "+"
TK_MINUS = "-"
TK_STAR = "*"
TK_SLASH = "/"
TK_LPAREN = "("
TK_RPAREN = ")"
TK_EQ = "=="
TK_NEQ = "!="
TK_LT = "<"
TK_GT = ">"
TK_LEQ = "<="
TK_GEQ = ">="
TK_ASSIGN = "="
TK_ARROW = "->"
TK_LET = "let"
TK_LETREC = "letrec"
TK_IN = "in"
TK_IF = "if"
TK_THEN = "then"
TK_ELSE = "else"
TK_FUN = "fun"
TK_EOF = "EOF"
KEYWORDS = {"let": TK_LET, "letrec": TK_LETREC, "in": TK_IN,
"if": TK_IF, "then": TK_THEN, "else": TK_ELSE,
"fun": TK_FUN, "true": TK_BOOL, "false": TK_BOOL}
print("Token types defined.")
1.2 The Lexer
# lexer.py
class LexError(Exception):
def __init__(self, msg, line, col):
super().__init__(f"[lexer:{line}:{col}] {msg}")
class Lexer:
def __init__(self, source: str):
self.source = source
self.pos = 0
self.line = 1
self.col = 1
def error(self, msg):
raise LexError(msg, self.line, self.col)
def peek(self) -> Optional[str]:
return self.source[self.pos] if self.pos < len(self.source) else None
def advance(self) -> str:
ch = self.source[self.pos]
self.pos += 1
if ch == '\n':
self.line += 1
self.col = 1
else:
self.col += 1
return ch
def skip_whitespace_and_comments(self):
while self.peek() in (' ', '\t', '\n', '\r', '#'):
if self.peek() == '#': # line comment
while self.peek() and self.peek() != '\n':
self.advance()
else:
self.advance()
def read_number(self) -> Token:
start_line, start_col = self.line, self.col
digits = []
while self.peek() and self.peek().isdigit():
digits.append(self.advance())
return Token(TK_NUMBER, int("".join(digits)), start_line, start_col)
def read_ident_or_keyword(self) -> Token:
start_line, start_col = self.line, self.col
chars = []
while self.peek() and (self.peek().isalnum() or self.peek() == '_'):
chars.append(self.advance())
word = "".join(chars)
if word in KEYWORDS:
val = True if word == "true" else (False if word == "false" else word)
return Token(KEYWORDS[word], val, start_line, start_col)
return Token(TK_IDENT, word, start_line, start_col)
def next_token(self) -> Token:
self.skip_whitespace_and_comments()
if self.peek() is None:
return Token(TK_EOF, None, self.line, self.col)
start_line, start_col = self.line, self.col
ch = self.peek()
if ch.isdigit():
return self.read_number()
if ch.isalpha() or ch == '_':
return self.read_ident_or_keyword()
self.advance() # consume ch
two = ch + (self.peek() or '')
if two == '==': self.advance(); return Token(TK_EQ, '==', start_line, start_col)
if two == '!=': self.advance(); return Token(TK_NEQ, '!=', start_line, start_col)
if two == '<=': self.advance(); return Token(TK_LEQ, '<=', start_line, start_col)
if two == '>=': self.advance(); return Token(TK_GEQ, '>=', start_line, start_col)
if two == '->': self.advance(); return Token(TK_ARROW, '->', start_line, start_col)
singles = {'+': TK_PLUS, '-': TK_MINUS, '*': TK_STAR, '/': TK_SLASH,
'(': TK_LPAREN, ')': TK_RPAREN, '=': TK_ASSIGN,
'<': TK_LT, '>': TK_GT}
if ch in singles:
return Token(singles[ch], ch, start_line, start_col)
self.error(f"Unexpected character: {ch!r}")
def tokenize(self):
tokens = []
while True:
tok = self.next_token()
tokens.append(tok)
if tok.type == TK_EOF:
break
return tokens
# Quick test
lexer = Lexer("let x = 3 + 4 in x * 2")
for tok in lexer.tokenize():
print(tok)
Stage 2: The AST
2.1 AST Node Classes
# ast_nodes.py
from dataclasses import dataclass, field
from typing import Optional, List
@dataclass
class Num:
value: int
line: int = 0
@dataclass
class Bool:
value: bool
line: int = 0
@dataclass
class Var:
name: str
line: int = 0
@dataclass
class BinOp:
op: str
left: object
right: object
line: int = 0
@dataclass
class IfExpr:
cond: object
then_: object
else_: object
line: int = 0
@dataclass
class Let:
name: str
value: object
body: object
line: int = 0
@dataclass
class LetRec:
name: str
value: object
body: object
line: int = 0
@dataclass
class Fun:
param: str
body: object
line: int = 0
@dataclass
class App:
func: object
arg: object
line: int = 0
print("AST node classes defined.")
Stage 3: The Recursive Descent Parser
3.1 Parser Structure
# parser.py
class ParseError(Exception):
def __init__(self, msg, line, col):
super().__init__(f"[parser:{line}:{col}] {msg}")
class Parser:
"""
Grammar (with precedences encoded in the call hierarchy):
expr ::= let_expr | letrec_expr | fun_expr | if_expr | cmp
cmp ::= arith (("==" | "!=" | "<" | ">" | "<=" | ">=") arith)*
arith ::= term (("+"|"-") term)*
term ::= app (("*"|"/") app)*
app ::= atom atom* (left-associative application)
atom ::= NUMBER | BOOL | IDENT | "(" expr ")"
"""
def __init__(self, tokens):
self.tokens = tokens
self.pos = 0
def peek(self) -> Token:
return self.tokens[self.pos]
def advance(self) -> Token:
tok = self.tokens[self.pos]
if tok.type != TK_EOF:
self.pos += 1
return tok
def expect(self, type_: str) -> Token:
tok = self.advance()
if tok.type != type_:
raise ParseError(
f"Expected {type_!r}, got {tok.type!r} ({tok.value!r})",
tok.line, tok.col)
return tok
def match(self, *types) -> Optional[Token]:
if self.peek().type in types:
return self.advance()
return None
# ---- Entry point ----
def parse(self):
node = self.parse_expr()
self.expect(TK_EOF)
return node
def parse_expr(self):
tok = self.peek()
if tok.type == TK_LET:
return self.parse_let()
if tok.type == TK_LETREC:
return self.parse_letrec()
if tok.type == TK_FUN:
return self.parse_fun()
if tok.type == TK_IF:
return self.parse_if()
return self.parse_cmp()
def parse_let(self):
tok = self.expect(TK_LET)
name = self.expect(TK_IDENT).value
self.expect(TK_ASSIGN)
val = self.parse_expr()
self.expect(TK_IN)
body = self.parse_expr()
return Let(name, val, body, tok.line)
def parse_letrec(self):
tok = self.expect(TK_LETREC)
name = self.expect(TK_IDENT).value
self.expect(TK_ASSIGN)
val = self.parse_expr()
self.expect(TK_IN)
body = self.parse_expr()
return LetRec(name, val, body, tok.line)
def parse_fun(self):
tok = self.expect(TK_FUN)
param = self.expect(TK_IDENT).value
self.expect(TK_ARROW)
body = self.parse_expr()
return Fun(param, body, tok.line)
def parse_if(self):
tok = self.expect(TK_IF)
cond = self.parse_expr()
self.expect(TK_THEN)
then_ = self.parse_expr()
self.expect(TK_ELSE)
else_ = self.parse_expr()
return IfExpr(cond, then_, else_, tok.line)
CMP_OPS = {TK_EQ, TK_NEQ, TK_LT, TK_GT, TK_LEQ, TK_GEQ}
def parse_cmp(self):
left = self.parse_arith()
while self.peek().type in self.CMP_OPS:
op = self.advance()
right = self.parse_arith()
left = BinOp(op.value, left, right, op.line)
return left
def parse_arith(self):
left = self.parse_term()
while self.peek().type in (TK_PLUS, TK_MINUS):
op = self.advance()
right = self.parse_term()
left = BinOp(op.value, left, right, op.line)
return left
def parse_term(self):
left = self.parse_app()
while self.peek().type in (TK_STAR, TK_SLASH):
op = self.advance()
right = self.parse_app()
left = BinOp(op.value, left, right, op.line)
return left
def parse_app(self):
"""Left-associative function application."""
func = self.parse_atom()
while self.peek().type in (TK_NUMBER, TK_BOOL, TK_IDENT, TK_LPAREN):
arg = self.parse_atom()
func = App(func, arg, func.line)
return func
def parse_atom(self):
tok = self.peek()
if tok.type == TK_NUMBER:
self.advance()
return Num(tok.value, tok.line)
if tok.type == TK_BOOL:
self.advance()
return Bool(tok.value, tok.line)
if tok.type == TK_IDENT:
self.advance()
return Var(tok.value, tok.line)
if tok.type == TK_LPAREN:
self.advance()
node = self.parse_expr()
self.expect(TK_RPAREN)
return node
raise ParseError(
f"Unexpected token: {tok.type!r} ({tok.value!r})",
tok.line, tok.col)
def parse(source: str):
tokens = Lexer(source).tokenize()
return Parser(tokens).parse()
# Test the parser
ast = parse("let x = 3 in x + 1")
print(ast)
Stage 4: The Evaluator
4.1 Values and Environments
# evaluator.py
from dataclasses import dataclass
class RuntimeError_(Exception):
def __init__(self, msg, line=0):
prefix = f"[eval:{line}]" if line else "[eval]"
super().__init__(f"{prefix} {msg}")
@dataclass
class Closure:
"""A function value: captures its defining environment."""
param: str
body: object
env: dict
def __repr__(self):
return f"<closure:{self.param}>"
class Environment:
"""Linked-list environment for lexical scoping."""
def __init__(self, parent=None):
self.bindings = {}
self.parent = parent
def lookup(self, name: str, line=0):
if name in self.bindings:
return self.bindings[name]
if self.parent:
return self.parent.lookup(name, line)
raise RuntimeError_(f"Undefined variable: {name!r}", line)
def bind(self, name: str, value):
self.bindings[name] = value
return self
def extend(self, name: str, value):
"""Create a child environment with one new binding."""
child = Environment(parent=self)
child.bind(name, value)
return child
print("Value types and Environment defined.")
4.2 The Evaluator
class Evaluator:
def evaluate(self, node, env: Environment):
method = f"eval_{type(node).__name__}"
handler = getattr(self, method, None)
if handler is None:
raise RuntimeError_(f"Unknown node type: {type(node).__name__}")
return handler(node, env)
def eval_Num(self, node, env):
return node.value
def eval_Bool(self, node, env):
return node.value
def eval_Var(self, node, env):
return env.lookup(node.name, node.line)
def eval_BinOp(self, node, env):
left = self.evaluate(node.left, env)
right = self.evaluate(node.right, env)
ops = {
'+': lambda a, b: a + b,
'-': lambda a, b: a - b,
'*': lambda a, b: a * b,
'/': lambda a, b: a // b if b != 0 else (_ for _ in ()).throw(
RuntimeError_(f"Division by zero", node.line)),
'==': lambda a, b: a == b,
'!=': lambda a, b: a != b,
'<': lambda a, b: a < b,
'>': lambda a, b: a > b,
'<=': lambda a, b: a <= b,
'>=': lambda a, b: a >= b,
}
if node.op not in ops:
raise RuntimeError_(f"Unknown operator: {node.op!r}", node.line)
return ops[node.op](left, right)
def eval_IfExpr(self, node, env):
cond = self.evaluate(node.cond, env)
if not isinstance(cond, bool):
raise RuntimeError_(f"Condition must be boolean, got {type(cond).__name__}", node.line)
return self.evaluate(node.then_ if cond else node.else_, env)
def eval_Let(self, node, env):
val = self.evaluate(node.value, env)
new_env = env.extend(node.name, val)
return self.evaluate(node.body, new_env)
def eval_Fun(self, node, env):
return Closure(node.param, node.body, env)
def eval_App(self, node, env):
func = self.evaluate(node.func, env)
arg = self.evaluate(node.arg, env)
if not isinstance(func, Closure):
raise RuntimeError_(f"Not a function: {func!r}", node.line)
call_env = func.env.extend(func.param, arg)
return self.evaluate(func.body, call_env)
def eval_LetRec(self, node, env):
"""
For letrec, the binding must be in scope while the value is evaluated.
We use a mutable placeholder and patch it after evaluation.
This handles the common case of recursive function definitions.
"""
# Create the child env with a placeholder
rec_env = Environment(parent=env)
# Evaluate the value in the recursive environment
val = self.evaluate(node.value, rec_env)
# Now bind the name to the value (closures will capture rec_env)
rec_env.bind(node.name, val)
# Evaluate the body in the recursive environment
return self.evaluate(node.body, rec_env)
print("Evaluator defined.")
4.3 Testing the Evaluator
def run(source: str, env=None):
"""Parse and evaluate a Mini expression."""
if env is None:
env = Environment()
tree = parse(source)
return Evaluator().evaluate(tree, env)
# Basic arithmetic
print(run("3 + 4 * 2")) # 11 (precedence: * before +)
print(run("(3 + 4) * 2")) # 14
print(run("10 / 3")) # 3 (integer division)
# Let binding
print(run("let x = 5 in x * x")) # 25
# Conditionals
print(run("if 3 > 2 then 10 else 20")) # 10
print(run("if 3 < 2 then 10 else 20")) # 20
# Functions
print(run("(fun x -> x * x) 7")) # 49
# Higher-order functions
print(run("let double = fun x -> x * 2 in let apply = fun f -> fun x -> f x in apply double 5")) # 10
# Recursion with letrec
factorial_src = """
letrec fact = fun n -> if n <= 0 then 1 else n * fact (n - 1)
in fact 6
"""
print(run(factorial_src)) # 720
# Fibonacci
fib_src = """
letrec fib = fun n -> if n <= 1 then n else fib (n - 1) + fib (n - 2)
in fib 10
"""
print(run(fib_src)) # 55
Stage 5: The REPL
5.1 Read-Eval-Print Loop
def repl():
"""A simple REPL for the Mini language."""
print("Mini Language Interpreter")
print("Type an expression and press Enter. Type 'quit' to exit.")
print()
env = Environment()
while True:
try:
line = input("mini> ").strip()
if not line:
continue
if line == "quit":
break
result = run(line, env)
print(f"=> {result!r}")
except (LexError, ParseError, RuntimeError_) as e:
print(f"Error: {e}")
except KeyboardInterrupt:
print("\nInterrupted.")
break
except Exception as e:
import traceback
print(f"[repl:unexpected] {e}")
traceback.print_exc()
# Uncomment to run the REPL interactively:
# repl()
print("REPL defined. Call repl() to start it.")
5.2 File Runner
import sys
def run_file(filename: str):
"""Read and evaluate a Mini source file."""
try:
with open(filename) as f:
source = f.read()
except FileNotFoundError:
print(f"[runner] File not found: {filename!r}")
sys.exit(1)
try:
result = run(source)
print(result)
except (LexError, ParseError, RuntimeError_) as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# Usage: run_file("program.mini")
print("File runner defined.")
Stage 6: What to Try Next
6.1 Extension Ideas
Now that your interpreter works, here are natural extensions to explore:
Type checking (pre-evaluation pass):
class TypeChecker(Evaluator):
"""
Instead of values, carry types. Catch type errors before running.
Replace int values with the string "Int", bool values with "Bool", etc.
"""
pass
Multi-argument functions (syntactic sugar):
fun x y z -> body => fun x -> fun y -> fun z -> body
Lists and pattern matching:
let xs = [1, 2, 3] in
match xs with
| [] -> 0
| h :: t -> h + sum t
Tail-call optimization: The eval_App call above will blow Python’s stack on deeply recursive functions. Trampolining converts tail calls to iteration, research “trampoline in Python” for a clean implementation.
Print / IO: Add a print built-in function to the global environment.
6.2 Key Takeaways
| Concept | Where it appears |
|---|---|
| Lexical analysis | Lexer.next_token(); character-by-character |
| Token types | TK_* constants, the vocabulary |
| Recursive descent | Each grammar rule -> one parse_* method |
| Operator precedence | Call hierarchy: parse_expr > parse_cmp > parse_arith > parse_term > parse_app > parse_atom |
| Abstract Syntax Tree | Num, BinOp, Let, Fun, App, etc. |
| Tree-walking evaluation | evaluate(), dispatches by node type |
| Lexical scoping | Environment linked list, each let creates a child |
| Closures | Closure captures the defining env |
| Recursion | LetRec, value evaluated in an environment that includes the binding |
6.3 Suggested Exercises
-
Add
andandoras short-circuit operators. Add them to the lexer, parser (betweenparse_cmpandparse_arith), and evaluator. -
Add
let x = ewithoutinfor a top-level definition form. The evaluator should update the global environment. -
Add string literals
"hello". Update the lexer to scan quoted strings and the evaluator to handle them in==and+(string concatenation). -
Add a built-in
printfunction by adding aBuiltinvalue class and pre-populating the global environment withprint = Builtin(lambda x: (print(x), x)[1]). -
Implement a pretty-printer that converts an AST back to Mini source code. This is an
Unparservisitor, the inverse of the parser. Use it to verify your parser:parse(unparse(parse(src)))should equalparse(src)for well-formed programs.
Further Reading
- Nystrom, Robert. Crafting Interpreters (free online). The Lox interpreter follows this exact arc; Chapters 4-11 correspond to the stages above.
- Krishnamurthi, Shriram. Programming Languages: Application and Interpretation (PLAI) (free online). Chapters 1-8 cover the same interpreter with formal semantics.
- Abelson and Sussman. Structure and Interpretation of Computer Programs (SICP) (free online). Chapter 4 builds a metacircular evaluator in Scheme, an interpreter written in the language it interprets.
Advanced: A Metacircular Scheme Evaluator, Scheme in Python
This advanced section deepens the same lexer -> parser -> environment -> evaluator architecture you built for Mini above, and it backs Direction G of the Functional assignment (contributing to mal: Make-a-Lisp) for students heading that way.
An interpreter written in the very language it interprets sounds like a paradox, but it is actually one of the most clarifying ideas in computer science: it proves that the language’s evaluation rules are self-consistent and complete. Think of it like a dictionary that defines every word using other words in the same dictionary: the circularity is the point here, because it shows the system is closed. Building this evaluator in Python forces every semantic choice to become explicit code, revealing the machinery that the Mini interpreter you just built already contains.
Learning Goals
By the end of this section, you will be able to:
- Parse Scheme s-expressions into Python data structures and traverse them to implement
evalandapply - Implement lexical scoping using a linked chain of environment frames that correctly handles closures
- Build a trampoline-based tail-call optimizer that runs deeply recursive Scheme programs without stack overflow
- Explain the relationship between the metacircular evaluator and this tutorial’s Mini-language interpreter, identifying where the two designs converge and diverge
Before You Begin: This section assumes you can:
- Write and trace through a recursive Python function that processes nested lists
- Explain what a Python dictionary is and how you would use one to map variable names to values
- Describe what a closure is: a function paired with the environment in which it was created
If any of these feel shaky, review them first.
“To understand the evaluator is to understand computation.”, SICP
A metacircular evaluator is an interpreter for a language written in (or very close to) that language itself. In SICP Chapter 4, Abelson and Sussman build a Scheme interpreter in Scheme, revealing that the evaluation rules almost write themselves, because the host language and the implemented language share the same underlying ideas. Here, we build a Scheme interpreter in Python. Python is close enough that the translation is direct; different enough that we must make every semantic choice explicit.
You have just built a Mini-language interpreter in this tutorial. That experience carries over completely. The arc of this section: Scheme code as data (s-expressions) -> the environment model -> the evaluator dispatch loop -> the global environment -> tail-call optimization via trampoline.
By the end you will have a working evaluator that can run recursive Scheme programs of arbitrary depth.
A working norm worth keeping: predict every code cell’s output before running it. If the result surprises you, explain why before moving to the next question.
Part I: S-Expressions, Code as Data
Model 1: S-Expressions
In most languages, source code is text and data is something else entirely. Scheme collapses this distinction: a program is a list, and lists are data. This means a Scheme program can construct and run another Scheme program using the same car, cdr, and cons operations it uses on ordinary lists. Before you can build the evaluator, you need to be comfortable reading nested Python lists as Scheme programs; the translation table in this model is your Rosetta Stone.
Watch out! In our representation, Scheme symbols (like variable names
x,y, operator names+) and Scheme strings (like"hello") are both Pythonstrvalues. The evaluator distinguishes them by context: a string that starts with"is a literal; anything else is a symbol to look up. This is a shortcut that would not work in a production system, but it simplifies the parser significantly.
Scheme’s defining design choice: program text and data share the same representation. Every Scheme expression is an s-expression (symbolic expression): either an atom (number, boolean, string, or symbol) or a pair (head . tail), where tail is usually another pair, recursively, giving a list. The surface syntax (op arg1 arg2 ...) is just a printed list.
This is not a curiosity; it is what makes Scheme’s macros, eval, and quote work: a program can construct and execute another program using the same list operations it uses on ordinary data.
Mapping Scheme to Python
For our interpreter we represent s-expressions as nested Python lists of atoms. The correspondence:
| Scheme source | Python representation |
|---|---|
(+ 1 2) |
['+', 1, 2] |
(define x 42) |
['define', 'x', 42] |
(lambda (x) (* x x)) |
['lambda', ['x'], ['*', 'x', 'x']] |
(if #t 1 0) |
['if', True, 1, 0] |
(let ((x 5)) (+ x 1)) |
['let', [['x', 5]], ['+', 'x', 1]] |
'foo |
['quote', 'foo'] |
Atoms map to: Python int/float, bool, str (for Scheme strings), or Python str (for Scheme symbols; we distinguish symbol from string by context).
The Parser
The parser has two stages: a tokenizer that splits the input string into a flat list of token strings, then a recursive descent step that folds those tokens into nested Python lists.
import re
def tokenize(s):
"""
Split a Scheme source string into a list of token strings.
Handles: parentheses, strings, #t/#f, numbers, symbols.
"""
# Insert spaces around parens, then split; handle quoted strings carefully
token_pattern = r'\"[^\"]*\"|\(|\)|[^\s()\"]+'
return re.findall(token_pattern, s)
def parse_atom(token):
"""Convert a single token string to its Python atom value."""
if token == '#t':
return True
if token == '#f':
return False
if token.startswith('"') and token.endswith('"'):
return token[1:-1] # strip quotes; store as Python str
try:
return int(token)
except ValueError:
pass
try:
return float(token)
except ValueError:
pass
return token # symbol: just keep the string
def parse_tokens(tokens):
"""
Consume tokens (a list used as a mutable queue via pop(0)) and return
the next complete s-expression as a nested Python list/atom.
"""
if not tokens:
raise SyntaxError("Unexpected EOF")
token = tokens.pop(0)
if token == '(':
result = []
while tokens[0] != ')':
result.append(parse_tokens(tokens))
tokens.pop(0) # consume ')'
return result
elif token == ')':
raise SyntaxError("Unexpected ')'")
elif token == "'": # shorthand quote
return ['quote', parse_tokens(tokens)]
else:
return parse_atom(token)
def parse_sexp(s):
"""Parse a Scheme source string and return its Python representation."""
tokens = tokenize(s)
return parse_tokens(tokens)
# --- Demo ---
examples = [
"(+ 1 2)",
"(define x 42)",
"(lambda (x) (* x x))",
"(if #t 1 0)",
"(let ((x 5)) (+ x 1))",
]
for src in examples:
print(f"{src!s:40s} => {parse_sexp(src)}")
Questions to Consider, Model 1
Question 1. What Python type represents a Scheme pair/list in our encoding? What Python type represents a Scheme symbol? How does the evaluator distinguish a symbol "x" (which should be looked up) from a Scheme string "hello" (which is a literal value)?
Question 2. What does parse_sexp("(+ (* 2 3) 4)") return? Trace through parse_tokens step by step, listing the state of tokens at each recursive call.
Question 3. Numbers and booleans are stored as Python int, float, and bool rather than as strings. What advantage does this give the evaluator? What would break if (+ 1 2) were stored as ['+', '1', '2']?
Part II: Environments
Model 2: The Environment as a Linked Chain of Frames
Scoping rules determine which variable binding wins when the same name exists in multiple contexts. Lexical scoping (the rule Scheme and Python both use) answers “which binding?” by looking at where the code was written, not where it was called. The linked chain of frames implements this: each frame holds the bindings introduced at one scope level, and the outer pointer to the enclosing scope forms the lookup chain. This structure is the heart of closures.
An environment in our interpreter is a dictionary that may have a pointer to an outer (enclosing) environment. Variable lookup walks the chain until the name is found or the outermost frame is exhausted.
class SchemeError(Exception):
pass
class Env(dict):
"""
A single environment frame.
Inherits from dict so frame[var] = val works directly.
outer: the enclosing environment, or None for the global frame.
"""
def __init__(self, params=(), args=(), outer=None):
super().__init__()
self.outer = outer
if len(params) != len(args):
raise SchemeError(
f"Arity mismatch: expected {len(params)} args, got {len(args)}"
)
self.update(zip(params, args)) # bind each param to its arg
def find(self, var):
"""
Return the innermost frame that contains var.
Raises SchemeError if var is unbound anywhere in the chain.
"""
if var in self:
return self
if self.outer is None:
raise SchemeError(f"Unbound variable: {var!r}")
return self.outer.find(var)
# --- Demo: manual environment construction ---
global_env = Env()
global_env['y'] = 10
# Simulate (lambda (x) (+ x y)) called with x=3
call_env = Env(params=['x'], args=[3], outer=global_env)
print("x in call_env:", call_env.find('x')['x']) # 3
print("y via outer: ", call_env.find('y')['y']) # 10
The chain for (define f (lambda (x) (+ x y))) where y = 10 in the global environment looks like this:
Global frame: { y: 10, f: <Procedure> }
^
| outer
Call frame: { x: 3 }
When the body (+ x y) is evaluated in the call frame, x resolves immediately; y requires walking up one link to the global frame.
Questions to Consider, Model 2
Question 4. What happens when find reaches the outermost environment (where outer is None) and the variable still has not been found? Write the exact exception that would be raised for (+ x undefined-var).
Question 5. Lexical (static) scope vs. dynamic scope differs entirely in which frame becomes the outer of a new call frame. In lexical scope, which environment is passed as outer when a closure is called? In dynamic scope, which environment would be passed instead?
Question 6. Trace the full environment chain for the following interaction:
(define y 10)
(define f (lambda (x) (+ x y)))
(f 5)
Draw the frames that exist when (+ x y) is being evaluated. Label every outer pointer. Then answer: if y were rebound to 20 after f was defined, would (f 5) return 15 or 25? Why?
Part III: The Evaluator Core
Model 3: scheme_eval, Dispatch on Form
The entire evaluator fits in one function because every Scheme expression falls into one of three categories: a self-evaluating atom (numbers, booleans), a symbol to look up, or a list. Lists are further divided into special forms (keywords like if, define, lambda that have their own evaluation rules) and procedure calls. This dispatch-on-shape pattern is the same pattern you used in the Mini evaluator above; seeing it made explicit here should feel familiar.
Watch out! In Scheme, only
#f(the boolean false) is falsy. Everything else (including0, the empty list, and the empty string) is truthy. The linebranch = x[2] if test is not False else ...implements this rule. Students frequently miss this and writeif not test, which would treat0as false and produce wrong results for numeric conditions.
The evaluator is a single function that dispatches on the type and shape of the expression. Atoms evaluate to themselves or to their binding. Lists beginning with a keyword are special forms handled directly. Any other list is a procedure call.
# Env and SchemeError are the same as in Model 2,
# repeated here so this cell runs standalone.
class SchemeError(Exception):
pass
class Env(dict):
"""
A single environment frame.
outer: the enclosing environment, or None for the global frame.
"""
def __init__(self, params=(), args=(), outer=None):
super().__init__()
self.outer = outer
if len(params) != len(args):
raise SchemeError(
f"Arity mismatch: expected {len(params)} args, got {len(args)}"
)
self.update(zip(params, args)) # bind each param to its arg
def find(self, var):
"""Return the innermost frame that contains var."""
if var in self:
return self
if self.outer is None:
raise SchemeError(f"Unbound variable: {var!r}")
return self.outer.find(var)
class Procedure:
"""
A first-class Scheme procedure (closure).
params: list of parameter name strings
body: s-expression (the body, a single expression or begin-list)
env: the defining environment (captured at lambda creation)
"""
def __init__(self, params, body, env):
self.params = params
self.body = body
self.env = env # lexical environment - the closure
def __call__(self, args):
"""Create a new frame on the *defining* environment, then evaluate body."""
call_env = Env(self.params, args, self.env)
return scheme_eval(self.body, call_env)
def __repr__(self):
return f"#<procedure ({' '.join(self.params)})>"
def scheme_eval(x, env):
"""
Evaluate s-expression x in environment env.
Returns a Python value representing the Scheme result.
"""
# --- Self-evaluating atoms ---
if isinstance(x, (int, float, bool)):
return x
if isinstance(x, str) and x.startswith('"'):
return x # Scheme string literal
# --- Symbol lookup ---
if isinstance(x, str):
return env.find(x)[x]
# --- Special forms and procedure calls (x is a list) ---
if not isinstance(x, list) or len(x) == 0:
raise SchemeError(f"Cannot evaluate: {x!r}")
head = x[0]
# (quote datum)
if head == 'quote':
return x[1]
# (if test consequent [alternate])
if head == 'if':
test = scheme_eval(x[1], env)
# In Scheme only #f is false; everything else (including 0) is truthy
branch = x[2] if test is not False else (x[3] if len(x) > 3 else False)
return scheme_eval(branch, env)
# (define symbol value) or (define (name params...) body)
if head == 'define':
if isinstance(x[1], list):
# Syntactic sugar: (define (f x y) body) => (define f (lambda (x y) body))
name = x[1][0]
params = x[1][1:]
body = x[2]
env[name] = Procedure(params, body, env)
else:
env[x[1]] = scheme_eval(x[2], env)
return None
# (set! symbol value)
if head == 'set!':
env.find(x[1])[x[1]] = scheme_eval(x[2], env)
return None
# (lambda (params...) body)
if head == 'lambda':
params = x[1]
body = x[2] if len(x) == 3 else ['begin'] + x[2:]
return Procedure(params, body, env)
# (begin expr1 expr2 ...)
if head == 'begin':
result = None
for expr in x[1:]:
result = scheme_eval(expr, env)
return result
# (let ((var val) ...) body)
if head == 'let':
bindings = x[1] # list of [var, val] pairs
body = x[2]
params = [b[0] for b in bindings]
args = [scheme_eval(b[1], env) for b in bindings]
# Desugar: ((lambda (params...) body) args...)
proc = Procedure(params, body, env)
return proc(args)
# (and expr ...)
if head == 'and':
result = True
for expr in x[1:]:
result = scheme_eval(expr, env)
if result is False:
return False
return result
# (or expr ...)
if head == 'or':
for expr in x[1:]:
result = scheme_eval(expr, env)
if result is not False:
return result
return False
# --- Procedure call: (proc arg1 arg2 ...) ---
proc = scheme_eval(head, env)
args = [scheme_eval(a, env) for a in x[1:]]
if callable(proc):
return proc(args)
raise SchemeError(f"Not a procedure: {proc!r}")
# --- Minimal global environment for the demo ---
import operator, math
def make_global_env():
env = Env()
env.update({
'+': lambda args: args[0] + args[1],
'-': lambda args: args[0] - args[1],
'*': lambda args: args[0] * args[1],
'/': lambda args: args[0] / args[1],
'<': lambda args: args[0] < args[1],
'>': lambda args: args[0] > args[1],
'<=': lambda args: args[0] <= args[1],
'>=': lambda args: args[0] >= args[1],
'=': lambda args: args[0] == args[1],
'not': lambda args: args[0] is False,
'display': lambda args: print(args[0], end=''),
'newline': lambda args: print(),
})
return env
# --- Tokenizer / parser (abbreviated; same as Model 1) ---
import re
def tokenize(s):
return re.findall(r'\"[^\"]*\"|\(|\)|[^\s()\"]+', s)
def parse_atom(t):
if t == '#t': return True
if t == '#f': return False
if t.startswith('"'): return t
try: return int(t)
except ValueError: pass
try: return float(t)
except ValueError: pass
return t
def parse_tokens(tokens):
if not tokens: raise SyntaxError("EOF")
t = tokens.pop(0)
if t == '(':
lst = []
while tokens[0] != ')':
lst.append(parse_tokens(tokens))
tokens.pop(0)
return lst
elif t == "'":
return ['quote', parse_tokens(tokens)]
else:
return parse_atom(t)
def parse_sexp(s):
return parse_tokens(tokenize(s))
# --- Run some expressions ---
genv = make_global_env()
tests = [
"(+ 2 3)",
"(if #t 42 0)",
"(if #f 42 99)",
"(define x 10)",
"(+ x 5)",
"(define square (lambda (n) (* n n)))",
"(square 7)",
"(let ((a 3) (b 4)) (+ (* a a) (* b b)))",
"(and #t #t #f)",
"(or #f #f 7)",
"(begin (define y 100) (+ y 1))",
]
for src in tests:
ast = parse_sexp(src)
result = scheme_eval(ast, genv)
if result is not None:
print(f"{src!s:50s} => {result}")
Questions to Consider, Model 3
Question 7. Why does define store into env directly with env[x[1]] = ... while set! uses env.find(x[1])[x[1]] = ...? What would happen if set! used env[x[1]] = ... instead? Give a concrete example where the behavior would differ.
Question 8. Show the complete desugaring of (let ((x 5) (y 3)) (+ x y)) into a lambda application. Write out both the s-expression that scheme_eval actually evaluates and the equivalent Python call tree that results.
Question 9. Consider:
(define fact
(lambda (n)
(if (<= n 1)
1
(* n (fact (- n 1))))))
(fact 5)
Does this work in our evaluator? Trace through why fact is visible inside its own body even though it is being defined right now. (Hint: look at how define stores the procedure into env before the body is ever called.)
Part IV: The Global Environment
Model 4: make_global_env, The Built-In World
Every language has a layer of operations that the interpreter cannot define in terms of itself: the bedrock primitives. In Scheme these are things like +, cons, car, and display. In our interpreter they are Python lambdas sitting in the global environment frame. Everything else the user writes builds on top of this layer, which is why getting the primitive set right matters: it is the entire foundation.
The global environment pre-loads all the primitive operations. In real Scheme these are implemented in a low-level language for speed; in our interpreter they are just Python lambdas.
import operator, math
def make_global_env():
"""Return an Env pre-loaded with standard Scheme primitives."""
env = Env()
env.update({
# --- Arithmetic ---
'+': lambda a: sum(a),
'-': lambda a: a[0] - a[1] if len(a) == 2 else -a[0],
'*': lambda a: a[0] * a[1],
'/': lambda a: a[0] / a[1],
'%': lambda a: a[0] % a[1],
# --- Comparison ---
'<': lambda a: a[0] < a[1],
'>': lambda a: a[0] > a[1],
'<=': lambda a: a[0] <= a[1],
'>=': lambda a: a[0] >= a[1],
'=': lambda a: a[0] == a[1],
# --- List operations ---
# We represent Scheme pairs as Python 2-tuples (head, tail).
# The empty list is None (representing Scheme's '()).
'cons': lambda a: (a[0], a[1]),
'car': lambda a: a[0][0],
'cdr': lambda a: a[0][1],
'list': lambda a: _make_list(a),
'null?': lambda a: a[0] is None,
'pair?': lambda a: isinstance(a[0], tuple),
'length': lambda a: _length(a[0]),
'append': lambda a: _append(a[0], a[1]),
'map': lambda a: _map(a[0], a[1]),
# --- Boolean ---
'not': lambda a: a[0] is False,
'boolean?': lambda a: isinstance(a[0], bool),
# --- Type predicates ---
'number?': lambda a: isinstance(a[0], (int, float)) and not isinstance(a[0], bool),
'symbol?': lambda a: isinstance(a[0], str) and not a[0].startswith('"'),
'string?': lambda a: isinstance(a[0], str) and a[0].startswith('"'),
'procedure?': lambda a: callable(a[0]),
# --- I/O ---
'display': lambda a: (print(a[0], end=''), None)[1],
'newline': lambda a: (print(), None)[1],
})
return env
# --- Helpers for list operations ---
def _make_list(items):
result = None
for item in reversed(items):
result = (item, result)
return result
def _length(pair):
count = 0
while pair is not None:
count += 1
pair = pair[1]
return count
def _append(p1, p2):
if p1 is None:
return p2
return (p1[0], _append(p1[1], p2))
def _map(proc, lst):
if lst is None:
return None
return (proc([lst[0]]), _map(proc, lst[1]))
def scheme_list_to_python(pair):
"""Convert our pair-based list to a Python list for display."""
result = []
while pair is not None:
result.append(pair[0])
pair = pair[1]
return result
# --- Test the global environment ---
# (Re-define tokenizer, parser, Env, Procedure, scheme_eval here - abbreviated)
import re
class SchemeError(Exception): pass
class Env(dict):
def __init__(self, params=(), args=(), outer=None):
super().__init__()
self.outer = outer
self.update(zip(params, args))
def find(self, var):
if var in self: return self
if self.outer is None: raise SchemeError(f"Unbound: {var!r}")
return self.outer.find(var)
class Procedure:
def __init__(self, params, body, env):
self.params, self.body, self.env = params, body, env
def __call__(self, args):
return scheme_eval(self.body, Env(self.params, args, self.env))
def __repr__(self): return f"#<procedure>"
def scheme_eval(x, env):
if isinstance(x, (int, float, bool)): return x
if isinstance(x, str) and x.startswith('"'): return x
if isinstance(x, str): return env.find(x)[x]
if not isinstance(x, list) or not x: raise SchemeError(f"Bad expr: {x!r}")
head = x[0]
if head == 'quote': return x[1]
if head == 'if':
test = scheme_eval(x[1], env)
branch = x[2] if test is not False else (x[3] if len(x) > 3 else False)
return scheme_eval(branch, env)
if head == 'define':
if isinstance(x[1], list):
env[x[1][0]] = Procedure(x[1][1:], x[2], env)
else:
env[x[1]] = scheme_eval(x[2], env)
return None
if head == 'set!':
env.find(x[1])[x[1]] = scheme_eval(x[2], env); return None
if head == 'lambda':
body = x[2] if len(x)==3 else ['begin']+x[2:]
return Procedure(x[1], body, env)
if head == 'begin':
result = None
for e in x[1:]: result = scheme_eval(e, env)
return result
if head == 'let':
params = [b[0] for b in x[1]]; args = [scheme_eval(b[1],env) for b in x[1]]
return Procedure(params, x[2], env)(args)
if head == 'and':
r = True
for e in x[1:]:
r = scheme_eval(e, env)
if r is False: return False
return r
if head == 'or':
for e in x[1:]:
r = scheme_eval(e, env)
if r is not False: return r
return False
proc = scheme_eval(head, env); args = [scheme_eval(a,env) for a in x[1:]]
if callable(proc): return proc(args)
raise SchemeError(f"Not a procedure: {proc!r}")
def tokenize(s): return re.findall(r'\"[^\"]*\"|\(|\)|[^\s()\"]+', s)
def parse_atom(t):
if t=='#t': return True
if t=='#f': return False
if t.startswith('"'): return t
try: return int(t)
except: pass
try: return float(t)
except: pass
return t
def parse_tokens(tokens):
if not tokens: raise SyntaxError("EOF")
t = tokens.pop(0)
if t=='(':
lst=[]
while tokens[0]!=')': lst.append(parse_tokens(tokens))
tokens.pop(0); return lst
elif t=="'": return ['quote',parse_tokens(tokens)]
else: return parse_atom(t)
def parse_sexp(s): return parse_tokens(tokenize(s))
genv = make_global_env()
tests = [
("(cons 1 2)", None),
("(car (cons 1 2))", None),
("(cdr (cons 1 2))", None),
("(null? (list))", None),
("(pair? (cons 1 2))", None),
("(number? 42)", None),
("(boolean? #t)", None),
("(procedure? car)", None),
]
for src, _ in tests:
result = scheme_eval(parse_sexp(src), genv)
print(f"{src!s:40s} => {result}")
# List demo
lst = scheme_eval(parse_sexp("(list 1 2 3 4)"), genv)
print("(list 1 2 3 4) as Python:", scheme_list_to_python(lst))
Questions to Consider, Model 4
Question 10. Our cons returns a Python 2-tuple (head, tail), not a Python list. This means (list 1 2 3) produces (1, (2, (3, None))). Name two operations from Model 3 that would break if we used Python lists instead of tuples for pairs. Why would the null? check fail?
Part V: Tail Call Optimization
Model 5: The Stack Overflow Problem and the Trampoline
A properly tail-recursive Scheme program should run in constant stack space; that is the Scheme specification’s guarantee. But our Python evaluator grows a Python stack frame for every recursive scheme_eval call, even when the Scheme call is in tail position. The trampoline fixes this without changing Python’s runtime: instead of recursing, tail calls return a “do this next” object (a Thunk), and a top-level loop bounces on those thunks until a real value appears. It converts recursion into iteration by making “what to do next” explicit.
Watch out! The TCO evaluator uses a
while Trueloop withcontinuefor self-tail-calls. This is only an optimization for calls where the current function calls itself. Calls to a different procedure still need to updatexandenvandcontinuethe loop, which is what theProcedure callbranch does. Missing thecontinueafter updatingenvandxwould send execution to the bottom of the loop body instead of restarting from the top.
Python has a default recursion limit of about 1000 frames. A naive Scheme-in-Python evaluator will hit this limit when evaluating deeply recursive Scheme programs, even if the Scheme program is tail recursive and should need no stack at all.
Consider:
(define count-down
(lambda (n)
(if (= n 0)
'done
(count-down (- n 1)))))
(count-down 10000) ; Should work in Scheme; crashes in naive Python evaluator
The fix is a trampoline: instead of calling the recursive eval directly, return a thunk (a zero-argument lambda that will do the work) from tail positions. The trampoline loop bounces on thunks until a real value emerges.
# Trampoline-based TCO evaluator
class Thunk:
"""A deferred computation: a zero-argument callable."""
def __init__(self, thunk_fn):
self.thunk_fn = thunk_fn
def __call__(self):
return self.thunk_fn()
def trampoline(val):
"""Repeatedly call val() while val is a Thunk; return the final value."""
while isinstance(val, Thunk):
val = val()
return val
# In scheme_eval_tco we return Thunk objects at tail positions.
# Here is the key part of the TCO evaluator - only the changed branches shown:
def scheme_eval_tco(x, env):
"""
TCO variant: tail calls return Thunk instead of recursing.
Call via trampoline(scheme_eval_tco(expr, env)).
"""
while True: # Use a loop for self-tail-calls to avoid Python stack growth
if isinstance(x, (int, float, bool)):
return x
if isinstance(x, str) and x.startswith('"'):
return x
if isinstance(x, str):
return env.find(x)[x]
if not isinstance(x, list) or not x:
raise SchemeError(f"Bad expr: {x!r}")
head = x[0]
if head == 'quote':
return x[1]
# (if ...) - only the taken branch is a tail position
if head == 'if':
test = trampoline(scheme_eval_tco(x[1], env))
branch = x[2] if test is not False else (x[3] if len(x) > 3 else False)
x = branch # tail position: loop instead of recurse
continue
if head == 'define':
if isinstance(x[1], list):
env[x[1][0]] = ProcedureTCO(x[1][1:], x[2], env)
else:
env[x[1]] = trampoline(scheme_eval_tco(x[2], env))
return None
if head == 'set!':
env.find(x[1])[x[1]] = trampoline(scheme_eval_tco(x[2], env))
return None
if head == 'lambda':
body = x[2] if len(x)==3 else ['begin']+x[2:]
return ProcedureTCO(x[1], body, env)
# (begin ...) - last expression is in tail position
if head == 'begin':
for expr in x[1:-1]:
trampoline(scheme_eval_tco(expr, env))
x = x[-1] # tail position: loop
continue
if head == 'let':
params = [b[0] for b in x[1]]
args = [trampoline(scheme_eval_tco(b[1], env)) for b in x[1]]
env = Env(params, args, env)
x = x[2] # tail position: loop
continue
# Procedure call
proc = trampoline(scheme_eval_tco(head, env))
args = [trampoline(scheme_eval_tco(a, env)) for a in x[1:]]
if isinstance(proc, ProcedureTCO):
env = Env(proc.params, args, proc.env)
x = proc.body # tail call: loop
continue
elif callable(proc):
return proc(args)
raise SchemeError(f"Not a procedure: {proc!r}")
class ProcedureTCO:
def __init__(self, params, body, env):
self.params, self.body, self.env = params, body, env
def __repr__(self): return "#<procedure-tco>"
# --- We need supporting code from previous models here ---
import re
class SchemeError(Exception): pass
class Env(dict):
def __init__(self, params=(), args=(), outer=None):
super().__init__(); self.outer = outer; self.update(zip(params, args))
def find(self, var):
if var in self: return self
if self.outer is None: raise SchemeError(f"Unbound: {var!r}")
return self.outer.find(var)
def tokenize(s): return re.findall(r'\"[^\"]*\"|\(|\)|[^\s()\"]+', s)
def parse_atom(t):
if t=='#t': return True
if t=='#f': return False
if t.startswith('"'): return t
try: return int(t)
except: pass
try: return float(t)
except: pass
return t
def parse_tokens(tokens):
if not tokens: raise SyntaxError("EOF")
t = tokens.pop(0)
if t=='(':
lst=[]
while tokens[0]!=')': lst.append(parse_tokens(tokens))
tokens.pop(0); return lst
elif t=="'": return ['quote', parse_tokens(tokens)]
else: return parse_atom(t)
def parse_sexp(s): return parse_tokens(tokenize(s))
def make_global_env_tco():
env = Env()
env.update({
'+': lambda a: a[0]+a[1], '-': lambda a: a[0]-a[1],
'*': lambda a: a[0]*a[1], '/': lambda a: a[0]/a[1],
'<=': lambda a: a[0]<=a[1], '>=': lambda a: a[0]>=a[1],
'<': lambda a: a[0]<a[1], '>': lambda a: a[0]>a[1],
'=': lambda a: a[0]==a[1],
'display': lambda a: (print(a[0], end=''), None)[1],
'newline': lambda a: (print(), None)[1],
})
return env
def run(src):
genv = make_global_env_tco()
exprs = []
tokens = tokenize(src)
while tokens:
exprs.append(parse_tokens(tokens))
result = None
for expr in exprs:
result = trampoline(scheme_eval_tco(expr, genv))
return result
# --- Demo: deep recursion without stack overflow ---
prog = """
(define count-down
(lambda (n)
(if (= n 0)
0
(count-down (- n 1)))))
"""
print("count-down 100000 =>", run(prog + "(count-down 100000)"))
# Tail-recursive sum
prog2 = """
(define sum-iter
(lambda (n acc)
(if (= n 0)
acc
(sum-iter (- n 1) (+ acc n)))))
"""
print("sum 0..1000 =>", run(prog2 + "(sum-iter 1000 0)"))
Questions to Consider, Model 5
Question 11. In the expression (if test then-branch else-branch), which sub-expressions are in tail position and which are not? Justify your answer by explaining what computation (if any) must happen after that sub-expression returns.
Question 12. Python does not automatically optimize tail calls, even when the programmer writes a tail-recursive function. Name two language design decisions in Python that make automatic tail-call optimization difficult or undesirable (consider stack traces, debugging, and Python’s object model).
Part VI: Multiple Choice Comprehension Check
Answer these to check your understanding before moving on.
Question 1. What does evaluating (lambda (x) x) return in our interpreter?
- The number
0 - A
Procedureobject (a closure) - The symbol
x - A
SchemeErrorbecausexis unbound
Answer
A `Procedure` object (a closure)Question 2. The expression (let ((x 5)) (+ x 1)) desugars to which of the following?
(define x 5) (+ x 1)((lambda (x) (+ x 1)) 5)(set! x 5) (+ x 1)(begin (define x 5) (+ x 1))
Answer
`((lambda (x) (+ x 1)) 5)`Question 3. In (define (square n) (* n n)), the list (square n) as the first argument to define is:
- A syntax error in standard Scheme
- A pair of a function name and its return type
- Syntactic sugar that expands to
(define square (lambda (n) (* n n))) - A call to the
squarefunction before it is defined
Answer
Syntactic sugar that expands to `(define square (lambda (n) (* n n)))`Question 4. Which component of the evaluator is directly responsible for implementing lexical scope?
- The tokenizer, which preserves symbol names
- The
scheme_evaldispatch loop - The
Envchain: eachProcedurecaptures and stores its defining environment, which becomes theouterof each call frame - The
trampolinefunction
Answer
The `Env` chain: each `Procedure` captures and stores its *defining* environment, which becomes the `outer` of each call framePart VII: Exercises
Work through these exercises at your own pace. Each builds directly on the evaluator code from Parts I-V.
Exercise 1: Add cond
Scheme’s cond is a multi-way conditional:
(cond
((< x 0) 'negative)
((= x 0) 'zero)
(else 'positive))
It evaluates each test in order; the first truthy test causes its associated expression to be evaluated and returned. The else clause (if present) is always truthy.
Task: Add a cond branch to scheme_eval (or scheme_eval_tco). The clause list is x[1:]; each clause is a two-element list [test, expr]. The special symbol 'else' should be treated as always true.
# Starter: fill in the cond branch inside scheme_eval
# if head == 'cond':
# for clause in x[1:]:
# test_expr, result_expr = clause[0], clause[1]
# if test_expr == 'else' or scheme_eval(test_expr, env) is not False:
# return scheme_eval(result_expr, env)
# return None # no matching clause
# Test with:
# (cond ((< 3 0) 'neg) ((= 3 0) 'zero) (else 'pos))
# Expected: 'pos'
Write the complete working implementation and verify it handles the test case above, plus a case where the first clause matches and the others are never evaluated.
Exercise 2: Add Scheme do Loops
Scheme’s do loop is a structured iteration form:
(do ((i 0 (+ i 1)) ; var init step
(sum 0 (+ sum i)))
((= i 5) sum) ; termination test, result
(display i)) ; body (side effect only; run each iteration)
Each binding is (var init step). On each iteration: evaluate all step expressions (using the current bindings, not the updated ones), then rebind. When test is true, evaluate result and return it.
Task: Implement do as a special form in scheme_eval. You will need to:
- Extract bindings, the termination clause, and the body.
- Create an initial environment with
var = initfor each binding. - Loop: check the test; if true, evaluate and return the result expression. Otherwise evaluate the body, compute all new step values simultaneously, rebind, repeat.
Exercise 3: Tail-Recursive map in Pure Scheme
The built-in map uses Python recursion. Write a pure Scheme map that is tail-recursive using an accumulator, then reverses the result.
(define my-reverse
(lambda (lst acc)
(if (null? lst)
acc
(my-reverse (cdr lst) (cons (car lst) acc)))))
(define my-map
(lambda (f lst)
; YOUR CODE HERE
; Use my-reverse and an accumulator
))
(my-map (lambda (x) (* x x)) (list 1 2 3 4 5))
; Expected: (1 4 9 16 25) as a Scheme list
Verify that your implementation produces the correct result by running it in the TCO evaluator. Then explain: is your my-map call to my-map in the recursive case actually in tail position? Draw the call to convince yourself.
Exercise 4: The Y Combinator
Without define, a lambda cannot refer to itself by name. The Y combinator makes anonymous recursion possible. In our evaluator (which uses applicative-order evaluation), the Z combinator (the strict variant) works:
(define Z
(lambda (f)
((lambda (x) (f (lambda (v) ((x x) v))))
(lambda (x) (f (lambda (v) ((x x) v)))))))
(define fact
(Z (lambda (self)
(lambda (n)
(if (<= n 1)
1
(* n (self (- n 1))))))))
(fact 6)
; Expected: 720
Task:
- Run the Z combinator in your evaluator. Verify
(fact 6) = 720. - Explain in one paragraph why the eager Y combinator
(lambda (f) ((lambda (x) (f (x x))) (lambda (x) (f (x x)))))diverges under applicative-order evaluation but the Z combinator above does not. - Challenge: Define
fibusingZwithoutdefine. Test(fib 10).
Part VIII: Reflection
Answer these questions in your course notebook after completing this section.
Reflection 1. The word “metacircular” implies the evaluator is defined in terms of itself. Our evaluator is written in Python, not Scheme; so in what sense is it still “metacircular”? What would it take to port our evaluator from Python into the Scheme subset our evaluator understands, and what would that accomplish?
Reflection 2. The course’s project assignments ask you to build and extend a language interpreter. Identify three specific features from this evaluator (the Env chain, Procedure as a closure, or TCO via trampoline) that map directly to something you will need in your own interpreter. For each, write one sentence explaining the connection.
Reflection 3. Our evaluator has no type system: (+ 1 "hello") raises a Python TypeError that leaks through the abstraction boundary. Describe at minimum two changes you would make to add a static type system to this evaluator. Consider: where would type annotations appear in the s-expression representation? Where in scheme_eval would you insert a type-checking pass? What new data structure would represent a type error vs. a value?
Further Reading on Metacircular Evaluation
-
Runnable example archive: SchemeInterpreter.zip: a complete reference implementation of this section’s evaluator, worth exploring after you have worked through this section yourself.
-
SICP Chapter 4: Abelson & Sussman, Structure and Interpretation of Computer Programs, 2nd ed. The original metacircular evaluator. MIT Press open access: https://mitp-content-server.mit.edu/books/content/sectbyfn/books_pubs/6515/sicp.pdf
-
“The Art of the Interpreter”: Guy Steele & Gerald Sussman (1978). The foundational paper on meta-circular evaluation, environments, and the relationship between interpreters and compilers. MIT AI Memo 452.
-
Norvig’s
lis.py: Peter Norvig’s “How to Write a (Lisp) Interpreter in Python.” Norvig’s version is compact and elegant; ours extends it with TCO and a fuller special-form set. Search for “Norvig lis.py” to find his blog post. -
R7RS Scheme specification: The current small Scheme standard. Section 4 (Expressions) maps directly to our
scheme_evaldispatch table. Available at https://small.r7rs.org/. -
“Proper Tail Recursion and Space Efficiency”: Will Clinger (PLDI 1998). A careful treatment of what tail-call optimization guarantees and how to implement it correctly.
From the Closures Activity: Closures in Your Interpreter
Twenty lines that give your interpreter first-class functions, plus why closures are what make recursion work. Previously part of the Closures class session.
Part II: Closures in Your Interpreter
Building an interpreter that supports closures requires translating the abstract idea (“a function carries its birth environment”) into concrete data structures. Think of it like building a passport system: when a function is created, you stamp its passport with the environment it was born in; when it is called later, you open a new room that is connected back to that stamped environment, not to wherever the function happens to be called from. This section shows exactly how Environment, Closure, and eval_call work together to implement that passport stamp.
2. Twenty Lines to First-Class Functions
Adding closures to Mini requires:
- A
FunDefnode and aCallnode from the parser - A
Closurevalue created at definition time, capturing the current environment - A call rule that builds the new environment parented on the closure’s captured environment
# Closure-based interpreter for Mini (simplified)
class Environment:
def __init__(self, parent=None):
self._vars = {}
self.parent = parent
def define(self, name, value):
self._vars[name] = value
def lookup(self, name):
if name in self._vars:
return self._vars[name]
if self.parent is not None:
return self.parent.lookup(name)
raise NameError(f"Undefined: {name}")
def assign(self, name, value):
if name in self._vars:
self._vars[name] = value
elif self.parent is not None:
self.parent.assign(name, value)
else:
raise NameError(f"Undefined: {name}")
class Closure:
def __init__(self, params, body, env):
self.params = params
self.body = body
self.env = env # THE CAPTURED ENVIRONMENT - static scope lives here
class ReturnSignal(Exception):
def __init__(self, value): self.value = value
def execute_fundef(name, params, body, env):
"""Create a closure and bind it to name in env."""
closure = Closure(params, body, env) # capture env HERE
env.define(name, closure)
def eval_call(callee_val, arg_vals, evaluate_body, env):
"""Call a closure with evaluated argument values."""
fn = callee_val
if not isinstance(fn, Closure):
raise TypeError(f"Not callable: {fn!r}")
if len(arg_vals) != len(fn.params):
raise TypeError(f"Expected {len(fn.params)} args, got {len(arg_vals)}")
# *** THE ONE LINE THAT CHOOSES LEXICAL SCOPE ***
local = Environment(parent=fn.env) # parent = DEFINING env, not calling env!
for name, val in zip(fn.params, arg_vals):
local.define(name, val)
try:
evaluate_body(fn.body, local)
except ReturnSignal as r:
return r.value
return None
# -----------------------------------------------------------------------
# STEP-BY-STEP TRACE: what happens when we define and call make_adder(5)
#
# Step 1 (DEFINITION - execute_fundef called):
# current env = global_env { }
# closure = Closure(params=["n"], body=..., env=global_env)
# global_env.define("make_adder", closure)
# Result: global_env = { make_adder -> <Closure env=global_env> }
#
# Step 2 (CALL make_adder(5) - eval_call called):
# fn = global_env.lookup("make_adder") -> the Closure from Step 1
# arg_vals = [5]
# local = Environment(parent=fn.env) # parent = global_env (lexical!)
# local.define("n", 5)
# Result: local = { n -> 5, parent -> global_env }
# evaluate_body runs and creates the inner 'adder' closure:
# inner_closure = Closure(params=["x"], body=..., env=local) # captures local!
# local.define("adder", inner_closure)
# ReturnSignal(inner_closure) raised and caught
# eval_call returns inner_closure
#
# Step 3 (CALL add5(3), where add5 = inner_closure from Step 2):
# fn = add5 (inner_closure, env=local where n=5)
# arg_vals = [3]
# call_env = Environment(parent=fn.env) # parent = local (n=5), NOT global!
# call_env.define("x", 3)
# body evaluates x + n:
# call_env.lookup("x") -> 3 (found in call_env)
# call_env.lookup("n") -> not in call_env -> tries parent (local) -> 5
# return 3 + 5 = 8 OK
#
# The key: Step 3 uses fn.env (local, where n=5) as parent, NOT the caller's env.
# That single parent= choice IS lexical scope.
# -----------------------------------------------------------------------
# Demo: make_adder in this closure system
global_env = Environment()
execute_fundef("make_adder", ["n"],
# body: return lambda x: x + n (simulated as a nested closure)
[("fundef", "adder", ["x"], [("return", ("add", ("var", "x"), ("var", "n")))])],
global_env)
# Verify the closure was created and captured the right env
ma = global_env.lookup("make_adder")
print(f"make_adder is a Closure: {isinstance(ma, Closure)}")
print(f"make_adder captured env has 'make_adder': {'make_adder' in ma.env._vars}")
CTQs
CTQ 3.1 Find the single line
local = Environment(parent=fn.env)that decides static-versus-dynamic scope. Write the one-token change that would make your language dynamically scoped. (Hint: what if you usedenvinstead offn.env?)
CTQ 3.2 Arguments are evaluated in
env(the caller’s environment) but bound inlocal(parented on the definer’s environment). Construct a program where these two environments differ and where confusing them would change the output.
CTQ 3.3
ReturnSignalrides an exception out of nested blocks to the call boundary. What would happen ifeval_callcaught all exceptions rather than onlyReturnSignal?
For a function to call itself recursively, it must be able to look up its own name at the moment it runs. This is not automatic; it requires the function’s name to be bound in the environment before the function body executes. Think of it like a business that must be registered with the government before it can issue contracts referencing itself. This model shows the precise ordering: bind the name first, then use the closure, so that recursive lookup through the captured environment succeeds.
Model 3: Closures Enable Recursion
# Recursion requires the function to see itself in its own closure.
# execute_fundef binds the name BEFORE returning, so:
class Environment:
def __init__(self, parent=None):
self._vars = {}
self.parent = parent
def define(self, name, val): self._vars[name] = val
def lookup(self, name):
if name in self._vars: return self._vars[name]
if self.parent: return self.parent.lookup(name)
raise NameError(name)
class Closure:
def __init__(self, params, body_fn, env):
self.params = params; self.body_fn = body_fn; self.env = env
def __call__(self, *args):
local = Environment(parent=self.env)
for p, a in zip(self.params, args):
local.define(p, a)
return self.body_fn(local)
global_env = Environment()
# Define factorial using our closure mechanism
# fact(n) = if n <= 0 then 1 else n * fact(n-1)
def fact_body(env):
n = env.lookup('n')
if n <= 0: return 1
return n * env.lookup('fact')(n - 1) # looks up 'fact' via captured env!
fact_closure = Closure(['n'], fact_body, global_env)
global_env.define('fact', fact_closure) # bind BEFORE any calls
print(f"fact(0) = {global_env.lookup('fact')(0)}")
print(f"fact(5) = {global_env.lookup('fact')(5)}")
print(f"fact(10) = {global_env.lookup('fact')(10)}")
CTQ 4.1
execute_fundefdefines the name in the current environment before any calls. Whenfact_bodyruns and looks up'fact', it finds the closure inglobal_env. Trace the environment chain: call frame -> capturedglobal_env-> findsfact. What would break if we didn’t define the name until after creating the closure?
CTQ 4.2
make_addercreates a new closure for each call.factis a single closure that calls itself. Draw the environment chain forfact(3)callingfact(2)callingfact(1)callingfact(0). How deep does the chain grow?
Every closure question becomes answerable the moment you draw the boxes. Here we take the classic counter factory (the “hello world” of stateful closures) and draw every environment box and arrow it creates, then verify the picture by peeking at Python’s actual closure cells.
Model 4: The Counter Factory, Drawn as Environment Boxes
Worked example. Trace this program by hand before running anything:
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
c1 = make_counter() # call #1
c2 = make_counter() # call #2
c1(); c1(); c2()
Step by step:
- Call #1 to
make_countercreates environment box E1 (parent: global) holdingcount = 0. - The
def incrementinside that call creates closure A = ⟨code ofincrement, E1⟩, which is returned and bound toc1.make_counterhas returned, but E1 survives: closure A still points to it (lifetime follows reachability). - Call #2 repeats the story with a fresh box E2 and closure B, bound to
c2. c1()creates a call frame whose parent is E1 (the captured environment, not the caller’s!).nonlocal countmakescount += 1an assignment into E1: E1’s count becomes 1. The secondc1()makes it 2.c2()assigns into E2: its count becomes 1. E1 is untouched.
The final picture:
+---------------------------+
| global |
| make_counter -> <fn> |
| c1 -> closure A |
| c2 -> closure B |
+---------------------------+
^ ^
parent | | parent
+-----------------+ +-----------------+
| E1 (call #1) | | E2 (call #2) |
| count = 2 | | count = 1 |
+-----------------+ +-----------------+
^ ^
captured | captured |
closure A = <increment, E1> closure B = <increment, E2>
(bound to c1) (bound to c2)
And the same history as a table:
| Action | E1’s count |
E2’s count |
Return value |
|---|---|---|---|
c1 = make_counter() |
0 | - | closure A |
c2 = make_counter() |
0 | 0 | closure B |
c1() |
1 | 0 | 1 |
c1() |
2 | 0 | 2 |
c2() |
2 | 1 | 1 |
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
c1 = make_counter()
c2 = make_counter()
print(c1(), c1()) # 1 2 - both assignments land in E1
print(c2()) # 1 - E2 is a separate box
# Verify the boxes are real: Python exposes them as closure "cells"
print("c1's captured count:", c1.__closure__[0].cell_contents) # 2
print("c2's captured count:", c2.__closure__[0].cell_contents) # 1
print("same box?", c1.__closure__[0] is c2.__closure__[0]) # False - E1 is not E2
After c1 = make_counter(), c2 = make_counter(), then c1(); c1(); c2(), the returned values are 1, 2, 1 because:
- Each call to
c1creates a fresh environment withcount = 0 - Each call to
make_countercreated its own environment box, soc1andc2increment differentcountbindings - Python copies the value of
countinto each closure at definition time c2reset the shared counter
Answer
Each *call to `make_counter`* created its own environment box, so `c1` and `c2` increment different `count` bindingsCritical Thinking Questions (CTQs)
CTQ 5.1 The diagram shows two separate boxes, E1 and E2, each holding its own
count. What single fact about when environment boxes are created explains whyc1andc2never interfere?
CTQ 5.2
make_counterreturned long ago, yet the table shows E1’scountstill changing. Using the phrase “lifetime follows reachability,” name exactly what is keeping E1 alive, and predict what would have to happen for Python to reclaim it.
CTQ 5.3
nonlocal countmakescount += 1an assign into E1 rather than a define of a new local. Connect this to the environments module: withoutnonlocal, which operation wouldcount += 1attempt, and why does it fail here? (Delete thenonlocalline in the cell and read the error.)
CTQ 5.4 Redraw the boxes for the
make_counterof Model 1, which returns two closures (incrementandreset). How many E-boxes does one call create, and which arrows in your drawing explain why the pair shares state?