CS312 Lecture 7: More Substitution Model, and Higher-Order Procedures

Overview

Administrivia: PS#3 out today, pick a partner (or not) by next Friday.

Last time: evaluation model and rewrite rules.

This time: a slightly more program-like definition of the evaluation model. There will be a handout available on the web shortly (by tomorrow).

Syntax of our ML subset

We will still be looking at a subset of ML, consisting of

but we will soon add fun as well.

We can formally define the syntax of our ML subset:

Expressions:

e ::= c                             (* constants *)
    | id                            (* variables *)
    | (fn (id:t) => e)              (* anonymous functions *) 
    | e1(e2)                        (* function applications *)
    | u e                           (* unary operations, ~, not, etc. *)
    | e1 b e2                       (* binary operations, +,*,etc. *)
    | (if e then e1 else e2)        (* if expressions *)
    | let d in e end                (* let expressions *)           [ADD LATER]
    | (fun id(id1:t1):t2 = e)       (* recursive functions *)       [ADD LATEST OF ALL]

Declarations:

d ::= val id = e                    (* value declarations *)        [ADD LATER]
    | fun id(id1:t1):t2 = e         (* function declarations *)     [ADD EVEN LATER]

Values:

v ::= c                              (* constant values *)
    | (fn (id:t):t' => e)            (* anonymous functions *)

The eval function

We will now specify a function called eval. This is not an ML function (yet!). Instead it is a recursive specification (abstract description) of the result of evaluating any ML function. For each element of the syntax we have a rule. Here are the ones corresponding to what we did in lecture on Tuesday:

Rule #E1 [constants]: constants evaluate to themselves

eval(c) = c

Rule #E2 [functions]: anonymous functions evaluate to themselves

eval(fn (id:t) => e) = (fn (id:t) => e)

Rule #E3 [function calls]: to evaluate e1(e2), evaluate e1 to a function (fn (id:t) => e), then evaluate e2 to a value v, then substitute v for the formal parameter id within the body of the function e to yield an expression e'. Finally, evaluate e' to a value v'. The result is v'.

eval(e1(e2)) = v'  where
  (0) eval(e1) = (fn (id:t) => e)
  (1) eval(e2) = v
  (2) substitute([(id,v)],e) = e'
  (3) eval(e') = v'

Rule #E4 [unary ops]: to evaluate u e where u is a unary operation such as not or ~, evaluate e to a value v', then perform the appropriate unary operation on the value v' to get the result v.

eval(u e) = v where
  (0) eval(e) = v'
  (1) v = apply_unop(u,v')

Rule #E5 [binary ops]: to evaluate e1 b e2 where b is a binary operation such as +, *, -, etc. Evaluate e1 to a value v1, then evaluate e2 to a value v2, then perform the appropriate operation on the values v1 and v2 to get the result v.

eval(e1 b e2) = v where
  (0) eval(e1) = v1
  (1) eval(e2) = v2
  (2) v = apply_binop(b,v1,v2)

Rule #E6 [if]: to evaluate (if e then e1 else e2), evaluate e to a value v. Then depending on the (boolean) value of v, the value is either the result of evaluating e1 or e2.

eval(if e then e1 else e2) = v' where
(0) eval(e) = v
(1) if v = true then v' = eval(e1)
(2) if v = false then v' = eval(e2)

A substitution is a finite map from identifiers (variables) to expressions. We represent the substitution as a list of identifiers and expressions (i.e., [(x1,e1),(x2,e2),...,(xn,en)]). We can perform a substitution S = [(x1,e1),(x2,e2),...,(xn,en)] on an expression or declaration by first substituting e1 for x1, then e2 for x2, ..., then xn for en. This is what the specification substitute does.

Now we can do our earlier examples slightly more precisely. The only real difference from the re-write rules we described earlier is that now we are specifying the order of evaluation; so for example we need to evaluate a function's arguments before we call it.

Examples:

(fn(y:int) => y*y*y)(2+1)

(if (1 < 2) then (fn(y:int) => y*y*y) else (fn(y:int) => y*y))(~ 4)

(fn(y:int) => (fn(z:int) => z*y))(2+1)

let val x:int = 2+1 in (* turn this into function application *)
  fn(z:int) => z+x
end

LET and variable declarations

We can also add LET to the language as a primitive, which is useful (though not, strictly speaking, necessary) (just like having * is not necessary if you have +).

[Add syntax rules for let and declaration]

Rule #E7 [let]: to evaluate let d in e end, evaluate the declaration d to get a substitution S. Perform the substitution S on e yielding a new expression e'. Then evaluate e' to get the final answer.

eval(let d in e end) = v where
  (0) eval_decl(d) = S
  (1) substitute(S,e) = e'
  (2) eval(e') = v

Rule #D1[val declarations]: to evaluate a declaration val id = e, evaluate e to a value v and match v against the identifier id to yield a substitution S. The substitution S is the result of the declaration.

eval_decl(val p = e) = S where
  (0) eval(e) = v
  (1) match(v,p) = S

Top-level loop, and variable scope

Remember that to find the value of a variable we just look textually (lexically) "up" from the piece of code where it is referenced, and find the first binding occurrence. You can think of the ML interpreter as having a giant LET statement before your code that gives lots of things their bindings. A decent model of val at top level is that it adds to this set of definitions.

val x = 27;

E ==
let val x:int = 27 in 
  E
end

So we can say

val triple = (fn(z:int):int => 3 * z);

triple(14) ==
let val triple:int->int =  (fn(z:int):int => 3 * z) in 
  triple(14)
end

Higher-order procedures (or, where the substitution model first shows its value)

So far the substitution model looks pretty simplistic; it's a set of rules that tells you things you already know. However, understanding the substitution model is the key to prelim #1 and the first 1/3 of the course (as well as the final).

HOP's are the first example of something that is easy to understand if you really get the substitution model, and impossible otherwise.

We need one more thing in our language subset, namely fun. (fun fun…)

For the moment only, we will assume that fun is used just as a declaration, as in

let fun triple(z:int->int) = 3 * z in 
  triple(14)
end

We will assume this is shorthand for saying

let val triple:int->int =  (fn(z:int):int => 3 * z) in 
  triple(14)
end

Note that this is NOT the final story (like Newtonian physics), but a simplification for the moment. We will tell you when we change the story (relativity?)

Higher-order procedures : first examples

Functions are values just like any other value in SML. What does that mean exactly? This means that we can pass functions around as arguments to other functions, that we can store functions in data structures, that we can return functions as a result from other functions. The full implication of this will not hit you until later, but believe us, it will.

Let us look at why it is useful to have higher-order functions. The first reason is that it allows you to write more general code, hence more reusable code. As a running example, consider these functions

fun triple (z:int):int = 3 * z
fun cube (z:int):int = z * z * z

Let us now come up with a function to multiply a number by 9. We could do it directly, but for utterly twisted motives decide to use the function triple above

fun mul9 (z:int):int = triple (triple (z))

Now let's find a way to get the 9th power of a number:

fun pow9 (z:int):int = cube (cube (z))

There is a totally unexpected and unplanned similarity between these two functions: what they do is apply a given function twice to a value. By passing in the function to apply twice as an argument, we can reuse code:

fun apply_twice (f:int->int, x:int):int = f (f (x))

Using this we can now write

val x = apply_twice(triple,4)           (* 36 *)

fun new_mul9(z:int):int = apply_twice(triple,z)

val x2 = new_mul9(4)                    (* 36 *)

fun new_pow9(z:int):int = apply_twice(cube,z)

val x3 = new_pow9(4)                    (* 512 *)

I strongly recommend that those of you who wish to pass CS312 (and especially prelim #1) work out these examples by hand using the substitution model.

The advantage is that the similarity between these two functions has been made manifest. Doing this is very helpful. If someone comes up with an improved version of apply_twice, then every function that uses it profits from the improvement.

The function apply_twice is a so-called higher-order function: it is a function from functions to other values. Notice the type of apply_twice is ((int -> int) * int) -> int

In order not to pollute the top level namespace, it can be useful to locally define the function to pass in as an argument. For example:

fun fourth (x:int):int = 
  let 
    fun square (y:int):int = y * y
  in
    apply_twice (square,x)
  end

However, it seems silly to define and name a function simply to pass it in as an argument to another function. After all, all we really care about is that apply_twice get a function that double its argument. So let's do that, using new notation:

fun fourth (x:int):int = apply_twice (fn (y:int) => y*y,x)

Anonymous functions are useful for creating functions to pass as arguments to other functions, but are also useful for writing functions that return other functions! Let us revisit the apply_twice function. We now write a function twice which takes a function as an argument and return a new function that applies the original function twice:

type base = int
type func = int -> int

fun twice_func (f:func):func =
  fn (x:base) => f (f (x))

val newer_mul9 = twice_func(triple);
val newer_pow9 = twice_func(cube);

fun compose (f:func, g:func):func =
  fn (x:base) => f (g (x))