Foundations to FrontierPart IX: The graduate coreBezhig

G02: Language semantics, scope, and type safety

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

On this page
  1. Prerequisites and observable goals
  2. The central question: what does an expression mean?
  3. Environments turn names into values
  4. Worked trace with shadowing
  5. Typing judgments and rules
  6. Why type checking helps, and what it cannot prove
  7. Method selection and debugging
  8. Independent practice
  9. Smallest useful hints
  10. Complete solutions: G02
  11. Retention and continuation

Prerequisites and observable goals#

Before

foundation C04, C15, C16; G01’s distinction between an implementation and a contract.

After

Define the evaluation and typing rules of a small language, trace lexical scope, and explain progress/preservation without mistaking type safety for all-purpose correctness.

The central question: what does an expression mean?#

A string of characters is syntax. A parsed tree identifies structure. A semantics specifies what that structure does. A type system rejects selected structural mistakes before evaluation. These are separate layers: a parser can accept True+2 syntactically even when the language’s type rules reject it.

We use a deliberately small, pure expression language. Its values are mathematical integers and Booleans. Its constructs are addition, multiplication, integer comparison, conditional expressions, variables, and a local binding. There are no loops, recursion, mutable references, division, I/O, or host-language calls. These restrictions make some proofs and optimizations possible that would be wrong in a larger language.

e::=n∣true∣false∣x∣e+e∣e∗e∣e<e∣if e then e else e∣let x=e in e.\begin{aligned} e ::= {}& n\mid\mathsf{true}\mid\mathsf{false}\mid x\mid e+e\mid e*e\mid e<e\\ &\mid\mathsf{if}\ e\ \mathsf{then}\ e\ \mathsf{else}\ e \mid\mathsf{let}\ x=e\ \mathsf{in}\ e. \end{aligned}

The symbol ::=::= defines grammatical alternatives; the vertical bars mean “or.” A grammar is not yet an execution rule. Parentheses or the abstract syntax tree remove ambiguity about grouping.

Environments turn names into values#

An environment ρ\rho maps variable names to values. The judgment ρ⊢e⇓v\rho\vdash e\Downarrow v reads “under environment ρ\rho, expression ee evaluates to value vv.” This is a big-step description: it relates a whole expression to its result.

For a variable, evaluate by lookup: ρ⊢x⇓ρ(x)\rho\vdash x\Downarrow\rho(x) when xx is bound. For addition, evaluate each operand under the same environment, obtain integers a,ba,b, and return a+ba+b. For a conditional, first evaluate its condition to a Boolean, then evaluate only the selected branch. For a binding, evaluate its right side in the outer environment, create an environment extended by the new name, and evaluate the body there:

ρ⊢e1⇓v1ρ[x↦v1]⊢e2⇓v2ρ⊢let x=e1 in e2⇓v2.\frac{\rho\vdash e_1\Downarrow v_1\qquad \rho[x\mapsto v_1]\vdash e_2\Downarrow v_2} {\rho\vdash\mathsf{let}\ x=e_1\ \mathsf{in}\ e_2\Downarrow v_2}.

A rule has premises above the line and a conclusion below it. It is a logical inference, not division. The updated environment shadows an older binding in that scope without mutating its caller’s environment.

Worked trace with shadowing#

Evaluate let x=2 in (let x=x+1 in x)+x\mathsf{let}\ x=2\ \mathsf{in}\ (\mathsf{let}\ x=x+1\ \mathsf{in}\ x)+x. The outer right side is 2, producing ρ1={x↦2}\rho_1=\{x\mapsto2\}. The inner right side x+1x+1 is evaluated in ρ1\rho_1, so it is 3. The inner body uses ρ2=ρ1[x↦3]\rho_2=\rho_1[x\mapsto3] and returns 3. The right operand of the outer addition still uses ρ1\rho_1 and returns 2. The answer is 5.

The figure is a scope diagram, not a required memory layout. An implementation can use parent-linked environments, a stack of frames, or compiled local slots. Correctness requires the same lookup behavior, not the same physical diagram.

Typing judgments and rules#

A type environment Γ\Gamma maps names to types rather than values. Write Γ⊢e:τ\Gamma\vdash e:\tau for “ee has type τ\tau under Γ\Gamma.” Here τ\tau is either Int\mathsf{Int} or Bool\mathsf{Bool}. The addition rule is

Γ⊢e1:IntΓ⊢e2:IntΓ⊢e1+e2:Int.\frac{\Gamma\vdash e_1:\mathsf{Int}\quad\Gamma\vdash e_2:\mathsf{Int}} {\Gamma\vdash e_1+e_2:\mathsf{Int}}.

Comparison has the same operand requirements but returns Bool\mathsf{Bool}. A conditional requires a Boolean condition and the same type in both branches. A local binding infers the right-side type under the outer environment and extends the type environment for the body.

