AI EngineeringZero to ProductionHome·About·What’s new·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

Start with a puzzle that trips up almost every new engineer. Someone tells you "Python is an interpreted language, C is compiled." You nod. Then you notice Python leaves little .pyc files lying around after you run a script, and you wonder — if it's just interpreted line by line, what are those? And why does a C program launch instantly while a Python one pauses to "warm up"? The tidy compiled-vs-interpreted split you were taught is a half-truth, and the missing piece explains startup time, portability, and half the "why is this slow?" questions you'll ever ask.

Here's the everyday analogy that makes it click. Imagine you've written a recipe in English and you need a French chef to cook it. You have three options. You could translate the whole recipe into French once, hand over the finished French document, and the chef cooks straight from it forever — fast every time, but you did all the translation up front and the French copy only works for French kitchens. That's compilation to native code. Or you could stand at the chef's shoulder and translate each line out loud as they cook — no up-front work, but you're re-translating every single time the dish is made. That's classic interpretation. Or — the clever middle path — you translate the recipe into a simple universal shorthand that any trained kitchen assistant can follow, then a local assistant reads that shorthand and cooks. That shorthand is bytecode, and the assistant is a virtual machine.

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.

The common mistake is to call Python "interpreted" and stop there — it makes people expect it to re-read the raw text of every line, which isn't what happens. Python compiles your .py to bytecode first (those .pyc files are the cached result), and only then does the CPython VM step through the bytecode. That's why the categories aren't a clean either/or but a spectrum of when translation happens: fully ahead of time (C), fully at run time (a pure interpreter), or a bit of both (bytecode plus a VM, sometimes with just-in-time compilation on top, as in the JVM and JavaScript). Why it matters to you as an engineer: the model decides the trade-offs you live with. Native compilation gives you fast startup and a self-contained binary but ties you to one CPU architecture. Interpretation gives you instant edit-run cycles but pays the translation cost on every execution. The bytecode-VM middle ground buys portability — the same .pyc runs anywhere CPython runs — at the price of shipping the interpreter alongside your code. Knowing which world you're in tells you why a Go binary drops into a container with nothing else, why a Python image needs the whole interpreter baked in, and why the JVM burns a few seconds "warming up" before it hits full speed.

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:

same source, three ways to reach the CPU Compiled (C/Rust/Go) source compiler native binary CPU runs Interpreted (classic shell) source reads & runs each line CPU runs Bytecode VM (Python/Java) source .pyc bytecode VM CPU runs "Compiled vs interpreted" is a spectrum of when translation happens. Compiled languages translate everything up front to a native binary; pure interpreters translate each line at run time; Python compiles once to .pyc bytecode, then a VM runs it — the middle path buys portability.
🗺️ How to read this diagram

Read each row left→right: the same source on the left travels a different path to the same destination — the CPU runs box on the right.

  • The top green row is compilation: a compiler turns source into a self-contained native binary once, ahead of time — fast to launch, but tied to one CPU architecture.
  • The middle amber row is pure interpretation: there is no artifact — the interpreter re-reads and runs each line every time, so edits are instant but the translation cost is paid on every run.
  • The bottom blue row is Python's model: source is compiled once to .pyc bytecode, then the VM steps through that bytecode. The extra box is why those .pyc files appear and why the interpreter must ship alongside your code.

In short: all three end at the CPU — they differ only in how much translation happens up front versus at run time, which is exactly what decides startup speed and portability.

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

Think about how you read this very sentence. Your eyes don't take in the page as one undifferentiated smear of ink — you first pick out individual words, then you group those words into a structure (subject, verb, object), and only then do you extract the meaning. You do all three so fast it feels like one step, but they're distinct: recognise the words, figure out how they fit together, understand what they say. A compiler reads your source code in exactly these same three moves, and once you see the parallel the whole pipeline stops being mysterious.

Those three moves have names. Lexing (also called scanning or tokenising) is "pick out the words" — it walks the flat character stream and groups it into tokens, the atoms of the language: this run of digits is a number, this + is a plus operator, throw away the spaces. Parsing is "figure out the structure" — it takes that flat list of tokens and discovers the nesting hidden in it, building an AST (abstract syntax tree) where 4 * 2 sits inside the addition because multiplication binds tighter. Evaluation or code generation is "extract the meaning" — either run the tree directly (an interpreter) or emit machine code from it (a compiler). The reason we split the work into stages rather than doing it all at once is the same reason you don't wire a whole house with one giant cable: each stage has one clean job and a clean handoff, so you can test, swap, or reuse any stage independently — the same lexer can feed a syntax highlighter, a formatter, and a compiler.

