Lecture 5: A Formal Account of Dynamic Semantics ================================================ Semantics is about what a program means---an important thing to understand if you are going to use a language, and especially if you are defining it or implementing it with an interpreter or compiler! The semantics of a program determine how it behaves at run time, as well as static and dynamic checks that the program is well-formed. We often try to check as much as possible at compile time; this portion is called the static semantics of the program. Alternatively, dynamic semantics focuses on the run-time behavior of a program and any safety checks that were not done in the static semantics. One way to specify semantics is by using natural language, as was done for example in the Java Language Specification. However, although natural language specifications may be accessible to a wide audience, they are also often imprecise. This can lead to many problems, including incorrect compiler implementations. In recent years an alternative approach has begun to be used: defining program semantics formally, using mathematical definitions. These definitions are well-defined and precise, allowing language designers to reason about the design of their language, and allowing language implementers to be sure they are implementing the specification correctly. Formal language definitions were notably used in the specification of WebAssembly, a portable executable format for executing programs in web browers. Operational Semantics --------------------- Today, we'll look at defining the dynamic semantics of a small subset of JavaScript using mathematical formalisms. We'll use operational semantics, a form of language definition that describes, at a high level, how a computer executes a program. There are two major forms of operational semantics: big-step operational semantics shows how an entire program executes to produce a resulting value, while small-step operational semantics models how a program executes one step at a time. We'll cover big-step operational semantics first, as they are quite natural and also correspond to the way that interpreters are implemented. Modeling Expressions -------------------- We'll start by modeling expressions with a simple abstract syntax. Our initial expressions are made up of natural numbers (which we denote with v, as numbers are values) and arithmetic expressions like e + e. Here's the grammar: e ::= v | e + e | ... v ::= 0 | 1 | 2 | ... Big-Step Semantics for Arithmetic --------------------------------- We can define the meaning of arithmetic expressions using inference rules. We'll define a judgment e => n that can be read "expression e evaluates to n." This judgment is given meaning using inference rules like we saw in the previous class. A value such as a natural number evaluates to itself: ------ eval-val v => v How do we evaluate an addition expression? Well, the expression consists of two sub-parts. We need to first evaluate each of these to a number, yielding n1 and n2. Then we can add the resulting numbers using the mathematical definition of plus, yielding n3, which is the overall result of the expression: e1 => v1 e2 => v2 v1 + v2 = v3 ---------------------------------- eval-add e1 + e2 => v3 As we did when formalizing basic mathematics, we can evaluate concrete expressions using derivation trees. Here's one for 1 + 2 + 3: ... ------ ------ --------- 1 => 1 2 => 2 1 + 2 = 3 ... --------------------------- ------ --------- 1 + 2 => 3 3 => 3 3 + 3 = 6 ----------------------------------------------- eval-add 1 + 2 + 3 => 6 In the example derivation tree above I have not shown the derivation of mathematical equations like 1 + 2 = 3, but we could easily do so using the formalism described in the previous lecture. Variable Use and Binding ------------------------ What about expressions that have variables in them? For example, we could extend the definition of our expression language as follows: e ::= x | v | e + e | ... The meaning of a variable depends on what value is assigned to that variable when it is declared. Let's consider a simple and somewhat idealized subset of the JavaScript language which has const declarations that bind immutable variables. We'll define the following statement syntax which allows function bodies to be a sequence of const declarations followed by a return statement: S ::= const x = e; S[x] | return e; Here we use the notation S[x] to denote that the variable x is bound in the statement sequence S. That is, the scope of x is the statement S. [Note: the true semantics of JavaScript are a bit more complicated; x can only be used inside S, but it is in scope from the beginning of the containing block and thus will shadow declarations of the same variable from outside that block.] Now we can define the meaning of variables by using substitution. When a const declaration executes, the right-hand side evaluates to a value, and we can substitute that value for x wherever x appears in the subsequent statements. The statements, in which x no longer appers, are evaluated, resulting in a value that is returned from the function. We write rules for this as follows: e => v1 S[v1/x] => v2 ----------------------- eval-const const x = e; S => v2 e => v -------------- eval-return return e; => v Technically this is a different judgment form: a statement sequence S evaluates to a value v : S => v. In the eval-const rule, the notation S[v1] means substitute the value v1 for the variable x wherever it appears in statement sequence S. We can define substitution formally with rules also, first for expressions: e[v/x] ---------- x[v/x] = v x != y ---------- y[v/x] = y ------------ v1[v/x] = v1 ------------------------------------ (e1 + e2)[v/x] = (e1[v/x] + e2[v/x]) Note the side condition on the second rule: we ignore the substitution if the expression is a variable y that is different from x. Now we can define substitution for statements: S[v/x] x != y ------------------------------------------------ (const x = e; S)[v/y] = (const x = e[v/y]; S[v/y]) ------------------------------------------------ (const x = e; S)[v/x] = (const x = e; S) --------------------------------- (return e)[v/x] = (return e[v/x]) Here again in the first rule there is an interesting side condition: we only substitute for x in the subexpression e and statements S if x is different from the variable we are binding, y. Indeed, this will usually be the case. But what if the programmer writes two nested const declarations that bind the same variable? In that case the second rule applies: the substitution does not affect the statement S, because any occurrences of x in S refer to the inner binding, not the outer binding of x that is being substituted. Interestingly, in most other languages we would substitute [n/x]e in the second rule above...but in JavaScript we do not do this because x is in scope even in e, although x cannot actually be used until after e evaluates to a value. Exercise: write a derivation for the execution of the following statement sequence: const x = 1 + 2; return x+3; Functions --------- Let's add functions and function calls to our little JavaScript subset. We add one-argument functions as a new form of value, written as function (x) { S[x] }, where S[x] again indicates that the variable x is in scope within the statement sequence S. The function call expression e1(e2) invokes the function e1, passing e2 as the argument. Both e1 and e2 may themselves be expressions that are evaluated before the call is executed. The syntax is thus: e ::= ... | e(e) v ::= ... | function (x) { S[x] } Incidentally, the expression language with variables, functions, and function calls is known as the lambda calculus, and has been studied as one of the two primary theoretical foundations of computation (the other is the Turing Machine). Exercise: Using the principles we've seen above, can you define a rule for the execution of a call, e1(e2)? Try to do so on your own before reading ahead. We can define the operational semantics of functions and function calls using the principles we have already learned. Functions are values and evaluate to themselves, using the rule we already defined. To evaluate a function call, we first evaluate the function and argument expressions to values. Evaluation only proceeds if the function expression evaluated to a function value. In that case, then what we need to do next is evaluate the function body...but as with the const statement, we substitute any occurrences of the function parameter x with the actual argument value. The result of evaluating the function body is the final result of the call: e1 => (function (x) { S }) e2 => v2 S[v2/x] => v ---------------------------------------------------- eval-call e1(e2) => v Exercise: Show a derivation tree evaluating the expression: (function (x) { return x + 1; })(2 + 3) What happens if there is an error in evaluation, such as if we make a function call but the function expression evaluates to a number rather than a function? For example, the expression 1(2) cannot be evaluated with the rules above. We say that such expressions are stuck: there is no rule that applies. In practice, stuck expressions result in execution errors reported to the user. If we wanted, we could formalize error reporting with an explicit error value and rules of the form: e1 => v v != function ... --------------------------- eval-call-error e1(e2) => error In our simple language, there would also be a rule for the error that occurs when one of the arguments of an arithmetic operator evaluates to a function, rather than a number. A theorem: Evaluation is Deterministic -------------------------------------- Based on our semantics, we can prove theorems about execution. For example, we'd like to show that our rules are deterministic. This theorem is fairly easy, though we need to state it for expressions and statements and prove it with a new technique, mutual induction. In mutual induction, we simultaneously prove two related theorems, but we must ensure that any uses of the induction hypothesis are on subderivations, typically when one judgment relies on another. The proof shows the technique: Theorem [Evaluation is Deterministic]: If e => v1 and e => v2, then v1 = v2 and if S => v3 and S => v4 then v3 = v4. Proof: by mutual induction on the derivation of e => v1 and S => v3. We case analyze on the rule used: Case ------ eval-val v1 => v1 Then the result is immediate because eval-val is the only rule that applies, and it always yields the same result. Case e1 => (function (x) { S }) e2 => v3 S[v3/x] => v1 ---------------------------------------------------- eval-call e1(e2) => v1 e is of the form e1(e2), and only one rule applies: eval-call. Thus by inversion (the principle that if there is only one rule that applies, it must have been the one used) the derivation e => v2 must have used this same rule. By the induction hypothesis, we know that e1 => function (x) { S } in the second derivation. Also by the induction hypothesis, we know that e2 => v3 in the second derivation. We use a lemma [not shown] to prove that the substitution operation S[v3/x] is also deterministic. Then, we can use the induction hypothesis for statements (this is why we need mutual induction), which we can do because S[v3/x] => v1 is a subderivation of the current derivation e => v1 we are doing induction over. The induction hypothesis shows that S[v3/x] => v1 in the second derivation as well. Thus e1(e2) => v1 in the second derivation, ending the case. The other cases are similar and are left to the reader. The complete proof includes cases for statements as well as expressions, due to the mutual induction. Environment Semantics: Modeling an Interpreter ---------------------------------------------- The semantics above has the virtue of being relatively simple. However, we would like our dynamic semantics to inform how we write interpreters, and an operational semantics based on variable substitution isn't very realistic in this respect. A real interpreter would not go through the program AST every time a variable binding is executed to substitute a value for that variable, because this would require a costly tree traversal on every binding operation. Instead, an interpreter typically maintains an environment mapping variables to values. We can write an environmental operational semantics that more closely reflects what interpreters do. This adds a small amount of complexity to our formalism, but also provides insight into how variables are handled in language implementations. In order to define the semantics, we'll define an environment E mapping variables to values: E ::= * | E, x=v Here, we define E as a grammar; E is either empty (denoted *) or it extends another environment with a mapping from x to v. We also extend our expression evaluation judgment to include an environment: E |- e => v read "in the context of environment E, the expression e evaluates to value v." Here, the turnstile symbol |- denotes that this is a hypothetical judgment: the way that e evalutes depends on some background information provided in E. The rules for expressions are mostly similar, but we will now define an evaluation rule for variables that looks up a variable-value binding in the environment: x=v in E ----------- eval-var E |- x => v ----------- eval-val E |- v => v E |- e1 => v1 E |- e2 => v2 v1 + v2 = v3 -------------------------------------------- eval-add E |- e1 + e2 => v3 Notice that in the last rule, we don't use E in the premise that defines the semantics of +, since that judgment relates to concrete numbers and not variables. We've left out function calls for now as they will add an additional complication. To see how we add information to the environment, consider the rule for variable definitions (const or let--we'll use a let-based expression here for simplicity): instead of using substition, we add the new value to the environment: E |- e1 => v1 E, x=v1 |- e2 => v2 --------------------------------- eval-let E |- let x = e1 in e2 => v2 An interesting question is, what happens in the eval-let rule if E already has a binding of the variable x? In that case, we add a new binding to the end of the list. But we always want to use the most recent binding for x. So the meaning of the premise x=v in E, from the eval-var rule above, is that we search E starting from the last variable-value binding and going backward, returning only the most recent binding of x that is in the environment. Functions --------- Let's consider the following plausible (but incorrect) rule for functions (We'll consider JavaScript-style arrow functions here): E |- e1 => (x => e) E |- e2 => v2 E, x=v2 |- e => v ------------------------------------------------------- eval-call-broken E |- e1(e2) => v Exercise: before reading below, can you figure out what is wrong with this rule? This rule implements dynamic scoping, and would express the semantics of early Lisp implementations. However, it is wrong for JavaScript, which implements static scoping. The reason is that in the third premise, the environment of the caller is used, extended with only the function parameter. If the body S of the function contains other variables, they might be captured by the environment E, which might have more recent definitions for the variable that did not come from an enclosing scope of the function. Exercise: show the derivation of this expression using the rule above. What would the result be under static scoping? const f = function (g) { const x = 2; return g(0);} const x = 1; return f( function (y) { return x; }) In this example, the use of the variable x in the function on the last line is captured by the declaration of x in the function f. But we want x in the last line to refer to the definition const x = 1, following the rule for static scoping. To implement static scoping in our rules, we must implement closures. To do so, we'll take functions out of the category of values and define them as a kind of expression: e ::= ... | function (x) { S[x] } We'll instead need closures as one of our values. Closures are a pair of an environment and a function: v ::= 0 | 1 | 2 | ... | We need a rule to create closures when we evaluate a function expression. The rule simply pairs the environment from the current scope with the function: ---------------------------- eval-fn E |- (x => e) => e> Finally, we will unpack the closure in the function call rule: E |- e1 => e> E |- e2 => v2 E', x=v2 |- e => v ------------------------------------------------------------------------ eval-call E |- e1(e2) => v Note that when evaluating the body of the function, the environment E' from the closure is used. Exercise: Verify that the code from the last exercise correctly returns 1 using the rules above.