Foundations to FrontierPart IV: Algorithms and systemsBezhig

C15: Languages, parsing, interpreters, and compiler structure

5 exercises, each with a complete solution. 1 figure. 1 code listing.

On this page
  1. A. Prerequisites and observable goals
  2. B. The central question: how do characters acquire meaning?
  3. C. A grammar makes precedence explicit
  4. D. Recursive descent mirrors the grammar
  5. E. Type rules and optimization need semantic assumptions
  6. F. Worked error classification
  7. G. Independent practice
  8. H. Complete solutions: C15
  9. I. Retention and readiness

A. Prerequisites and observable goals#

Before

C02 recursion; C04 logic; C05 structural induction; C09 compilation.

After

Separate syntax from meaning, parse precedence into an abstract syntax tree, evaluate a bounded expression language, and explain how an interpreter differs from a compiler.

Level: Degree core introduction; this language is intentionally smaller than Python or C++.

B. The central question: how do characters acquire meaning?#

The text 2 + 3 * 4 is a sequence of characters. A language definition tells us which character sequences are valid and how to interpret them. Lexical analysis groups characters into tokens such as number, identifier, plus, and parenthesis. Parsing organizes tokens according to a grammar. Semantic analysis and evaluation then determine what the organized expression means under an environment.

Syntax concerns form; semantics concerns meaning. The expression x + 1 can be syntactically valid while evaluation fails because x has no binding. The expression 1 / 0 can parse correctly while its requested arithmetic operation is undefined. Reporting every failure as a syntax error hides these distinctions and makes debugging harder.

A compiler translates a source representation to another representation, often through an intermediate representation before machine code. An interpreter executes the meaning of a representation directly or through a runtime. These strategies can be combined: a system may parse to bytecode, interpret it, and compile hot paths. Static versus dynamic type checking is a different distinction; compilation does not automatically imply static typing, nor does interpretation automatically imply dynamic typing.

C. A grammar makes precedence explicit#

Our language supports finite numeric literals, identifiers, unary signs, parentheses, and the four arithmetic binary operators. A useful grammar, with braces meaning repetition and brackets here avoided to prevent confusion, is

expression→term {(+∣−) term},term→atom {(∗∣/) atom},atom→number∣name∣(expression)∣−atom∣+atom.\begin{align*} \textit{expression}&\to\textit{term}\ \{(+\mid-)\ \textit{term}\},\\ \textit{term}&\to\textit{atom}\ \{(*\mid/)\ \textit{atom}\},\\ \textit{atom}&\to\textit{number}\mid\textit{name}\mid(\textit{expression})\mid-\textit{atom}\mid+\textit{atom}. \end{align*}

A term consumes multiplication and division before the surrounding expression consumes addition and subtraction. Iterative left folding makes subtraction and division left-associative: 10-3-2 means (10−3)−2(10-3)-2, not 10−(3−2)10-(3-2). Parentheses explicitly override the usual structure. Unary signs are handled before binary multiplication in this grammar.

An abstract syntax tree, or AST, keeps the semantic structure while omitting punctuation that is no longer needed. A binary node contains an operator and two subtrees. A literal node contains a value. A variable node contains a name. For 2*(x+3)-1, the root is subtraction, with multiplication on the left and literal 11 on the right.

The drawing is an exact representation of the parse structure, not a memory-address diagram. Evaluation follows dependencies from leaves toward the root: at x=4x=4, compute 4+3=74+3=7, then 2(7)=142(7)=14, then 14−1=1314-1=13.

D. Recursive descent mirrors the grammar#

Each grammar level becomes a function. expression() calls term() for its first operand and repeatedly extends the tree when it sees plus or minus. term() does the analogous work for multiplication and division. atom() handles literals, names, signs, or a parenthesized recursive expression. A token cursor records how much input has been consumed.

The parser must require end-of-input after the expression. Otherwise it might accept a valid prefix and ignore trailing garbage, such as parsing 1 2 as just 11. Every successful token consumption advances the cursor; loops consume an operator and an operand. Nested parentheses and unary signs reduce the remaining token suffix. The companion also bounds source length at 40964096 characters and tokens at 128128, making the educational recursion depth controlled by small input rather than arbitrary untrusted programs.

An environment maps identifiers to values. Evaluation of a literal returns that value; evaluation of a variable looks up its binding; evaluation of a binary node evaluates both children and applies the operator. The environment is passed explicitly instead of silently reading mutable global variables. The interpreter does not use Python’s eval; its accepted language and effects are defined by our own parser and evaluator.

