Power
OCaml Operational Semantics
An OCaml expression can do several things:
- It can return a value
- It can loop forever
- It can print out a string to standard output or read one in from standard input
- It can read and write to files
- It can raise an exception
- It can ...
A Few Observations About Variables
Consider the following two expressions:let x = 30 in let dave = 30 in let y = 12 in let dan = 12 in x + y dave + danIs the resulting expression any different? No! Both expressions evaluate to 42. Now consider the following two expressions:
let x = 30 in let x = 30 in let y = 12 in let x = 12 in x + y x + xAre they any different? Yes! The expression on the left evaluates to 42 as before. The expression on the right evaluates to 24. One more example:
let x = 30 in let y = 30 in let x = 12 in let x = 12 in x + x x + xDifferent? No. Both evaluate to 24.
Static Scoping
OCaml is a lexically scoped, also known as statically scoped language. This means that the value associated with a variable (say x) is given by the closest enclosing let (or function variable) declaration of that same variable. An xxample:
let x = 30 in <---- variable x is bound to 30 let y = 15 in let x = 12 in <---- variable x is bound to 12 -------- let y = -4 in | let z = 16 in | x + y + z -- the x on this line refers to 12 ->---
The scope of a variable (say x) is all those places in the program text where you can refer to it. Hence, as mentioned above, the scope of any variable introduced (or bound) by a let expression is the body of that let expression, up to the point where another variable with the same name is introduced. In the example above the scope of the first variable named x (bound to 30) extends until the second variable x (bound to 12) is introduced. Here is another example.
let x = 30 in let f (y:int) : int = y + x in let x = 12 in f x (* 4 *)
What is the result of evaluating this expression?
Because OCaml is statically scoped, the value of x within the body of f never changes. It remains 30 (the value assigned to it in the nearest enclosing let declaration). Hence, the function f is really just a function that always adds 30 to its argument. On the line marked 4, we see that f is applied to an argument (also named x). The value of that argument is 12 (because the nearest enclosing definition of x, on the line above, binds x to 12). Hence, the result of the expression is 12 plus 30, which is 42.
How should you think about the two definitions of x in the code above? Think of the two definitions as defining completely different variables that just happen to have the same name.
Of course, it is not good programming style to reuse the same name for two different variables, especially non-descript names like x. A key principle of programming in ML is that these local names do matter: You can pick any name you want and it does not change the meaning of your program. However, when you change a definition of a name, you must change all the uses of the name consistently. For example, we can convert this:
let x = 30 in let f (y:int) : int = y + x in let x = 12 in f xto this:
let z = 30 in let f (y:int) : int = y + z in let x = 12 in f xNotice that we change the x to z at the point of definition and each of the uses also change from x to z. We can also change the above to:
let z = 30 in let x (y:int) : int = y + z in let f = 12 in x f
One more example. We can change this:
let x = 2 in let x = (let x = x + x in x + 7) in x * 3to this:
let a = 2 in let c = (let b = a + a in b + 7) in c * 3The second piece of code uses a different variable name for each let declaration. Hence, you can clearly see ML's scoping rules.
In general, the act of changing one expression in to another expression that differs only because of a consistent renaming of variables is called alpha-conversion. The two expressions are also called alpha-equivalent expressions.
Exercise: Are the following two definitions the same? Why or why not? If you don't know, type the definitions in to ML toplevel environment (or a file and compile) and see what happens. What do these examples imply about the scope of certain variables?let x = 3 in let x = 3 in let x y = x + y in let rec x y = x + y in x 3 x 3
Free and Bound Variables
Consider the following expression:
let z = 30 in (* 1 *) let y = z + x in (* 2 *) yIn line 2, the variable x is called a free variable within the expression. It is free because there is no enclosing let expression (or function declaration) that defines it. In contrast, on line 2, z is called a bound variable. It is bound because it is within the scope of a definition (namely the definition on line 1). In the following expression, x is a bound variable. We might call line 0 the binding site of x.
let x = 17 in (* 0 *) let z = 30 in (* 1 *) let y = z + x in (* 2 *) y
Substitution
Recall one of our earlier example programs:
let z = 30 in let f (y:int) : int = y + z in let x = 12 in f xWhen describing the function f, we said that f was just a function that always added 30 to its argument. Why did we say that? We said that because we understood that we could evaluate a let expression simply by substituting the computed value on the right-hand side of the = for the variable, everywhere within the scope of that variable. In other words, the expression above is equivalent to the following as we are allowed to substitute the value 30 for all the uses of z.
let f (y:int) : int = y + 30 in let x = 12 in f xMore generally, given any value v and any expression e, we use the notation:
e[v/x]to refer to the expression that results from substituting value v for all of the free occurrences of x within the expression e. For example, if e is the following expression:
let y = x + x in x * ythen the result of the substitution e[17/x] is:
let y = 17 + 17 in 17 * yAs another example, suppose e is as follows:
let y = x + x in let x = 9 in x * ythen the result of the substitution of e[2/x] is:
let y = 2 + 2 in let x = 9 in x * yWe must be careful to only substitute for the free occurrences of a value, not the bound ones.
Operational Semantics
Having explained what free and bound variables are and having defined substitution, we are now in a position to define an operational semantics for the core expressions that make up ML. The operational semantics explains how to evaluate an ML expression step by step. If you want to reason about what your ML programs do, you will use this operational semantics.
When we want to say that one expression
exp1 --> exp2
The arrow above is usually read aloud as "steps to". So the above is usually
read aloud as "exp1 steps to exp2".
(Please note that the arrow -->
is not
a part of any program -- it is notation that we use to talk about
programs.) To talk about several execution
steps in a row, we might write:
exp1 --> exp2
--> exp3
--> exp4
And if we don't care about the intermediate steps but just want to talk about 0 or more steps all at once,
we write:
exp1 -->* exp4
A first example stating that 2 + 3
evaluates to 5
:
3 + 2 --> 5
Some more examples:
"hello" ^ " world" --> "hello world"
("rock" ^ " and ") ^ "roll" --> "rock and " ^ "roll"
--> "rock and roll"
(2 - 3) + (4 - 5) --> (-1) + (4 - 5) (* 3 *)
--> (-1) + (-1)
--> -2
(5 * 6) + (4 - 5) -->* 29
In the examples above, we evaluated expressions from left to right.
For example on the line marked 3, we evaluated the expression
(2 - 3) + (4 - 5)
by first evaluating the subexpression
to the left of the +
and then evaluating the subexpression
to the right of the plus. Once both subexpressions are evaluated to the
point of being values, we evaluate the +
operation itself.
Finally, a little bit longer example involving an if expression. (Recall that an if expression is just like any other expression in the language and can be nested inside other expressions). Once again, take notice of the left-to-right evaluation order.
(if 1 < 2 then ("yes" ^ " ") else ("no" ^ " ")) ^ "way!" --> (if true then ("yes" ^ " ") else ("no" ^ " ")) ^ "way!" --> ("yes" ^ " ") ^ "way!" --> ("yes ") ^ "way!" --> "yes way!"
Operational Semantics for Let Declarations
Evaluation of let expressions are slightly more interesting
than the other expressions we have seen so far: This is where substitution
comes in. Given a declaration let var = exp1 in exp2
,
we simply evaluate exp1
until we get a value. Then we substitute that
value for the variable in exp2
and continue evaluating.
Here is an example.
let x = 2 + 5 in let y = x + x in y * 3 --> let x = 7 in let y = x + x in y * 3 --> let y = 7 + 7 in y * 3 --> let y = 14 in y * 3 --> 14 * 3 --> 42
And another example:
let x = (let x = 2 - 1 in x + x) in x * 21 --> let x = (let x = 1 in x + x) in x * 21 --> let x = (1 + 1) in x * 21 --> let x = 2 in x * 21 --> 2 * 21 --> 42
Operational Semantics for Function Declarations
Functions are evaluated much the same way as let declaration in that they use substitution. In general, given a function application expression with the form:
exp1 exp2we evaluate it by first evaluating
exp1
until we get a function value f
.
Then we evaluate exp2
until we get an argument value v
. Then
we substitute v
for f
's parameter in to the body of f
.
For example, here is a declaration of the function add:
let add1 (x:int) : int = x + 1;;And here is how we evaluate an expression involving add:
add1 3 + add1 4 --> (3 + 1) + add1 4 --> 4 + add1 4 --> 4 + (4 + 1) --> 4 + 5 --> 9Notice that we evaluate the arguments of
+
from left to right.
When it comes to evaluating the application of add1 3
,
we do so by substituting 3 for the parameter x
in the body
of the add function. In other words, the body is x + 1
and when we substitute 3 for x in that body, we obtain 3 + 1
.
And evaluation continues.
Multi-argument functions work the same way as multi-argument functions are just abbreviations for single argument functions. For example, consider the following addition function.
let add (x:int) (y:int) = x + y;;It can be thought of as an abbreviation for the following:
let add (x:int) = (fun y -> x + y);;Which is also equal to the following:
let add = (fun x -> (fun y -> x + y));;Now consider what happens when we evaluate the following expression:
add 2 3 --> (fun x -> (fun y -> x + y)) 2 3 (* 2 *) --> (fun y -> 2 + y) 3 (* 3 *) --> 2 + 3 (* 4 *) --> 5On line 2, we substituted the definition of add in place of the variable add. Then on lines 3 and 4, we substituted arguments for parameters one at a time. Typically, we won't be so verbose when we work out how to evaluate applications of multi-argument functions and we will simply substitute all of the arguments at once (unless the function is only partially applied, and we can't):
add 2 3 --> 2 + 3 (* 2 *) --> 5Even though we might have considered line (* 2 *) multiple steps and used the
-->*
notation, by convention, we will only
consider it one step from now on.
Intuitively, the last example showed what happens when we try to evaluate functions that return other functions as results. We can also evaluate functions that take other functions as arguments. For example, below we define the function pipe that pipes a value in to another function.
let pipe (x:'a) (f:'a -> 'b) : 'b = f x;;Watch how we evaluate an expression that uses such a function:
pipe 3 (fun x -> 2 * x) --> (fun x -> 2 * x) 3 (* 2 *) --> 2 * 3 --> 6On the line marked 2, we have substituted the function value
(fun x -> 2 * x)
for the function parameter f
in the definition of pipe
.
Finally, here is an example involving a recursive function.
let rec fact (x:int) : int = if x <= 0 then 0 else (fact (x-1)) + x ;;Watch what happens when we evaluate the following expression.
fact 2 --> if 2 <= 0 then 0 else (fact (2-1)) + 2 --> if false then 0 else (fact (2-1)) + 2 --> (fact (2-1)) + 2 --> (fact 1) + 2 --> (if 1 <= 0 then 0 else (fact (1-1)) + 1) + 2 --> (if false then 0 else (fact (1-1)) + 1) + 2 --> ((fact (1-1)) + 1) + 2 --> ((fact 0) + 1) + 2 --> ((if 0 <= 0 then 0 else (fact (0-1) + 0)) + 1) + 2 --> ((if true then 0 else (fact (0-1) + 0)) + 1) + 2 --> ((0) + 1) + 2 --> 1 + 2 --> 3Ok, so that gets pretty verbose very quickly. However, it is important to understand exactly how the language works. If you understand this operational semantics, then you can use it to analyze your ML program step by step if it does something you didn't expect. Of course, we can typically run through many steps in a row quickly in our heads. For example, using the multi-step relation, it is equally correct to skip a some of the intermediate steps and write down the following sequence:
fact 2 -->* (fact 1) + 2 -->* ((fact 0) + 1) + 2 -->* ((0) + 1) + 2 -->* 3