Both branches are checked even though only one is evaluated. Thus if true then 7 else false\mathsf{if}\ \mathsf{true}\ \mathsf{then}\ 7\ \mathsf{else}\ \mathsf{false} is rejected by this type system. Another language could admit union types, but adding them would require new typing and runtime rules. Do not assume our simplification is a universal language law.

Python itself permits some operations between Booleans and integers. Our language intentionally does not. The reference uses exact runtime type comparisons so a Boolean literal is not accidentally classified as an integer. An interpreter written in a host language must implement the guest language’s rules rather than silently inheriting every host behavior.

Why type checking helps, and what it cannot prove#

A small-step relation e→e′e\to e' describes one evaluation step. Two central proof ideas are progress and preservation. Progress says a well-typed closed expression is a value or has an allowed next step. Preservation says an allowed step of a well-typed expression preserves its type. Together they exclude a particular kind of stuck state, such as trying to add a Boolean and an integer. Their precise forms depend on the language and evaluation rules. [G-TYPES]

For addition, progress follows by cases: if the left operand can step, step it; if it is an integer value and the right can step, step the right; if both are integer values, add them. The typing rule excludes a Boolean operand. For a variable, closedness matters: an unbound name has no value lookup. For let, the corresponding substitution or environment-consistency lemma connects a value of the inferred type to its binding in the body.

An approachable big-step argument fits our implementation directly. Assume every name’s runtime value has the type recorded in Γ\Gamma. Prove by structural induction on ee that successful evaluation of a well-typed expression produces a value of its inferred type. Literals and variables follow immediately. Arithmetic/comparison use the induction hypothesis for operands. For a conditional, its condition is Boolean and the selected branch has the common branch type. For let, the right side has its inferred type, so extending both environments consistently lets the induction hypothesis apply to the body.

Our finite AST and absence of recursive calls also yield termination in the ideal unbounded-resource model: evaluation descends into proper subtrees. This is not a consequence of type safety alone. A typed language can have nonterminating recursion. Nor does type safety prove that an integer expression computes the business quantity you intended, that a model’s prediction is fair, or that physical memory cannot be exhausted.

Method selection and debugging#

When a program is rejected, ask which layer rejects it. A missing parenthesis is parsing. An unknown name is binding/scope. Boolean addition is typing. A wrong but well-typed arithmetic formula is a specification or implementation error. Reproducing the failure at the smallest AST separates these categories.

For example, an interpreter that mutates scope[name] in place may return the right answer for non-shadowing examples yet fail nested binding. A checker that only inspects the selected constant branch may hide an invalid unchosen branch, changing the language’s acceptance rules. A checker that uses isinstance(True,int) inherits a host quirk. Each bug needs a test drawn from the appropriate rule, not only more random arithmetic inputs.

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.

G02.01FSolution

Draw the AST and evaluate 2+3∗42+3*4. Explain why a tree with addition at the root differs from one with multiplication at the root.

G02.02CSolution

Infer the type and value of let x=3 in if x<4 then x*x else 0. Give the required operand and branch types.

G02.03CSolution

Does if True then 1 else missing evaluate successfully under the reference API? Distinguish evaluating a branch from validating a complete expression.

G02.04TSolution

Prove the preservation-of-result-type claim for the let case of the environment-based evaluator. State the consistency assumption relating type and value environments.

G02.05TSolution

Explain why replacing 0∗e0*e by 00 preserves results in this pure terminating integer language but can change behavior in a language with side effects, exceptions, or divergence. Give one counterexample for a larger language.

G02.06LSolution

Implement infer and evaluate in starters/language.py. Follow the AST forms in its header. Enforce exact integer/Boolean distinctions, lexical scope, same-type conditional branches, unknown-name errors, and a depth bound of 48. Do not use Python eval. Test shadowing, wrong types, bad arity, and untouched caller environments.

Smallest useful hints#

For .04, evaluate the right side before extending the environment. For .05, distinguish “same returned value” from “same observable behavior.” For .06, write separate environments for types and values; infer before executing the selected branch.

Complete solutions: G02#

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

G02.01Show solution

Structure determines the operation order

The conventional tree is (+,2,(∗,3,4))(+,2,(*,3,4)): multiply 33 and 44 to get 12, then add 2 to get 14. The tree (∗,(+,2,3),4)(*,(+,2,3),4) describes (2+3)∗4(2+3)*4 and gives 20. Precedence is a parsing convention that chooses a tree from text; evaluation follows the tree. Parentheses can request a different tree explicitly.

Back to G02.01
G02.02Show solution

The conditional has an integer result

The literal 3 has integer type, so bind x:Intx:\mathsf{Int}. The comparison takes two integers and returns Boolean, satisfying the condition requirement. Both x∗xx*x and 0 are integers, so the whole conditional and binding have integer type. Evaluation binds x=3x=3, finds 3<43<4 true, evaluates 3∗3=93*3=9, and never evaluates the other branch’s runtime expression. Checking that branch still occurs before execution.

Back to G02.02
G02.03Show solution

Static validation precedes branch selection

