Caml
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 ...
To begin to understand how an OCaml expression executes, we are going to focus exclusively on understanding what value the expression returns. The other things -- reading, printing, raising exceptions, etc. -- are called the effect of the expression. We are going to ignore that stuff for now. One of the great things about OCaml is that for this core fragment we have looked at so far, the rules for evaluating expressions are incredibly simple. They are much, much, much simpler than for any equivalent fragment of C or Java. We call the rules for evaluating a language its operational semantics.

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 + dan
Is 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 + x
Are 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 + x
Different? 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 x               
to this:
let z = 30 in  
let f (y:int) : int = y + z in 
let x = 12 in
f x               
Notice 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 * 3              
to this:
let a = 2 in  
let c = (let b = a + a in b + 7) in 
c * 3              

The 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 *)
y 
In 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 x       
When 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 x       
More 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 * y              
then the result of the substitution e[17/x] is:
let y = 17 + 17 in
17 * y              
As another example, suppose e is as follows:
let y = x + x in
let x = 9 in
x * y              
then the result of the substitution of e[2/x] is:
let y = 2 + 2 in
let x = 9 in
x * y              
We 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 evaluates to another expression in a single computation step, we write:

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 exp2
we 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
--> 9
Notice 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 *)
--> 5
On 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 *)          
--> 5
Even 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
--> 6
On 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
--> 3
Ok, 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