The mistake to avoid is imagining the compiler "understands" the raw text directly, skipping straight from "3 + 4 * 2" to the answer. It never does. Every language processor you'll meet — from a JSON parser to a full C++ compiler to the thing that reads your Terraform files — funnels text through this same assembly line: characters → tokens → tree → behaviour. 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. The diagram below traces one tiny expression, 3 + 4 * 2, through every stage — and the next three sections build each stage in real, runnable Python so it's never a black box again.

Source text 3 + 4 * 2 Lexer scan Tokens NUM PLUS… Parser structure AST tree Eval / codegen = 11
🗺️ How to read this diagram

Follow the single left→right chain of boxes: it traces one expression, 3 + 4 * 2, through every stage of the compiler front end.

  • The leftmost green box is the raw Source text (3 + 4 * 2) — just characters, with no structure yet.
  • The alternating blue and purple boxes are the stages and their outputs: the Lexer (scan) emits Tokens (NUM PLUS…), the Parser discovers structure and builds the AST (tree). Each arrow is a clean handoff to the next stage.
  • The rightmost amber box is the payoff — Eval / codegen either runs the tree or emits machine code, here producing = 11.

In short: every language processor is this same assembly line — characters → tokens → tree → behaviour.

3 · Build a lexer advanced

Here's the moment lexing suddenly feels real. The first time most people meet the word "tokenizer" is when they hit an LLM — you paste text into a model and it charges you "per token," and you learn that "tokenizer" might be three tokens while "the" is one. That token-splitting is the exact same idea you're about to build by hand: taking a stream of characters and chopping it into the meaningful units downstream code actually cares about. Build a lexer once, from scratch, and every "why did the model count my tokens that way?" question afterwards has an intuition behind it.

The analogy is scanning a printed page for words. You sweep left to right, and your brain silently does three things over and over: it skips the gaps (spaces don't carry meaning), it collects runs of letters into whole words (you don't stop after each letter), and it recognises punctuation as its own thing. A lexer does precisely that: skip whitespace, gather a run of digits into one number token, and treat each operator or parenthesis as its own token. The one subtlety — and it's the whole reason a lexer is a loop and not a one-liner — is that a single token can span several characters. The number 127 is one token made of three characters, so when the lexer sees a digit it has to keep reading ahead, gobbling digits until it hits a non-digit, before it knows the token is complete. That "look ahead and keep consuming" move is called maximal munch, and it's why the code below has an inner while loop.

The classic beginner mistake is to tokenise character by character — emitting a separate token for the 1, the 2, and the 7 of 127. Do that and the parser downstream has no idea those three belong together as one number; you've thrown away the very grouping the lexer exists to create. The other trap is forgetting the EOF token at the end: the parser needs an explicit "the input stops here" marker so it can tell "I finished cleanly" from "I ran off the edge expecting more." 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:

character stream (one box = one character) 1 2 7 · + · 4 · = whitespace maximal munch: keep reading digits tokens NUM 127 PLUS NUM 4 EOF explicit "input ends" marker Lexing groups characters into tokens. The three digits of 127 munch together into a single NUM token, whitespace is thrown away, and an EOF token is appended so the parser can tell a clean finish from running off the edge.
🗺️ How to read this diagram

Scan the top row left→right — it is the raw input as the lexer sees it, one box per character, including the whitespace dots.

  • The dashed bracket over 1 2 7 is maximal munch: when the lexer hits a digit it keeps consuming digits until it hits a non-digit, so all three characters collapse into one token.
  • The whitespace boxes (marked ·) have no arrow leaving them — they are silently discarded, not turned into tokens.
  • The bottom row is the output token list: NUM 127, PLUS, NUM 4, and the trailing amber EOF marker the parser relies on.

In short: seven characters become four tokens — multi-character numbers are munched into one, spaces vanish, and EOF caps the list.

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

Ask yourself a question you already know the answer to but have never had to write down: why is 3 + 4 * 2 equal to 11 and not 14? "Because multiplication comes first," you say — but where is that rule written? It isn't in the lexer (which just spits out tokens in the order it reads them), and you'd rather not bury it in a tangle of if statements. The place that precedence lives — cleanly, provably, once — is the grammar. A grammar is the rulebook that says which sequences of tokens count as a valid sentence in your language, and how they're allowed to nest.

The friendliest analogy is the grammar of English you absorbed as a child: a sentence is a noun phrase followed by a verb phrase; a noun phrase is an article plus a noun; and so on. Each rule can refer to other rules, and that referring-to-each-other is what lets a finite rulebook describe infinitely many sentences. Programming-language grammars work identically, and we write them in a compact notation called BNF (Backus–Naur Form). Each line is a rule of the form name ::= what it's made of. The magic trick — the thing worth slowing down for — is that precedence is encoded purely by how the rules are layered. You define expr in terms of term, and term in terms of factor. Because the tighter-binding operators (*, /) live in the rule that sits lower in the stack (term), they get resolved before the looser ones (+, -) up in expr. You never write "multiply first" anywhere — the shape of the rules says it for you.

