** 15. Small-Step Operational Semantics ** ---------------------------------------------------------------------------- 15.1. The previous kind of formal evaluation rules are called a "big-step operational semantics" or "natural semantics". It's pretty simple, and corresponds to how people usually think informally about programs. An alternative is "small-step operational semantics" or "structural operational semantics". Big-step semantics has each judgment talking about an expression evaluating *completely* to a value; the semantics of non-terminating evaluation isn't defined. Small-step semantics has each judgment taking a single, primitive evaluation step, e.g. performing a single reduction or addition or some other basic step. It allows the semantics of non-terminating programs to be defined, since a non-terminating program still makes lots of little steps. It also allows us to state and prove some kinds of properties of programs easier than with big-step semantics. Small-step operational semantics have judgments of the form: e --> e' meaning "the closed expression e (an expression with no free variables) evaluates in one step to a new closed expression e'". If e is a value, then no step is defined, and evaluation stops at that point. Similarly, if evaluation of e would cause a run-time error, then evaluation cannot take a step. We can distinguish program execution that stops due to an error from those that stop successfully with a final result value by looking at whether the final e is a value or not. ---------------------------------------------------------------------------- 15.2. We'll use substitution to get rid of any free identifier references, eliminating the need for an environment. (This will help us do proofs about this semantics.) We've seen the notation of substitution many times before: "e[id := e']" means to return e where all *free* references to id are replaced with e'. If e' itself can have free variables, then we have to worry about those references getting "captured" by bindings enclosing the id replacement sites in e, but we'll be substituting in only closed expressions e', so we won't have to worry about this. Here is a formal definition of substitution. We'll go by cases of expressions. (intconst)[id := e'] = intconst (* and similarly for true & false *) (id)[id := e'] = e' (id')[id := e'] where id != id' = id (* only considering simple formal argument patterns *) (lambda(id:tau). e)[id := e'] = (lambda(id:tau). e) (lambda(id':tau). e)[id := e'] where id != id' = (lambda(id':tau). (e[id := e'])) (* only correct if e' doesn't have a free reference to id'! *) (* only considering simple let patterns *) (let val (id:tau) = e1 in e2)[id := e'] = (let val (id:tau) = (e1[id := e']) in e2) (let val (id':tau) = e1 in e2)[id := e'] where id != id' = (let val (id':tau) = (e1[id := e']) in (e2[id := e'])) (* only correct if e' doesn't have a free reference to id'! *) (* and similarly for identifier-binding constructs, case and rec *) (if e1 then e2 else e3)[id := e'] = (if (e1[id := e']) then (e2[id := e']) else (e3[id := e'])) (* and similarly for all other expressions with subexpressions *) ---------------------------------------------------------------------------- 15.3. Finally, here are the rules for the small-step operational semantics. (* ints and bools are values, so they don't take any evaluation steps, so there's nothing to write about them *) (* if's evaluate their test expression until it is a value, at which point they reduce to either the then or else expressions *) e1 --> e1' -------------------- (if e1 then e2 else e3) --> (if e1' then e2 else e3) -------------------- (if true then e2 else e3) --> e2 -------------------- (if false then e2 else e3) --> e3 (* lambdas evaluate to themselves, so they don't take any evaluation steps (we don't need closures, since we'll be using substitution to create closed expressions) *) (* applications evaluate the function expression to a value, then the argument expression, then substitute the argument into the body *) e1 --> e1' -------------------- (e1 e2) --> (e1' e2) e2 --> e2' -------------------- (v1 e2) --> (v1 e2') e[p := v2] --> e' (* substitution of patterns defined below *) -------------------- ((lambda p. e) v2) --> e' (* there are no identifier references, since we're only evaluating closed expressions *) (* let bindings *) e1 --> e1' -------------------- (let val p = e1 in e2) --> (let val p = e1' in e2) e2[p := v1] --> e2' -------------------- (let val p = v1 in e2) --> e2' (* Record operations *) eJ --> eJ' (0 < J <=N) -------------------- {id1=v1, ..., idI=vI, idJ=eJ, ..., idN=eN} --> {id1=v1, ..., idI=vI, idJ=eJ', ..., idN=eN} (* nothing to write for records all of whose components are values, as that's a value that's done evaluating *) e --> e' -------------------- (#idi e) --> (#idi e') -------------------- (#idi {id1=v1, ..., idN=vN}) --> vi (* Union operations *) e --> e' -------------------- [id=e] --> [id=e'] (* nothing to write for unions whose component is a value, as that's a value that's done evaluating *) e --> e' -------------------- (?idi e) --> (?idi e') -------------------- (?idi [idi=vi]) --> true idi != idj -------------------- (?idi [idj=vj]) --> false e --> e' -------------------- (%idi e) --> (%idi e') -------------------- (%idi [idi=vi]) --> vi (* we don't define any step for the run-time error of a %id operation on a union with a tag other than id *) (* case evaluates patterns until it finds one that isn't refuted, evaluating the rhs of that case *) e --> e' -------------------- (case e of p1 => e1 ... pN => eN) --> (case e' of p1 => e1 ... pN => eN) e1[p1 := v] refuted ... e(i-1)[p(i-1) := v] refuted ei[pi := v] --> ei' (0 < i <= N) -------------------- (case v of p1 => e1 ... pN => eN) --> ei' (* fold and unfold have no effect at run-time *) -------------------- (fold e as tau) --> e -------------------- (unfold e) --> e (* for recursive values, we just unfold them. we only allow recursive values whose bodies are values (v), not arbitrary expressions. *) -------------------- (rec id:tau = v) --> v[id := (rec id:tau = v)] ---------------------------------------------------------------------------- 15.4. Here are the rules for pattern-based substitution. (* bind an identifier, causing substitution *) -------------------- e[(id:tau) := v] --> e[id := v] (* do nothing for a wildcard *) -------------------- e[(_:tau) := v] --> e (* recursively bind components of the record. refute the record pattern if any of the components refuted *) e[p1 := v1] --> e1 e1[p2 := v2] --> e2 ... e(n-1)[pN := vN] --> eN -------------------- e[{id1=p1,...,idN=pN} := {id1=v1,...,idN=vN}] --> eN e[p1 := v1] --> e1 ... ei[pi := vi] refuted (0 < i <= N) -------------------- e[{id1=p1,...,idN=pN} := {id1=v1,...,idN=vN}] refuted (* for a union pattern, succeed if we've matched the right tag and the component pattern recursively matches, refute otherwise *) e[pi := vi] --> e' -------------------- e[([idi=pi]_tau) := [idi=vi]] --> e' e[pi := vi] refuted -------------------- e[([idi=pi]_tau) := [idi=vi]] refuted idi != idj -------------------- e[([idi=pi]_tau) := [idj=vj]] refuted (* patterns on values of recursive type just ignore the recursion *) e[p := v] --> e' -------------------- e[(fold p as tau) := v] --> e' e[p := v] refuted -------------------- e[(fold p as tau) := v] refuted ---------------------------------------------------------------------------- 15.5. If we extend the language to include reference expressions, as we did with big-step operational semantics, then we need to add a store. This causes our small-step semantics to go from one "machine configuration" to another, where a machine configuration is a pair of a store and a closed expression: --> We can change all the rules to track and update the stores, in a straightforward way. I'll write the rules for the 3 reference-related expressions, leaving the remaining rules as an exercise for the reader. (* ref allocations a new location value l and binds it in the store *) --> -------------------- --> l not in dom(Sigma) -------------------- --> <(Sigma,l=v), l> (* ! looks up the contents of the given location in the current store *) --> -------------------- --> (l=v) in Sigma -------------------- --> (* := updates a location in the store, returning the rhs value (unlike real ML) *) --> -------------------- --> --> -------------------- --> -------------------- --> <(Sigma',l=v), v> ---------------------------------------------------------------------------- 15.6. Applicative-order (eager) evaluation vs. normal-order (lazy) evaluation. The rules above correspond to eager evaluation as in ML: the argument to an operation is evaluated fully to a value, before the operation is performed. An alternative, found in Haskell and other lazy languages, doesn't evaluate expressions until their value is actually needed. For an application, the argument expression isn't evaluated until and unless it's needed in the function body, or maybe even later if the argument is returned from the function. We can make a small change to our small-step operational semantics to switch to lazy evaluation. All we have to do is substitute the unevaluated argument *expression* rather than the evaluated *value* when doing function application, i.e.: (* applications evaluate the function expression to a value, then substitute the argument expression into the body *) e1 --> e1' -------------------- (e1 e2) --> (e1' e2) e[p := e2] --> e' -------------------- ((lambda p. e) e2) --> e' Basically, only when we need some value in order for evaluation to proceed do we force evaluation of a subexpression. If needs its test to be evaluated to a boolean, application needs to evaluate the function expression to a particular function (as above), record & union projection & testing & casing need to evaluate the record/union enough to resolve the operation, and pattern-matching needs to evaluate enough of the argument to resolve the pattern-match. When we build a value like a record or union, we don't need to evaluate the components, so we won't. This means our values are more liberally defined than before (note the change to record and union values): v ::= intconst | true | false | (lambda p. e) | {id=e, ..., id=e} | [id=e] Here are all the rules. It turns out that there are substantially fewer of them, since we don't have to force evaluation as much as in eager evaluation. (* if's evaluate their test expression until it is a value, at which point they reduce to either the then or else expressions *) e1 --> e1' -------------------- (if e1 then e2 else e3) --> (if e1' then e2 else e3) -------------------- (if true then e2 else e3) --> e2 -------------------- (if false then e2 else e3) --> e3 (If evaluation didn't change; if's already do lazy evaluation of their then and else expressions!) (* applications evaluate the function expression to a value, then substitute the argument expression into the body *) e1 --> e1' -------------------- (e1 e2) --> (e1' e2) e[p := e2] --> e' -------------------- ((lambda p. e) e2) --> e' (* let bindings: substitute the unevaluated expression e1 for the pattern in e2 *) e2[p := e1] --> e2' -------------------- (let val p = e1 in e2) --> e2' (* Record operations *) (* nothing to write for record constructor expressions, which are values already *) e --> e' -------------------- (#idi e) --> (#idi e') (* only evaluate the argument record enough to extract the right component *) -------------------- (#idi {id1=e1, ..., idN=eN}) --> ei (* Union operations *) (* nothing to write for union constructor expressions, which are values already *) e --> e' -------------------- (?idi e) --> (?idi e') -------------------- (?idi [idi=ei]) --> true idi != idj -------------------- (?idi [idj=ej]) --> false e --> e' -------------------- (%idi e) --> (%idi e') -------------------- (%idi [idi=ei]) --> ei (* we don't define any step for the run-time error of a %id operation on a union with a tag other than id *) (* case evaluates patterns on the unevaluated expression until it finds one that isn't refuted, evaluating the rhs of that case *) e1[p1 := e] refuted ... e(i-1)[p(i-1) := e] refuted ei[pi := e] --> ei' (0 < i <= N) -------------------- (case e of p1 => e1 ... pN => eN) --> ei' (* fold and unfold have no effect at run-time *) -------------------- (fold e as tau) --> e -------------------- (unfold e) --> e (* for recursive values, we just unfold them. we'll allow arbitrary expressions. *) -------------------- (rec id:tau = e) --> e[id := (rec id:tau = e)] The pattern-matching rules (which can cause some evaluation of the value being pattern-matched): (* bind an identifier, causing substitution *) -------------------- e[(id:tau) := e'] --> e[id := e'] (* do nothing for a wildcard *) -------------------- e[(_:tau) := e'] --> e (* recursively bind components of the record (forcing evaluation to a record). refute the record pattern if any of the components refuted *) e' --> e'' -------------------- e[{id1=p1,...,idN=pN} := e'] --> e[{id1=p1,...,idN=pN} := e''] e[p1 := e1'] --> e1 e1[p2 := e2'] --> e2 ... e(i-1)[pi := ei] --> ei'[pi' := ei'] -------------------- e[{id1=p1,...,idN=pN} := {id1=e1,...,idN=eN}] --> ei'[{idi=pi',...,idN=pN} := {idi=ei',...,idN=eN}] e[p1 := e1'] --> e1 e1[p2 := e2'] --> e2 ... e(n-1)[pN := eN] --> eN -------------------- e[{id1=p1,...,idN=pN} := {id1=e1,...,idN=eN}] --> eN e[p1 := e1] --> e1 ... ei[pi := ei] refuted (0 < i <= N) -------------------- e[{id1=p1,...,idN=pN} := {id1=e1,...,idN=eN}] refuted (* for a union pattern, evaluate the rhs to a union, then succeed if we've matched the right tag and the component pattern recursively matches, refute otherwise *) e' --> e'' e[([idi=pi]_tau) := e''] --> e''' -------------------- e[([idi=pi]_tau) := e'] --> e''' e[pi := ei] --> e' -------------------- e[([idi=pi]_tau) := [idi=ei]] --> e' e[pi := ei] refuted -------------------- e[([idi=pi]_tau) := [idi=ei]] refuted idi != idj -------------------- e[([idi=pi]_tau) := [idj=ej]] refuted (* patterns on values of recursive type just ignore the recursion *) e[p := e] --> e' -------------------- e[(fold p as tau) := e] --> e' e[p := e] refuted -------------------- e[(fold p as tau) := e] refuted (Technically, this isn't a small-step semantics, since the pattern-matching rules can take many evaluation steps (in fact, an infinite number in the case of matching a pattern against the result of a function call that runs forever), not just one. We'd have to expose the internal processing steps of pattern-matching in our machine configurations, and I've not bothered to do that. If we didn't fix this, then we wouldn't be able to prove progress & soundness w.r.t. our lazy operational semantics.)