Lecture 6:
Eager vs. Lazy Evaluation. Higher-Order Functions.

We spent the last course talking about identifiers and their scope, as well as about bindings and substitution. The elements that we introduced are important, but they don't give us a complete understanding of SML (or, at least, the subset of the language that we have seen up to now). We need to know more, especially about how substitution and evaluation is done. We will understand these details once we introduce the substitution model. Before we do that, however, we will examine a few examples that emphasize the need for a precise understanding of SML.

Eager versus Lazy SML

Consider the following two function definitions:

fun f(x:int):bool = (print "evaluate body\n"; true);
fun arg():int = (print "evaluate argument\n"; 3);

Now, what will SML print if the evaluate expression f(arg())? The answer is very simple:

- f(arg());
evaluate argument
evaluate body
val it = true : bool

The print statements help us understand that the argument is evaluated first, followed by the actual function call to f.

The evaluation strategy in which function arguments are evaluated before the function call is made is known as an "eager" evaluation. Eager evaluation is used by most well-known modern programming languages, and it might seem so natural that we don't even consider the possibility of alternative evaluation strategies.

Consider the following alternative strategy, however: let us postpone evaluating function arguments until after the function call, and then evaluate the argument only if it actually is needed (e.g. if it appears in an expression in the called function). Such a strategy is "lazy." Here is the corresponding output:

- f(arg())
evaluate body
val it = true: bool

If SML were lazy, it would not evaluate the argument (i.e. it would not call function arg), because arg it is not used in the body of f.

The examples above show that the behavior of the same program can be different when we apply the lazy or the eager evaluation strategy. One might argue that the different behavior emerges only because we used side-effects (after all, the final result is the same in both cases); if we restricted ourselves to the strictly functional subset of SML which we are interested in at this stage, then maybe such differences in behavior would not arise. This is not so, even a purely functional program can behave differently when using the two alternative evaluation strategies. Consider, for example the same function f as before, but a redefined function arg such that the body of arg encodes an algorithm that does not terminate (e.g. it is an "infinite loop"). Clealy, the lazy evaluation strategy would still be able to evalute expression f(arg()), while the eager evaluation method would get stuck in arg's infinite loop.

While SML uses an eager evaluation strategy, we must note that it also has some lazy features, visible, for example, in the evaluation of if expressions, when only the branch that corresponds to the logical value of the condition is evaluated.

Higher-Order Functions

In SML functions are first-order data, and we can easily write computations whose result is a new function. However, understanding higher-order functions can be difficult.

Consider the following definition:

fun mystery(f, g) = fn x => g(f(x))

What does function mystery do? First, let us note that its value is an (anonymous) function, so this is function that returns a function (note: later in the course we will use a more precise terminology for what exactly the returned value is).

Function mystery requires two arguments and returns an anonymous function that takes an argument x and applies g to x, then applies f to the result of g. In other words, function mystery composes functions f and g. We might as well rename it at this stage:

fun compose(f, g) = fn x => f(g(x))

But what is the type of compose? We will use a simple form of type inference to determine the answer to this question. First, we note that nothing restricts the type of x, except that is must be of a type to which the domain of f belongs. Let us denote the type of x with 'a. Now, we can immediately determine the type of f as being 'a -> 'b (the domain is restricted to be the type of x, but the range can be of any type). Applying a similar reasoning we get that the type of g must be 'b -> 'c. Obtaining the type of compose is now immediate:

type(x) = 'a
type(f) = 'a -> 'b
type(g) = 'b -> 'c

type(compose) = ('a -> 'b) * ('b -> 'c) -> 'a -> 'c

The type that we get for compose is exactly the same as the one SML prints out. Note that due to the use of type variables the representation of this type is not unique - by systematically renaming the type variables we can get any number of alternative representations for this type, e.g. ('z -> 'q) * ('q -> 'w) -> 'z -> 'w.

We have always assumed the most general types allowed by the type restrictions implicit in the function definition; and this is what SML does as well. We can restrict compose to have a less general type (see below). This, however, is rarely useful.

- fun compose(f: int->int, g: int->int) = fn x => g(f(x));
val compose = fn : (int -> int) * (int -> int) -> int -> int

We note that function composition is implemented as the infix o operator (the letter o) in SML. Here is an example of its use:

(fn x => x + 1) o (fn x => x - 1) (* identity on integers *)

Let us now examine a recursive higher-order function:

fun ntimes(f: 'a -> 'a, n: int): 'a -> 'a =
  fn x => if n = 0
          then x
          else f((ntimes(f, n-1)) x);


- fun sq(x) = x * x;
val sq = fn : int -> int
- ntimes(sq, 4) 2;
val it = 65536 : int

