G02: Language semantics, scope, and type safety
On this page
- Prerequisites and observable goals
- The central question: what does an expression mean?
- Environments turn names into values
- Worked trace with shadowing
- Typing judgments and rules
- Why type checking helps, and what it cannot prove
- Method selection and debugging
- Independent practice
- Smallest useful hints
- Complete solutions: G02
- Retention and continuation
Prerequisites and observable goals#
foundation C04, C15, C16; G01’s distinction between an implementation and a contract.
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.
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 maps variable names to values. The judgment reads “under environment , expression evaluates to value .” This is a big-step description: it relates a whole expression to its result.
For a variable, evaluate by lookup: when is bound. For addition, evaluate each operand under the same environment, obtain integers , and return . 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:
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 . The outer right side is 2, producing . The inner right side is evaluated in , so it is 3. The inner body uses and returns 3. The right operand of the outer addition still uses 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 maps names to types rather than values. Write for “ has type under .” Here is either or . The addition rule is
Comparison has the same operand requirements but returns . 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 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 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 . Prove by structural induction on 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.
Draw the AST and evaluate . Explain why a tree with addition at the root differs from one with multiplication at the root.
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.
Does if True then 1 else missing evaluate successfully under the reference API? Distinguish evaluating a branch from validating a complete expression.
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.
Explain why replacing by 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.
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 : multiply and to get 12, then add 2 to get 14. The tree describes 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.01G02.02Show solution
The conditional has an integer result
The literal 3 has integer type, so bind . The comparison takes two integers and returns Boolean, satisfying the condition requirement. Both and 0 are integers, so the whole conditional and binding have integer type. Evaluation binds , finds true, evaluates , and never evaluates the other branch’s runtime expression. Checking that branch still occurs before execution.
Back to G02.02G02.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.
G02.04Show solution
Extend the consistency relation
Assume each binding in has a runtime value of type . Typing the let expression gives and . By the induction hypothesis, evaluating under produces of type . Thus is consistent with , including when an outer binding is shadowed. Applying the hypothesis to produces a value of . 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.04G02.05Show solution
Optimization is a semantic theorem
Every well-typed 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, 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.05G02.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.
"""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))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]