Compilers, interpreters & automata
How text becomes behaviour: compiled vs interpreted vs bytecode VMs, the lex→parse→AST→eval pipeline built end-to-end in Python, grammars in BNF, finite automata and regular languages, and the Chomsky hierarchy up to undecidability.
Learning objectives
- Distinguish compiled, interpreted and bytecode-VM execution, and place Python/Java/C.
- Name the stages of a compiler: lex → parse → AST → codegen.
- Build a working lexer, recursive-descent parser and evaluator for arithmetic.
- Read a grammar in BNF and relate it to the parser's structure.
- Implement a DFA that decides a regular language, and connect regex ↔ DFA.
- Place regular/context-free/recursive languages in the Chomsky hierarchy, and nod to undecidability.
1 · Compiled vs interpreted vs bytecode VM essential
There are three broad ways to run source code. Compiled (C, Rust, Go): a compiler translates the whole program to native machine code ahead of time; you ship a binary. Interpreted (classic shell, early BASIC): a program reads and executes your source line by line. Bytecode VM (Python, Java, C#): the source is compiled to portable bytecode, then a virtual machine executes that bytecode.
| Language | How it runs | Artifact |
|---|---|---|
| C / Rust / Go | compiled to native machine code (AOT) | an executable binary |
| Python | compiled to bytecode, run by the CPython VM | .pyc + interpreter |
| Java / C# | compiled to bytecode, run by JVM/CLR (often JIT to native) | .class / IL |
| JavaScript | parsed then JIT-compiled by the engine | in-memory native code |
Python is not purely interpreted: it compiles your .py to bytecode first. You can see the bytecode with the standard-library dis module:
show_bytecode.pyimport dis
def add(a, b):
return a + b
dis.dis(add)
2 RESUME 0
3 LOAD_FAST_LOAD_FAST 1 (a, b)
BINARY_OP 0 (+)
RETURN_VALUE
LOAD_FASTs with instruction offsets; pre-3.11 uses BINARY_ADD instead of BINARY_OP). The concept — Python compiles your source to a stack-machine bytecode before running it — is what's stable; run dis.dis on your own build to see exactly what yours produces.2 · The compiler pipeline essential
Whether it ends in machine code or an interpreter, a language processor's front end is the same assembly line: lexing chops text into tokens, parsing builds an AST (abstract syntax tree) that captures structure, and the back end does code generation or direct evaluation.
3 · Build a lexer advanced
A lexer (tokenizer) turns a flat string into a list of meaningful tokens, discarding whitespace. For arithmetic we need numbers, the four operators, and parentheses:
lexer.pydef tokenize(src):
tokens, i = [], 0
while i < len(src):
ch = src[i]
if ch.isspace():
i += 1
elif ch.isdigit():
j = i
while j < len(src) and src[j].isdigit():
j += 1
tokens.append(("NUM", int(src[i:j])))
i = j
elif ch in "+-*/()":
kind = {"+": "PLUS", "-": "MINUS", "*": "STAR",
"/": "SLASH", "(": "LP", ")": "RP"}[ch]
tokens.append((kind, ch))
i += 1
else:
raise SyntaxError(f"unexpected char {ch!r}")
tokens.append(("EOF", None))
return tokens
for t in tokenize("3 + 4 * 2"):
print(t)
('NUM', 3)
('PLUS', '+')
('NUM', 4)
('STAR', '*')
('NUM', 2)
('EOF', None)
4 · A grammar in BNF intermediate
A grammar defines which token sequences are valid. Written in BNF (Backus–Naur Form), our arithmetic grammar encodes precedence — *// bind tighter than +/- — by layering the rules:
grammar.bnfexpr ::= term (("+" | "-") term)*
term ::= factor (("*" | "/") factor)*
factor ::= NUMBER | "(" expr ")"
# "expr is made of terms joined by + or -"
# "term is made of factors joined by * or /" <- tighter binding
# "factor is a number or a parenthesised expr" <- recursion / grouping
expr, term, factor) becomes one function in the parser below. Precedence falls out of which rule calls which: because term is inside expr, multiplication is resolved before addition.5 · A recursive-descent parser & evaluator advanced
A recursive-descent parser is one function per grammar rule, calling each other exactly as the grammar nests. Here we fold evaluation into the parse (a one-pass interpreter): each rule returns the numeric value of what it parsed, so the calculator is complete.
parser.pyclass Parser:
def __init__(self, tokens):
self.toks, self.pos = tokens, 0
def peek(self): return self.toks[self.pos]
def eat(self, kind):
tok = self.toks[self.pos]
if tok[0] != kind:
raise SyntaxError(f"expected {kind}, got {tok[0]}")
self.pos += 1
return tok
def parse(self):
v = self.expr()
self.eat("EOF")
return v
def expr(self): # expr ::= term (("+"|"-") term)*
v = self.term()
while self.peek()[0] in ("PLUS", "MINUS"):
op = self.eat(self.peek()[0])[0]
v = v + self.term() if op == "PLUS" else v - self.term()
return v
def term(self): # term ::= factor (("*"|"/") factor)*
v = self.factor()
while self.peek()[0] in ("STAR", "SLASH"):
op = self.eat(self.peek()[0])[0]
v = v * self.factor() if op == "STAR" else v // self.factor()
return v
def factor(self): # factor ::= NUM | "(" expr ")"
tok = self.peek()
if tok[0] == "NUM":
self.eat("NUM"); return tok[1]
self.eat("LP"); v = self.expr(); self.eat("RP")
return v
def calc(src):
return Parser(tokenize(src)).parse()
print("3 + 4 * 2 =", calc("3 + 4 * 2")) # * binds tighter
print("(3 + 4) * 2 =", calc("(3 + 4) * 2")) # parens override
print("20 / 4 - 1 =", calc("20 / 4 - 1"))
3 + 4 * 2 = 11
(3 + 4) * 2 = 14
20 / 4 - 1 = 4
3 + 4 * 2 gives 11, not 14, purely because term (multiplication) is resolved inside expr (addition) — the grammar's layering is the precedence. Real language interpreters are this same shape, scaled up with more rules and an explicit AST.6 · Regular languages & finite automata advanced
The lexer above recognised numbers with a loop — that loop is really a finite automaton. A DFA (deterministic finite automaton) is the simplest machine: a set of states, a transition for each input symbol, a start state, and some accepting states. It has no memory beyond its current state, so it recognises exactly the regular languages — the same class that regular expressions describe.
Let's build a DFA that accepts binary strings representing numbers divisible by 3. The trick: the state is the running remainder mod 3. Reading a bit b updates r = (r*2 + b) % 3; accept if we end in remainder 0.
dfa.py# states 0,1,2 = value-so-far mod 3; start = 0; accept = {0}
TRANSITION = {
(0, "0"): 0, (0, "1"): 1,
(1, "0"): 2, (1, "1"): 0,
(2, "0"): 1, (2, "1"): 2,
}
ACCEPT = {0}
def accepts(s):
state = 0 # start state
for ch in s:
state = TRANSITION[(state, ch)] # deterministic step
return state in ACCEPT
for s in ["0", "11", "110", "1001", "1010", "111"]:
print(f"{s:>5} (={int(s, 2):>2}) accepted={accepts(s)}")
0 (= 0) accepted=True
11 (= 3) accepted=True
110 (= 6) accepted=True
1001 (= 9) accepted=True
1010 (=10) accepted=False
111 (= 7) accepted=False
7 · The Chomsky hierarchy & the limits of computation professional
Languages form a hierarchy by the power of machine needed to recognise them (Chomsky, 1956). More power = more memory structure:
| Type | Language class | Machine | Example |
|---|---|---|---|
| Type 3 | Regular | finite automaton (DFA/NFA) | a*b*, 'divisible by 3' |
| Type 2 | Context-free | pushdown automaton (a stack) | balanced parens, arithmetic |
| Type 1 | Context-sensitive | linear-bounded automaton | aⁿbⁿcⁿ |
| Type 0 | Recursively enumerable | Turing machine | general programs |
A DFA can't count unbounded nesting — matching (( )) to arbitrary depth needs a stack, i.e. a context-free grammar (which is why our parser, not the lexer, handles parentheses). At the top sits the Turing machine, the model of everything computable.
✓ Checkpoint — you can move on when you can…
- Explain where C, Python and Java sit on compiled/interpreted/VM, and show Python's bytecode with dis.
- List the compiler stages lex → parse → AST → codegen/eval.
- Write a lexer that turns a string into tokens.
- Read the BNF grammar and map each rule to a parser function.
- Trace how the recursive-descent parser gives * higher precedence than +.
- Implement a DFA for a regular language and state the Chomsky level of parentheses vs a regex.
Knowledge check check yourself
In our arithmetic interpreter, why does 3 + 4 * 2 evaluate to 11 rather than 14 — where does precedence come from?
Show answer
expr handles +/− and calls term, which handles */÷ and calls factor. Because term is resolved inside expr, the 4 * 2 is computed first (=8) and then added to 3, giving 11. The parser structure mirrors the grammar, so the nesting is the precedence.Why can a DFA recognise 'binary strings divisible by 3' but not 'strings of the form (ⁿ )ⁿ' (n open then n close parens)?
Show answer
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Knowing whether your language is compiled, interpreted or VM-based explains startup cost, portability and debugging — day-one working knowledge.
Your task: State whether each of C, Python and Java is compiled-to-native, interpreted, or compiled-to-bytecode-run-by-a-VM, and name the artifact each produces.
Requirements:
- Give the execution model for C, Python, Java
- Name the shipped artifact for each
- Note that Python still compiles to bytecode first (it isn't line-by-line interpreted)
💡 Hint: The artifact is the giveaway: a native binary vs a .pyc/.class.
Show solution
answer = {
'C': 'compiled to native machine code -> executable binary',
'Python': 'compiled to bytecode -> run by CPython VM (.pyc)',
'Java': 'compiled to bytecode -> run by JVM, often JIT (.class)',
}
for k, v in answer.items():
print(k, '->', v)C ships native code; Python and Java both compile to a portable bytecode executed by a virtual machine. Python is a bytecode-VM language, not a pure line-by-line interpreter.
Context: Adding a token type is the smallest realistic change to a language front end.
Your task: Extend the arithmetic lexer to also recognise a % (modulo) operator as a PERCENT token.
Requirements:
- Add
%to the operator set and mapping - Emit
('PERCENT', '%') - Tokenize
10 % 3and print the tokens
💡 Hint: It's one entry in the operator dict and one character added to the membership test.
Show solution
def tokenize(src):
toks, i = [], 0
ops = {'+':'PLUS','-':'MINUS','*':'STAR','/':'SLASH',
'%':'PERCENT','(':'LP',')':'RP'}
while i < len(src):
c = src[i]
if c.isspace(): i += 1
elif c.isdigit():
j = i
while j < len(src) and src[j].isdigit(): j += 1
toks.append(('NUM', int(src[i:j]))); i = j
elif c in ops:
toks.append((ops[c], c)); i += 1
else: raise SyntaxError(c)
toks.append(('EOF', None)); return toks
print(tokenize('10 % 3'))
# [('NUM', 10), ('PERCENT', '%'), ('NUM', 3), ('EOF', None)]Lexing is a flat scan; a new operator is just another entry in the dispatch table. Giving % the right precedence would then be a change to the parser, not the lexer.
Context: Handling a prefix operator forces you to touch the right grammar rule — a common real parser task.
Your task: Extend the recursive-descent calculator so factor also accepts a unary minus, making -5 + 2 evaluate to −3.
Requirements:
- Allow a leading MINUS in
factorthat negates the following factor - Keep existing NUM and parenthesised-expr cases
- Verify
-5 + 2= −3 and-(3 + 4)= −7
💡 Hint: In factor, if the next token is MINUS, consume it and return -self.factor().
Show solution
def factor(self):
tok = self.peek()
if tok[0] == 'MINUS': # unary minus
self.eat('MINUS')
return -self.factor()
if tok[0] == 'NUM':
self.eat('NUM'); return tok[1]
self.eat('LP'); v = self.expr(); self.eat('RP')
return v
# with this factor(): calc('-5 + 2') == -3 ; calc('-(3 + 4)') == -7Unary minus belongs in factor because it binds tightest and can prefix either a number or a parenthesised group. Recursing into self.factor() lets --5 work too.
Context: Turning a small regex into a DFA by hand is the core insight behind lexers and regex engines.
Your task: Build a DFA that accepts exactly the strings over {a,b} that end in ab (the language of the regex (a|b)*ab).
Requirements:
- Define states for 'nothing yet', 'just saw a', 'just saw ab'
- Write the transition function for inputs a and b
- Accept only in the 'just saw ab' state
- Test on 'ab', 'aab', 'abab', 'ba', 'a'
💡 Hint: Track the longest suffix of the input that is a prefix of ab: states S0, Sa (seen trailing a), Sab (seen trailing ab).
Show solution
# states: 0 = start/other, 1 = ends in 'a', 2 = ends in 'ab' (accept)
T = {
(0,'a'):1, (0,'b'):0,
(1,'a'):1, (1,'b'):2,
(2,'a'):1, (2,'b'):0,
}
def accepts(s):
st = 0
for c in s: st = T[(st, c)]
return st == 2
for s in ['ab','aab','abab','ba','a']:
print(s, accepts(s))
# ab True / aab True / abab True / ba False / a FalseEach state records how much of the target suffix ab we've matched. Landing in state 2 means the string ends in ab. This hand-construction is exactly what a regex engine automates.
Context: Real compilers build an explicit AST so multiple passes (optimization, type-checking, codegen) can walk it; separating parse from evaluate is a professional habit.
Your task: Refactor the calculator to first build a nested tuple AST, then evaluate the AST in a separate function.
Requirements:
- Parser returns nodes like
('+', left, right)and('num', 3) - A separate
ev(node)recursively evaluates the tree - Preserve precedence (build term nodes under expr nodes)
- Show both the AST and its value for
3 + 4 * 2
💡 Hint: Where you previously combined values, instead return a tuple node; evaluation becomes a post-order tree walk.
Show solution
# parser builds nodes; evaluation is a separate pass
def ev(node):
if node[0] == 'num': return node[1]
op, l, r = node
a, b = ev(l), ev(r)
return {'+':a+b, '-':a-b, '*':a*b, '/':a//b}[op]
# AST for 3 + 4 * 2 (built by the parser, precedence preserved):
ast = ('+', ('num', 3), ('*', ('num', 4), ('num', 2)))
print('AST :', ast)
print('value:', ev(ast)) # 11The * node is nested under the + node, so evaluating bottom-up computes 4*2 first — precedence is now a property of the tree shape. An explicit AST is what lets a compiler run many passes over the same structure.
Context: Teams constantly need to evaluate user-supplied formulas (pricing rules, feature flags, config); using eval() is a critical security hole, so you ship a locked-down parser instead.
Your task: Explain why eval() on untrusted input is dangerous, and provide a safe evaluator for arithmetic that cannot execute arbitrary code.
Requirements:
- State the concrete risk of
eval/execon user input (arbitrary code execution) - Show that the lexer+parser calculator only ever does arithmetic — no attribute access, imports, or calls
- Reject malformed or malicious input with a clear error instead of running it
- Note this is the same principle behind sandboxing template and rule engines
💡 Hint: Because the parser's grammar only produces numbers and +−*/(), there is no rule that could ever parse __import__ or a function call — safety comes from the restricted grammar, not from blacklisting.
Show solution
# DANGER: eval runs ANY Python -- never on untrusted input
# eval("__import__('os').system('rm -rf /')") # catastrophic
# SAFE: our grammar can only ever describe arithmetic.
def safe_calc(src):
try:
return Parser(tokenize(src)).parse()
except SyntaxError as e:
return f'rejected: {e}'
print(safe_calc('2 + 3 * 4')) # 14
print(safe_calc("__import__('os')")) # rejected: unexpected char '_'
print(safe_calc('2 +')) # rejected: expected LP, got EOFThe safety is structural: the lexer refuses characters outside 0-9 +-*/(), and the grammar has no production for names, calls, or imports — so a malicious string can never parse into anything but arithmetic (or an error). This restricted-grammar approach is exactly how sandboxed rule engines and template languages stay safe, whereas eval() hands the attacker the whole interpreter.