Function ntimes returns a function that takes argument x and returns the value of f applied n times to x (in other words, f is composed with itself n times, and then applied to x). For any fixed n implementing such a function is easy, e.g.:

- fun threetimes f = fn x => (f o f o f) x
val threetimes = fn : ('a -> 'a) -> 'a -> 'a
- threetimes (fn x => x * x) 3;
val it = 6561 : int

Implementing a function that composes f with itself n times is not trivial, however. As we will see soon, we can in fact implement ntimes using the function composition operator.

How does ntimes work?

Let us first assume that we call ntimes with the second argument set to 0, i.e. we have ntimes(fa, 0), where fa denotes an otherwise unspecified argument function. The first step after the function call is to substitute the function arguments into the body of the function. We get

fn x => if 0 = 0
        then x
        else fa((ntimes(fa, 0-1)) x)

Next, the expression that results after the substitution of the argumentsbis evaluated; this is an anonymous function. Function definitions are evaluated by creating an internal representation for them, without actually executing their body (the body is executed only at the time of a function call, after all actual function arguments have been provided). This behavior is exhibited by the function in the following example:

- fn x:int => (print "body evaluated"; 3);
val it = fn : int -> int
- (fn x:int => (print "body evaluated"; 3)) 7;
body evaluatedval it = 3 : int

Returning to our ntimes function with the second argument set to 0, the result of the call ntimes(fa, 0) is an anonymous function whose body, while left unevaluated at the time of the call to ntimes, has been modified by the substitution of ntimes' arguments; let's call this function f0.

Now, assume that we call f0 with a suitable argument arg. Similarly to the substitution that occured when we evaluated ntimes, we now substitute the value of arg by replacing all occurences of the formal argument x with the actual (evaluated) argument arg. We get:

if 0 =  0
then arg
else fa((ntimes(fa, 0-1)) arg)

Note: we are simplifying a bit here, because in general we can't substitute all occurences of formal arguments in the body of the function, we can only substitute its free occurences. We will return to this issue when we discuss the substitution model rules.

Now we are ready to evaluate the expression that resulted after substitution in the body of f0 (remember that while we have not specified neither fa, nor arg, both of them are values of the correct types, as they are previously evaluated function arguments). The result of evaluating the if expression is arg. In other words:

ntimes(fa, 0) arg = f0 arg = arg

Because we made no assumption about fa or arg (except that they are of the correct type), the result is general: whenever the second argument of ntimes is 0, the ntimes(fa, 0) returns the identity function.

Now let us evaluate expression ntimes(fa, 1). By substituting the argument we get:

val f1 = fn x => if 1 = 0
                 then x
                 else fa((ntimes(fa, 1-1)) x)

Now, function call f1 arg yields the following expression after substitution in the body of f1:

if 1 = 0
then arg
else fa((ntimes(fa, 1-1)) arg)

The evaluation of this expression reduces to the evaluation of fa((ntimes(fa, 1-1) arg). The argument of the call to the leftmost occurence of fa reduces to (ntimes(fa, 0) arg), which we already know to be equal to arg. Thus fa((ntimes(fa, 1-1) arg) = fa(arg) and we get that:

ntimes(fa, 1) arg = fa(arg)

Let us now evaluate ntimes(fa, 2) arg. Again, we get:

val f2 = fn x => if 2 = 0
                 then x
                 else fa(ntimes(fa, 2-1)) x)

Evaluating f2 arg we get the following after substitution:

if 2 = 0  
then arg
else fa(ntimes(fa, 2-1)) arg)

The if expression reduces to fa(ntimes(fa, 1) arg). Using the fact that ntimes(fa, 1) arg = fa(arg), we get that fa(ntimes(fa, 1) arg) = fa(fa(arg)). We have thus proven the following equality:

ntimes(fa, 2) arg = fa(fa(arg))

The pattern of the proof is clear now: we can continue proving that ntimes(fa, k) arg = fa(fa(...(fa(arg)))), where fa is applied k times (k >= 1) by relying on the fact that we know that an analogous relation holds for k-1. Going step-by-step we can prove the general statement that ntimes(fa, n) returns a function that takes one argument and applies fa composed with itself n times to the respective argument. This is mathematical induction on programs (though we cut some corners). We will return to this topic shortly.

Now, ntimes can not be easily understood without having a mental model of what an SML program means. We worked out the details, but it is not necessarily clear how we can extend this style of reasoning to other - possibly more complex - problems. In the next two lectures we will provide a more rigurous framework, which we will call the substitution model, for a subset of SML.

Finally, let us note that more elegant implementations of ntimes are possible. Here is one:

fun ntimes(f: 'a -> 'a, n: int): 'a -> 'a = 
  if n = 0
  then fn x => x
  else f o (ntimes(f, n-1))