The mistake people make is treating a grammar as mere documentation — a comment describing the language after the fact. It's the opposite: the grammar is the source of truth, and the parser is a faithful transcription of it. Get the layering wrong and your calculator will cheerfully compute 3 + 4 * 2 as 14, not because of a bug in your loop but because you encoded the wrong precedence in the rules. That direct grammar-to-code correspondence is exactly why the next section's parser has one function per grammar rule, no more and no less. 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 layers — lower = binds tighter = resolved first expr + and −  ·  loosest calls term * and /  ·  binds tighter calls factor NUMBER or ( expr )  ·  tightest / recurses up ( ) recurses to expr Precedence is the layering, not a special rule. Because term (× ÷) sits below expr (+ −) and is resolved inside it, multiplication happens first — you never write "multiply first" anywhere.
🗺️ How to read this diagram

Read the three boxes top→bottom as a stack of rules; the solid arrows show which rule calls which.

  • The top blue box expr handles the loosest operators, + and −, and delegates down to term.
  • The middle box term handles * and /. Because it is resolved inside expr, its operators bind tighter and are computed first — that nesting is the precedence.
  • The bottom green box factor is a bare number or a parenthesised expression; the dashed amber arrow shows a ( … ) looping all the way back up to expr, which is how grouping overrides the layering.

In short: stack the rules so tighter-binding operators live lower, and correct precedence falls out for free.

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

This is the section where the magic stops being magic. You've probably used eval(), or watched a calculator app get precedence right, and quietly assumed there's some deep clever algorithm behind it. There isn't. A working expression parser is astonishingly small — a handful of short functions — and once you've written one you'll never again be intimidated by the phrase "I wrote a parser." The technique is called recursive descent, and its whole secret is: transcribe the grammar into functions, one function per rule, and let them call each other exactly the way the rules refer to each other.

The analogy that makes recursion feel natural here: think of a set of Russian nesting dolls, where opening one doll reveals another inside. The expr function opens up and finds a term inside it; the term function opens up and finds a factor; and a factor might be a plain number — or it might be a parenthesised expression, which sends you all the way back out to expr again. That "back out to the top" step is the recursion, and it's what lets a fixed handful of functions handle expressions nested arbitrarily deep, like ((1 + 2) * (3 + 4)). Each function has exactly one responsibility, matching exactly one grammar line, so you can read the parser and the grammar side by side and see they're the same thing written twice.

We're going to take a shortcut that's worth naming out loud: instead of building a tree and then walking it, we'll fold evaluation into the parse — each function returns the value of what it just parsed, so by the time expr finishes, you have the answer. This is a legitimate design called a one-pass interpreter, and it's the leanest way to see the whole idea work. The mistake to watch for: a real production compiler does not do this — it builds an explicit AST first and evaluates in a separate pass, precisely so it can run optimisation, type-checking, and code generation over the same tree (Exercise 5 in the ladder walks you through that refactor). For learning, evaluating in place keeps the code short enough to hold in your head. 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.

the tree for 3 + 4 * 2 — structure encodes precedence + 3 * 4 2 4*2 = 8 first then 3 + 8 = 11 The tree shape is the precedence. Because 4 * 2 sits deeper in the tree (built by term inside expr), it evaluates first to 8; only then does the top + add 3, giving 11 — never 14.
🗺️ How to read this diagram

Read a parse tree bottom→up: leaves are computed first, and each parent combines its children.

  • The root blue node is the loosest operator, + — it is applied last because it sits at the top.
  • Its left child is the leaf 3; its right child is the whole * subtree — 4 * 2 is bundled together below the addition.
  • Follow the amber note: the deeper * node evaluates first (4*2=8), then the green note shows the root adding it to 3 for 11. Each node maps to one call of expr/term/factor returning its value.

In short: deeper in the tree = evaluated sooner, so nesting 4*2 under the + makes multiplication win — no precedence rule needed.

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

You already run a finite automaton in your head every day and never call it that. A traffic light is one: it's in a state (green), an input arrives (the timer fires), and it moves to the next state (yellow, then red, then green again). A turnstile is one: locked until you insert a coin, then unlocked until you push through. There's no notebook, no memory of how many people came before — just "what state am I in, and what does this input do to it?" That stripped-down model of computation is the finite automaton, and it turns out to be the theoretical machine hiding inside every regular expression and every lexer you've ever used.

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. The word "deterministic" just means there's no guessing: in any state, a given input symbol leads to exactly one next state, so running the machine is a boring, mechanical walk — which is precisely why it's blindingly fast and why regex engines are built on it.

