Until now, we have moved in the realm of pure functional programming. The
only exception was print
. In purely functional programs there
is no state
, functions do not "remember" anything between
function calls. Values, once assigned to variables, can not be changed.
Also, it is always safe to substitute an expression by its (uniquely
determined) value. This property is called referential transparency. The
recomputation of the same expression (in the same environment) always
yields the same value.
All the features given above make functional programs easy to understand and to analyze.
It is possible to write purely functional programs in SML
,
but SML
also has imperative
features. Among
these are references
(and operations on references),
input/output commands
, and arrays
. These allow
us, among other things, to explicitly define and maintain program state
(e.g. functions that preserve information across function calls).
If we can solve all problems in a purely functional context, why do we need imperative features? We'll give two important reasons.
First, for efficiency reasons: some values are very expensive to compute - if we need these multiple times it might make sense to store them, and retrieve them when we need them again.
Second, it is often more practical for the program to read in its data (as opposed to having data hardwired into the code). If a computation's results depends on the input (and it should, otherwise the input would be redundant), the program will not be purely functional (i.e. the same expression evaluated repeatedly will produce different results when evaluated repeatedly).
To understand references we need to introduce the concept of
storage
or memory
. In SML
all
values that are computed reside - at least temporarily - in memory. For
now we will define memory as a collection of locations
(or
cells
) that hold values. Complex data structures actually
require several memory cells for storage, but how this is done is not
important at this stage of our discussion. Details, and plenty of
examples, will be provided in section.
Each memory location is uniquely identified by its A reference to value Here are some examples:
The opposite of creating a reference is Two references are It is possible to have polymorphic references. A variable of type An alternative notation is possible, but we don't encourage you using it:
We can understand what is going on if we use a box diagram like the one
below:
Complex values, like lists, can not be stored in one memory location. In
section we will expand the box diagram model to account for the treatment
of complex data structures.
References to references are particularly useful. Consider the following
example in which we set up a data structure with circular references:
A similar approach can be used to implement closures.
We mentioned above that one drawback of purely functional programs is that
results of expensive computations can not be saved for later reuse.
Workarounds are possible, but they are all unwieldy, and they break the
logical separation between various parts of the program.
For example, one could redesign each "expensive" function to receive both
the current arguments and the set of all values they computed in the past.
Then the function would either look up the value that corresponds to the
current arguments (if it is available), or it would compute the value from
scratch. Finally, the new value, if any, would be added to the set of all
computed values, and this list would be returned to the caller.
A much better approach is to use side effects to store the computed values
internally.
Consider the following simplistic implementation of Fibonacci numbers:
If you try to compute Here is an alternative approach:
The In address
.
v
holds the address of the memory
location that holds value v
. References are values. The type
of a reference to a value of type t
is t ref
.
References to references are allowed.
- ref 5;
val it = ref 5 : int ref
- val v = ref [1, 2, 3];
val v = ref [1,2,3] : int list ref
- val x = ref (ref (ref "secret message", 5));
val x = ref (ref (ref "secret message",5)) : (string ref * int) ref ref
- v;
val it = ref [1,2,3] : int list ref
- x;
val it = ref (ref (ref "secret message",5)) : (string ref * int) ref ref
dereferencing
, which
is implemented using the !
operator. Dereferencing allows for
direct access to the referenced value.
- ! (ref 5);
val it = 5 : int
- ! v;
val it = [1,2,3] : int list
- ! x;
val it = ref (ref "secret message",5) : (string ref * int) ref
- ! (! x);
val it = (ref "secret message",5) : string ref * int
- ! (#1 (! (! x)));
val it = "secret message" : string
equal
if and only if they are of the same
type, and they represent the same address
. It is not
sufficient for two references to point to equal
value for
them to be equal.
- ref 5 = ref 5;
val it = false : bool
- val p = ref 5;
val p = ref 5 : int ref
- val q = p;
val q = ref 5 : int ref
- p = q;
val it = true : bool
'a
ref
can hold a reference to a value of any type. Equality of
polymorphic reference types poses further problems, but we are not going
to address them here.
- fun f(x: 'a ref): int = 0;
val f = fn : 'a ref -> int
- f(ref "first attempt");
val it = 0 : int
- f(ref (1, 2, 3));
val it = 0 : int
It is still not possible to change a (non-reference) value in
SML
, but it is possible to change the value a reference
points to. This is done using the assignment operator
(:=
), whose signature is 'a ref * 'a -> unit
.
The assignment operator is an example of a function that is interesting
only for its side effect; the value it "computes" is trivial.
- val x: int ref = ref 7;
val x = ref 7 : int ref
- val y: int ref = x;
val y = ref 7 : int ref
- x;
val it = ref 7 : int ref
- y;
val it = ref 7 : int ref
- val () = x := 5;
- x;
val it = ref 5 : int ref
- y;
val it = ref 5 : int ref
val _ = x := 5;
+---+ +---+
x -------> |(R)| -------> | 7 |
+---+ +---+
+---+ +---+
x -------> |(R)| -------> | 7 |
+---+ +---+
A
|
y -----------+
+---+ +---+
x -------> |(R)| ---+ | 7 |
+---+ | +---+
A |
| | +---+
y -----------+ +---> | 5 |
+---+
- datatype 'a explist = Null | Cons of ('a * 'a explist ref ref);
datatype 'a explist = Cons of 'a * 'a explist ref ref | Null
- val dummy = ref (ref (Null: int explist));
val dummy = ref (ref Null) : int explist ref ref
- val lst = Cons(3, dummy);
val lst = Cons (3,ref (ref Null)) : int explist
- lst;
val it = Cons (3,ref (ref Null)) : int explist
- val () = dummy := ref lst;
- lst;
val it = Cons (3,ref (ref (Cons #))) : int explist
- (case lst of Cons(_, rrlst) => !(!rrlst) | _ => raise Fail
"impossible");
val it = Cons (3,ref (ref (Cons #))) : int explist
SML
prints nested data structures only until a (configurable)
level of nestedness is reached. This is often a disadvantage, but in this
case it is a life saver. Without stopping, the printing algorithm would
get into an infinite loop due to the circular reference we have created.
Memoization
fun fibo(n: int): int =
case n of
0 => 1
| 1 => 1
| _ => fibo(n - 1) + fibo(n - 2)
fibo(40)
you will note that it takes a
long time for the computation to finish. Why? Because of the way recursive
calls have been written - many of the values will be calculated
repeatedly.
local
val computed: (int * int) list ref = ref [];
in
fun fibo2 (n: int): int =
case n of
0 => 0
| 1 => 1
| _ => (case List.find (fn (x, _) => x = n) (!computed) of
NONE => let
val fibn: int = fibo2(n - 1) + fibo2(n - 2)
val (): unit = computed := (n, fibn)::(!computed)
in
fibn
end
| SOME (_, v) => v)
end
local
declaration makes computed
to
be declared outside fibo2
, but only visible from
it. Contrast this implementation with the one in which
computed
is declared inside fibo2
, in a
let
. The latter version will lose all speed advantages,
and it will be as slow (or slower) as fibo
. Can you tell
why?
fibo2
we memoize
all values that we
compute, except for the base cases, which are cheaper to treat
directly anyway. Even though we don't use a sophisticated data
structure to perform lookups in computed
, the difference
in computation time is striking. A careful implementation would
probably use a map
to store and retrieve memoized values.
CS312
© 2002 Cornell University Computer Science