AI EngineeringZero to ProductionHome·About·Contact
Developer Foundations · Chapter DF9

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.

⏱️ ~3.5 hours🧪 7 runnable labs🎯 Beginner→Industry

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.
🌱 Start here — from zeroHow does text you type become behaviour a machine performs? A language processor reads your source, understands its structure, and either runs it or translates it. In this lesson you build a tiny language end-to-end — a lexer, a parser, and an evaluator — then step down to the automata theory beneath it. Every runnable block uses only the standard library and prints exactly what's shown.
▶ Runnable companionThe calculator (lexer → parser → evaluator) and the DFA are complete, self-contained programs. Paste and run them; extend the grammar as an exercise.

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.

LanguageHow it runsArtifact
C / Rust / Gocompiled to native machine code (AOT)an executable binary
Pythoncompiled to bytecode, run by the CPython VM.pyc + interpreter
Java / C#compiled to bytecode, run by JVM/CLR (often JIT to native).class / IL
JavaScriptparsed then JIT-compiled by the enginein-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:

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · see the bytecode Python compiles to (runs)
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
Bytecode differs by versionThe output above is from CPython 3.13. Other versions emit different opcodes (e.g. 3.11 shows two separate 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.

Source text 3 + 4 * 2 Lexer scan Tokens NUM PLUS… Parser structure AST tree Eval / codegen = 11

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:

Python · a lexer for arithmetic (runs)
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:

BNF grammar for arithmetic (precedence baked in)
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
Grammar shape = parser shapeEach rule (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.

Python · parse + evaluate arithmetic with precedence (runs)
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
You just wrote an interpreter3 + 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.

state r0 accept state r1 rem 1 state r2 rem 2
Python · a DFA for 'binary divisible by 3' (runs)
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
Regex ↔ DFAEvery regular expression can be converted to an equivalent DFA (via an NFA, by Thompson's construction) and vice-versa — they describe the same regular languages. That equivalence is why regex engines are fast and why lexers are built from finite automata.

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:

TypeLanguage classMachineExample
Type 3Regularfinite automaton (DFA/NFA)a*b*, 'divisible by 3'
Type 2Context-freepushdown automaton (a stack)balanced parens, arithmetic
Type 1Context-sensitivelinear-bounded automatonaⁿbⁿcⁿ
Type 0Recursively enumerableTuring machinegeneral 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.

Some questions have no algorithmThe halting problem — 'will this program halt on this input?' — is undecidable: no program can correctly answer it for all inputs (Turing, 1936). It's a hard limit, not a missing-cleverness problem. This is the gateway to computability and complexity theory — see ds16 · Complexity theory & NP for P, NP and reductions.

✓ 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

✓ Knowledge check

In our arithmetic interpreter, why does 3 + 4 * 2 evaluate to 11 rather than 14 — where does precedence come from?

Show answer
Precedence is encoded in the grammar's layering: 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.
✓ Knowledge check

Why can a DFA recognise 'binary strings divisible by 3' but not 'strings of the form (ⁿ )ⁿ' (n open then n close parens)?

Show answer
Divisibility by 3 needs only a bounded amount of state — the remainder mod 3 — so three states suffice; that's a regular language. Matching n opens to n closes requires counting to an unbounded n, which a finite automaton can't do because it has no memory beyond its finite states. Balanced parentheses are context-free and need a stack (a pushdown automaton), one level up the Chomsky hierarchy.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Classify how a language runsBeginner

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.

Exercise 2 · Extend the lexerIntermediate

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 % 3 and 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.

Exercise 3 · Add unary minus to the parserAdvanced

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 factor that 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)') == -7

Unary 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.

Exercise 4 · DFA for a regexExpert

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 False

Each 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.

Exercise 5 · AST instead of eval-in-placeProfessional

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))   # 11

The * 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.

Exercise 6 · A safe expression evaluatorIndustry scenario

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/exec on 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 EOF

The 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.

© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in