The part that genuinely surprises people is the "no memory beyond its current state" constraint, because it sounds crippling and yet a DFA can do real work. The example below accepts binary strings that represent numbers divisible by 3 — and the elegant trick is that the state itself carries all the information you need: it's the running remainder mod 3. You don't store the digits you've seen; you just track "the number so far leaves remainder 0, 1, or 2," and each new bit nudges you to a new remainder via r = (r*2 + b) % 3. Three states are enough for an input of any length. The mistake to internalise here — and it's the whole punchline that Section 7 pays off — is assuming a DFA can therefore do anything. It can't: because it has no counter and no stack, it fundamentally cannot match n opening parentheses against n closing ones for arbitrary n. That limitation isn't a flaw to engineer around; it's the exact boundary line between "regular" languages and the more powerful ones above them. 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
🗺️ How to read this diagram

Read the three boxes left→right — each is one state of the DFA, and the state is the running remainder of the binary value mod 3.

  • The leftmost green box state r0 is the start and the only accept state — you end here when the value so far is divisible by 3 (remainder 0).
  • The two amber boxes state r1 and state r2 hold remainders 1 and 2; they are not accepting, so ending in either means "not divisible by 3."
  • The arrows are transitions between remainder states as each bit is read (via r = (r*2 + b) % 3); there are only three states because that remainder is the only thing the machine ever needs to remember.

In short: three states carrying the remainder mod 3 are enough to accept any-length binary string divisible by 3 — the state is the memory.

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

Section 6 left you with a nagging fact: a DFA cannot match nested parentheses. That should bother you, because parentheses are trivial for a human and trivial for the parser you wrote — so what did the parser have that the DFA didn't? The answer is a single, specific superpower: memory that can grow. And that one difference turns out to be the first rung of a ladder that organises every language and every machine, from a regex all the way up to the theoretical limit of what any computer can ever do. That ladder is the Chomsky hierarchy, and it's one of the most quietly profound ideas in all of computer science.

Here's the analogy to hold onto: it's a ladder where each rung buys the machine a bigger kind of scratch paper. At the bottom, a finite automaton has no scratch paper — only its current state, a fixed set of sticky notes it can point at. Climb one rung and you hand the machine a stack (a pushdown automaton) — now it can jot down "I owe a closing paren" and cross it off later, which is exactly enough to match nested brackets and parse arithmetic; that's why your parser, which used the call stack, could do what the lexer's DFA couldn't. Climb higher and the scratch paper gets roomier and more flexible, until at the top the machine gets an unbounded tape it can read and rewrite freely — that's the Turing machine, and it's the model of everything a computer can compute, full stop. More power, in other words, is literally more memory structure. Languages form a hierarchy by the power of machine needed to recognise them (Chomsky, 1956). More power = more memory structure:

Why should a working engineer care about a 1956 hierarchy of abstract machines? Because it tells you when you're reaching for the wrong tool — and this is the single most common mistake the hierarchy warns against. People habitually try to parse deeply nested things (HTML, source code, matched brackets) with a regular expression, and it fails in maddening, subtly-broken ways. The hierarchy explains why it can never fully work: regexes are Type-3 machines with no stack, and nested structure is a Type-2, context-free problem that fundamentally requires one. That's not a bug in your regex; it's a law of the ladder. The flip side is the humbling top of the hierarchy — the point where even a Turing machine, the most powerful model we have, hits a wall. Some questions have no algorithm at all, and knowing that boundary exists keeps you from burning weeks chasing a program that provably cannot be written.

each rung adds memory → recognises more languages Turing machine unbounded read/write tape · recursively enumerable · all computable linear-bounded automaton bounded tape · context-sensitive · aⁿbⁿcⁿ pushdown automaton a stack · context-free · balanced parens, arithmetic finite automaton no memory · regular · a*b*, ÷ by 3 The Chomsky hierarchy is a ladder of memory. A finite automaton (no memory) sits at the base; add a stack for context-free languages, a bounded tape for context-sensitive, and an unbounded tape for the all-powerful Turing machine. More memory structure = more languages recognised.
🗺️ How to read this diagram

Read the stack bottom→top: each rung is a more powerful machine, and its box is wider to show it contains everything below it.

  • The bottom amber rung is the finite automaton — no memory beyond its state — which recognises only regular languages (like the DFA you just built).
  • Climb one rung to the pushdown automaton: adding a stack is exactly what lets it match nested parentheses and parse arithmetic — the superpower your parser had that the lexer's DFA lacked.
  • The top green rung is the Turing machine with an unbounded read/write tape — the model of everything computable, and the level where undecidability appears.

In short: more memory structure = a wider box = a bigger class of languages; reaching for a regex (no stack) to parse nested structure fails because you are one rung too low.

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