No. The reference calls infer on the whole expression first. Lookup for missing fails, even though an evaluator without this validation could select the true branch and return 1. This is an intentional acceptance rule. The result is not evidence that both runtime branches are evaluated; it is evidence that both branches must be well formed and well typed.

Back to G02.03
G02.04Show solution

Extend the consistency relation

Assume each binding x:τx:\tau in Γ\Gamma has a runtime value ρ(x)\rho(x) of type τ\tau. Typing the let expression gives Γ⊢e1:τ1\Gamma\vdash e_1:\tau_1 and Γ[x↦τ1]⊢e2:τ2\Gamma[x\mapsto\tau_1]\vdash e_2:\tau_2. By the induction hypothesis, evaluating e1e_1 under ρ\rho produces v1v_1 of type τ1\tau_1. Thus ρ[x↦v1]\rho[x\mapsto v_1] is consistent with Γ[x↦τ1]\Gamma[x\mapsto\tau_1], including when an outer binding is shadowed. Applying the hypothesis to e2e_2 produces a value of τ2\tau_2. The let expression returns exactly that value. The proof would fail if the right side were evaluated under an uninitialized new binding.

Back to G02.04
G02.05Show solution

Optimization is a semantic theorem

Every well-typed ee here terminates ideally with an integer; it has no observable effect beyond that value. Multiplying it by zero returns zero. In a language with eager evaluation and division, 0∗(1/0)0*(1/0) raises an exception while literal zero does not. A call that writes a log or loops forever gives analogous counterexamples. The optimizer in GP1 type-checks before rewriting, so it does not make an invalid source program valid by deleting the offending branch or operand. Resource exhaustion remains outside the ideal semantic equivalence claim.

Back to G02.05
G02.06Show solution

Complete reference implementation

The first pass returns a type or raises a precise category of error. The second pass evaluates under a value environment whose contents are validated, copying it on extension. This is clear rather than optimized: checking and execution traverse overlapping structure, and dictionary copying has a cost. GP1 later separates these stages into compiler artifacts.

graduate_code/solutions/language.pyPython
"""G02: typed, pure, finite expressions. No eval or host-language calls.
AST: int, bool, variable str, ('+',a,b), ('*',a,b), ('<',a,b),
('if',condition,then,otherwise), ('let',name,value,body).
"""


def infer(expr, env=None, depth=0):
    env = {} if env is None else env
    if depth > 48:
        raise ValueError("expression depth exceeds 48")
    if type(expr) is bool:
        return "bool"
    if type(expr) is int:
        return "int"
    if type(expr) is str:
        if expr not in env:
            raise ValueError("unbound name: " + expr)
        return env[expr]
    if type(expr) is not tuple or not expr:
        raise ValueError("malformed expression")
    tag = expr[0]
    if tag in ("+", "*", "<") and len(expr) == 3:
        if any(infer(x, env, depth + 1) != "int" for x in expr[1:]):
            raise TypeError("arithmetic/comparison requires integers")
        return "bool" if tag == "<" else "int"
    if tag == "if" and len(expr) == 4:
        if infer(expr[1], env, depth + 1) != "bool":
            raise TypeError("condition must be Boolean")
        left = infer(expr[2], env, depth + 1)
        right = infer(expr[3], env, depth + 1)
        if left != right:
            raise TypeError("branches must have identical types")
        return left
    if tag == "let" and len(expr) == 4 and type(expr[1]) is str:
        typ = infer(expr[2], env, depth + 1)
        child = dict(env)
        child[expr[1]] = typ
        return infer(expr[3], child, depth + 1)
    raise ValueError("unknown node or wrong arity")


def evaluate(expr, env=None):
    values = {} if env is None else dict(env)
    if any(type(x) not in (int, bool) for x in values.values()):
        raise TypeError("environment values must be integers or Booleans")
    infer(expr, {k: "bool" if type(v) is bool else "int"
                 for k, v in values.items()})

    def go(e, scope):
        if type(e) in (int, bool):
            return e
        if type(e) is str:
            return scope[e]
        tag = e[0]
        if tag == "let":
            value = go(e[2], scope)  # value uses the outer scope
            return go(e[3], {**scope, e[1]: value})
        if tag == "if":
            return go(e[2] if go(e[1], scope) else e[3], scope)
        a, b = go(e[1], scope), go(e[2], scope)
        if tag == "+":
            return a + b
        if tag == "*":
            return a * b
        return a < b

    return go(expr, values)


if __name__ == "__main__":
    e = ("let", "x", 3, ("if", ("<", "x", 4), ("*", "x", 2), 0))
    print(infer(e), evaluate(e))
Back to G02.06

Retention and continuation#

From memory, distinguish a string, an AST, a type, an environment, and a value. Demonstrate a scope bug with a minimal shadowing program. You are ready for GP1 after you can explain how a conditional may have one runtime branch but two checked branches. Continue by adding functions and closures with an explicit environment-capture rule; that is an unassigned extension requiring new type and evaluation tests, not a capability claimed for this reference. [G-TYPES]