An execution model
The primary execution mode in functional languages is EXPRESSION EVALUATION. SML and most Lisp dialects also have imperative features, which work as side-effects of expression evaluation. Other functional languages, like Haskell and Miranda, are ``pure''--no side-effects (mostly ...). Since most expressions can be understood as function applications, we begin by giving a simple model of the evaluation of applications:
To evaluate F E where E and F stand for arbitrary expressions, evaluate F until it becomes either a built-in function name or an expression of the form fn x => E'. Then evaluate E, call the result R. If F evaluates to a built-in function name, ``execute'' that function with argument R. If F evaluates to fn x => E' then evaluate E' with R SUBSTITUTED for x.
Thus (fn x => 1 + (x * x)) 3 ``rewrites'' to 1 + (3 * 3) which gives 10 Another example: (fn x => 1 + (x * x)) (3 + 3) gives (fn x => 1 + (x * x)) 6 which rewrites to 1 + (6 * 6) which gives 37
This execution model is called the SUBSTITUTION MODEL.
A more interesting example:
(fn f => (fn x => f(f x))) sqrt 16.0
>> val it = 2.0 : real
Try using the substitution model of evaluation to check the result.
The fact that the function expression F needs to be evaluated may be a bit surprising. But in SML we can write for example (if x > 0 then fn y => y-x else fn z => z+x) 2. More commonly, the evaluation of curried functions requires the evaluation of a function expression:
val foo = fn x => (fn y => x+y) >> val foo = fn : int -> int -> int (foo 3) 4 >> val it = 7 : int
Evaluation strategies
As we saw, a function's argument is evaluated before it is substituted in the function's body (or ``passed'', in the case of built-in functions). This is called STRICT or applicative order or call-by-value evaluation.
The alternative is LAZY or normal order evaluation: the argument is substituted/passed unevaluated, with the idea that it will be evaluated only when (and if!) it is actually reached.
Thus (fn x => 1 + (x * x)) (3 + 3) gives 1 + ((3 + 3) * (3 + 3)) which gives 37
Strict evaluation seems more efficient when the argument appears more than once in the body, while lazy evaluation seems better only when the argument does not appear at all. In fact, the straightforward implementation of lazy evaluation which requires reevaluation of the argument for each occurrence (called call-by-name) is not used in practice. Instead, the argument is evaluated when reached the first time and its value is saved for when the future occurrences are reached (this strategy is called call-by-need). This strategy seems ideal in principle, but in practice it involves lots of bookkeeping and needs clever implementation techniques.
Lazy and strict evaluation yield the same answer whenever they both yield valid results. This fact follows from a famous theorem (by A. Church and J. B. Rosser).
A glaring omission in the execution model described so far is the evaluation of recursively defined functions. It is possible to extend the model to account for this, but in the end it turns out that this is not the most convenient model for thinking about evaluation of programs.
A better execution model
An ENVIRONMENT is an association (mapping) of names (identifiers, variables) to values. Remember that a function is a value. We can look up a name in an environment, and extend an environment with a new name/value pair. We say that in the environment names are BOUND to values.
When we fire up SML, we get a (top-level) environment in which the
names
1,2,...,3.14,... +, *, -, /, ^, ::, nil, @, explode
etc. are already defined . (By the way, these names are sometimes
called pervasives.) Warning: most of the pervasive
function names are bound to function values that cannot be expressed
in SML syntax (fn x => ...) but rather are built into the
compiler. However, usually they perform easily understood operations.
When we define something, with a val or a fun definition, we extend this top-level environment. For example, val x = 2 creates a new environment with the identifier x associated with the value 2.
We are now in a position to explain how to evaluate expressions in an environment. There are two cases.
Note that this model nicely takes care of the evaluation of recursive functions (how?). It is also easy to guess how it treats local and let definitions.
But there is something we haven't specified, since function bodies may contain other variable names in addition to the formal parameters of the function.
Static vs. dynamic scoping (binding)
val x = 3 >> val x = 3 : int fun foo y = x >> val foo = fn : 'a -> int fun bar x = foo 10 val bar = fn : 'a -> int bar 5 val it = 3 : intEvaluating bar 5 creates an environment in which x is 5. In it, we evaluate foo 10, i.e. (fn y => x) 10 which has a free variable x.
If the language has STATIC binding (scoping) we get the value of x from the environment in which foo is defined, namely 3. With DYNAMIC binding (scoping), we get the value of x from the environment in which foo is evaluated namely 5, and thus bar 5 would yield 5.
Thus, for static binding, it is not sufficient to identify the value of a fn x => ... expression with its text. The value consists of the text together with the environment in which the function was defined. And this environment should assign values to all the free variables of that expression. This text-environment pair is called a CLOSURE.
Dynamic binding is sensitive to parameter renaming. If we had defined bar by fun bar z = foo 10 we would get a different result under this mode of evaluation.
Languages such as Algol, Pascal, C(++), Java, SML, and some dialects of Lisp, notably Scheme, have static binding. Some other dialects of Lisp, as well as APL and Basic have dynamic binding.
Evaluation of boolean expressions
Consider the following.
fun myif (b,x,y) = if b then x else y >> val myif = fn : bool * 'a * 'a -> 'a fun fact n = myif(n=0, 1, n * (fact (n-1))) >> val fact = fn : int -> int - fact 2; GC #0.0.0.0.1.8: (10 ms) GC #0.0.0.1.2.10: (180 ms) GC #0.0.1.2.3.14: (550 ms) GC #0.1.2.3.4.16: (740 ms) GC #1.2.3.4.5.20: (1070 ms) GC #2.3.4.5.6.22: (1310 ms) GC #2.4.5.6.7.26: (350 ms) GC #2.5.6.7.8.28: (300 ms) GC #3.6.7.8.9.32: (2040 ms) GC #3.7.8.9.10.34: (290 ms) GC #3.8.9.10.11.38: (350 ms) GC #3.9.10.11.12.40: (260 ms) GC #3.10.11.12.13.44: (320 ms) GC #4.11.12.13.14.46: (3260 ms) GC #4.12.13.14.15.50: ^C (300 ms) Interrupt (* Ctrl-C will stop the execution *)
This seemed to go on forever ...What went wrong? Consider the evaluation of (fact 1) according to the rules we recently laid out.
The problem is that myif is bound to an ordinary function, and the evaluation of this function is strict. if E0 then E1 else E2, while it is certainly a function, has special evaluation rules which say the we evaluate first only the predicate E0 and, depending on the result, evaluate E1 or E2.
In SML we also have special evaluation rules for the functions orelse and andalso. As a consequence, they can be used on recursive function definitions (when appropriate) in the same way i then else is used:
fun even n = (n=0) orelse (odd (n-1)) and odd n = (n<>0) andalso (even (n-1)) >> val even = fn : int -> bool >> val odd = fn : int -> bool even 22 >> val it = true : bool odd 22 >> val it = false : bool
Note the syntax for MUTUALLY RECURSIVE function definition.
Some laziness is therefore essential, even in strict languages.
Recursion vs. iteration
Consider the evaluation of fact k with the definition
fun fact n = if (n=0) then 1 else (n * (fact (n-1))
According to our execution model, when a function call is evaluated,
we switch to evaluating the body of the function, but in a
different environment. After this we must continue the evaluation of
the original expression, in the original environment. Therefore
the original environment must be ``saved'' while the function call is
evaluated. This is usually done with a STACK data structure.
The stack grows with entering each recursive call and shrinks with
exiting it. We can get a hint about the dynamics of the stack
by tracing an equivalent ``rewriting'' process that involves expressions.
Here are some steps in this process in the case of fact 8:
fact 8
8 * (fact 7)
8 * (7 * (fact 6))
...
8 * (7 * (6 * (5 * (4 * (3 * (2 * (fact 1)))))))
8 * (7 * (6 * (5 * (4 * (3 * (2 * (1 * (fact 0))))))))
8 * (7 * (6 * (5 * (4 * (3 * (2 * (1 * 1)))))))
8 * (7 * (6 * (5 * (4 * (3 * (2 * 1)))))
8 * (7 * (6 * (5 * (4 * (3 * 2))))
8 * (7 * (6 * (5 * (4 * 6)))
...
This evaluation calls for a lot of space to accomodate the growth of
the stack. Moreover, the manipulation of environments themselves is
time costly.
On the other hand, we can write the factorial function as follows:
fun tailfact n =
let fun factaux i res =
if i=0 then res
else factaux (i-1) (i * res)
in factaux n 1 end
Tracing the evaluation of this gives us
tailfact 8
factaux 8 1
factaux 7 8
factaux 6 56
factaux 5 336
factaux 4 1680
factaux 3 6720
factaux 2 20160
factaux 1 40320
factaux 0 40320
40320
What is going on here is that it is not necessary anymore to save the current environment when a recursive call is reached because no more evaluation is required when the recursive call is exited. Such recursive definition in which the recursive call is the last item to be evaluated in the body are called TAIL-RECURSIVE or ITERATIVE. They can be compiled more efficiently than general recursive definitions because the compiler only needs to keep track of the changing value of a few variables (in the case of tailfact just i and res) and thus can produce just a simple imperative iteration (say, a while loop) that works by changing the state of a few locations.
Most modern compilers for functional languages incorporate this
optimization. You can see that our SML of NJ compiler interpreter does
this, for a call to fact (~1) will ``blow the stack'', while
tailfact (~1) will simply not terminate.