E. Type rules and optimization need semantic assumptions#

A simple type judgment Γ⊢e:Number\Gamma\vdash e:\text{Number} means that under type environment Γ\Gamma, expression ee has numeric type. One arithmetic rule says that if both operands are numbers, their sum is a number. This can reject some invalid operations before execution, but it does not prove a denominator is nonzero or every resource use is bounded. Richer types, contracts, and static analyses can express more properties at additional complexity.

A compiler pipeline often contains tokenization, parsing, name and type checking, intermediate representation, optimization, code generation, and linking. Optimizations must preserve the relevant language semantics. Replacing x×0x\times0 with 00 is valid for mathematical real numbers, but not an unconditional floating-point transformation when xx may be infinity or NaN. Reordering side-effecting function calls can change behavior even when an algebraic expression appears commutative. C09’s machine representation and C03’s lifetime rules remain relevant after parsing succeeds.

A bounded interpreter is useful in game tooling, configuration, and teaching because it makes language boundaries visible. Production scripting requires much more: resource accounting, safe host interfaces, isolation, debugging facilities, versioning, and a threat model. Rejecting Python syntax alone does not establish a secure sandbox.

F. Worked error classification#

For 2*(3+4, the tokenizer succeeds but the parser expects a closing parenthesis and encounters end-of-input: syntax failure. For speed+1 with an empty environment, parsing succeeds but lookup raises a missing-name error: binding failure. For 1/(2-2), the AST is valid and denominator evaluation yields zero: arithmetic failure. For 1e999, lexical recognition finds a number-shaped token but the conversion is nonfinite and is rejected by the numeric-literal contract.

A useful test suite keeps these categories separate. It includes precedence, associativity, nested parentheses, unary signs, valid variables, trailing input, illegal characters, missing names, division by zero, nonfinite results, and size limits. Testing only a successful arithmetic expression says little about the parser’s boundary behavior.

G. Independent practice#

F foundation C standard course work T transfer S optional stretch L runnable lab or project. Attempt the complete set before opening its solutions.

C15.01FSolution

Draw or write the AST for 8-2*3. Evaluate it. Contrast it with (8-2)*3 and identify the syntactic feature that changes the tree.

C15.02CSolution

Explain why 12/3/2 evaluates to 22 in this grammar rather than 88. Trace how the term parser’s repeated left fold creates that result.

C15.03CSolution

Classify these failures: 2+); unknown+2 with no bindings; 3/0; and 1 2. Why must the parser insist on end-of-input?

C15.04TSolution

An optimizer changes f()+g() to g()+f(). Explain one condition under which this preserves a simple mathematical result and one reason it can be invalid for real programs. Separately explain why numeric type checking alone does not prove division is safe.

C15.05LabSolution

Implement the tokenizer, recursive-descent parser, and evaluator in starters/interpreter_lab.py. Preserve operator precedence, left associativity, explicit environments, finite-value checks, and input limits. Do not call eval or execute host-language code. Run the interpreter tests and predict the demo’s output before executing it.

Optional small hints

For C15.01, the lowest-precedence outer operator becomes the root. For C15.02, update the accumulated left subtree after each operator. For C15.05, test parsing independently from variable lookup and arithmetic.

H. Complete solutions: C15#

Compare the first step where your work differs. A corrected answer is not yet an independent success.

C15.01Show solution

The tree records the evaluation dependencies

The first AST is −(8,∗(2,3))-(8,*(2,3)). Multiply 22 by 33 to obtain 66, then subtract from 88, obtaining 22. The second is ∗(−(8,2),3)*(-(8,2),3), which evaluates to 6(3)=186(3)=18. Parentheses make subtraction occur inside an atom before the outer multiplication. The difference is not a different multiplication rule; it is a different expression structure.

Back to C15.01
C15.02Show solution

Left associativity is a grammar decision

The parser starts with literal 1212. It sees division and creates /(12,3)/(12,3) as its current node. It then sees another division and creates /(/(12,3),2)/(/(12,3),2). Evaluation gives 4/2=24/2=2. A right-associated tree /(12,/(3,2))/(12,/(3,2)) would give 88, but it is not the tree this grammar constructs. Writing an implementation that recurses on the entire remaining term after each slash can accidentally change the associativity.

Back to C15.02
C15.03Show solution

Do not confuse a valid prefix with a valid program

2+) fails parsing because the right operand is missing. unknown+2 parses but fails name lookup. 3/0 parses but fails arithmetic. 1 2 contains an expression-shaped prefix followed by an unexpected extra token; the complete program is invalid. Requiring the end token prevents silently ignoring data. Otherwise input validation could approve one expression while downstream code interprets additional material differently.

Back to C15.03
C15.04Show solution

Meaning includes effects and exceptional behavior

For pure functions returning exact real numbers, when both terminate normally and evaluation order has no externally visible effect, the sum is commutative. In a program, either function may mutate shared state, perform I/O, throw, or depend on the other’s effects; swapping them can change behavior. Floating-point evaluation also requires attention to the exact transformation being proposed. Knowing both operands have numeric type does not imply the denominator differs from zero, so division needs a value-level condition or runtime check. A type system’s guarantees are only the properties its rules actually establish.

Back to C15.04
C15.05Show solution

Complete bounded interpreter

The reference stores AST nodes as tuples and uses a cursor over a token list. Missing environment entries raise KeyError; arithmetic division by zero raises ZeroDivisionError; malformed syntax raises ValueError. Callers can translate these into user-facing categories without pretending they are identical. The demo evaluates to 13.013.0. The complete ordinary-lab implementation follows.

code/solutions/interpreter_lab.pyPython
"""C15.05: bounded arithmetic language, parsed without Python eval."""
import math
import re
TOKEN = re.compile(r"\s*(?:(\d+(?:\.\d*)?|\.\d+)(?:([eE][+-]?\d+))?|([A-Za-z_]\w*)|([+*/()\-]))")

def tokenize(source: str) -> list[tuple[str, object]]:
    if len(source) > 4096:
        raise ValueError("source too long")
    out, pos = [], 0
    while pos < len(source):
        if source[pos:].isspace(): break
        match = TOKEN.match(source, pos)
        if match is None:
            raise ValueError(f"unexpected character at offset {pos}")
        number, exponent, name, op = match.groups()
        if number is not None:
            value = float(number + (exponent or ""))
            if not math.isfinite(value):
                raise ValueError("nonfinite literal")
            out.append(("number", value))
        elif name is not None:
            out.append(("name", name))
        else:
            out.append((op, op))
        pos = match.end()
        if len(out) > 128:
            raise ValueError("educational token limit exceeded")
    return out + [("end", None)]

class Parser:
    def __init__(self, source: str):
        self.tokens = tokenize(source)
        self.i = 0

    def peek(self):
        return self.tokens[self.i][0]

    def take(self, kind):
        if self.peek() != kind:
            raise ValueError(f"expected {kind}, got {self.peek()}")
        token = self.tokens[self.i]
        self.i += 1
        return token[1]

    def expression(self):
        node = self.term()
        while self.peek() in {"+", "-"}:
            op = self.peek(); self.take(op)
            node = (op, node, self.term())
        return node

    def term(self):
        node = self.atom()
        while self.peek() in {"*", "/"}:
            op = self.peek(); self.take(op)
            node = (op, node, self.atom())
        return node

    def atom(self):
        if self.peek() == "-":
            self.take("-")
            return ("neg", self.atom())
        if self.peek() == "+":
            self.take("+")
            return self.atom()
        if self.peek() == "number":
            return ("num", self.take("number"))
        if self.peek() == "name":
            return ("var", self.take("name"))
        self.take("(")
        node = self.expression()
        self.take(")")
        return node

    def parse(self):
        node = self.expression()
        self.take("end")
        return node

def evaluate(node, env: dict[str, float]) -> float:
    op = node[0]
    if op == "num": return node[1]
    if op == "var":
        value = float(env[node[1]])
        if not math.isfinite(value): raise ValueError("nonfinite variable")
        return value
    if op == "neg": return -evaluate(node[1], env)
    a, b = evaluate(node[1], env), evaluate(node[2], env)
    if op == "+": result = a + b
    elif op == "-": result = a - b
    elif op == "*": result = a * b
    elif op == "/": result = a / b
    else: raise ValueError("invalid AST operator")
    if not math.isfinite(result): raise ArithmeticError("nonfinite result")
    return result

def interpret(source: str, env: dict[str, float] | None = None) -> float:
    return evaluate(Parser(source).parse(), {} if env is None else env)

if __name__ == "__main__":
    print(interpret("2*(x+3)-1", {"x": 4}))
Back to C15.05

I. Retention and readiness#

Retrieve C02 recursive progress, C05 structural induction, and C09 language versus machine semantics. Advance when you can build an AST by hand, locate the first invalid token, and explain what a type rule does not guarantee. Continue with a scoped extension such as let-bindings and lexical environments before attempting a full compiler.