CS312 Lecture 21: Side Effects and References

Side Effects

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).

References

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 address.

A reference to value 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.

Here are some examples:

- 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

The opposite of creating a reference is 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

Two references are 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

It is possible to have polymorphic references. A variable of type '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

An alternative notation is possible, but we don't encourage you using it:

val _ = x := 5;

We can understand what is going on if we use a box diagram like the one below:

            +---+          +---+
 x -------> |(R)| -------> | 7 |
            +---+          +---+


            +---+          +---+
 x -------> |(R)| -------> | 7 |
            +---+          +---+
              A
              |
 y -----------+


            +---+          +---+
 x -------> |(R)| ---+     | 7 |
            +---+    |     +---+
              A      |
              |      |     +---+
 y -----------+      +---> | 5 |
                           +---+

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:

- 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.

A similar approach can be used to implement closures.

Memoization

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:

fun fibo(n: int): int =
  case n of
    0 => 1
  | 1 => 1
  | _ => fibo(n - 1) + fibo(n - 2)

If you try to compute 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.

Here is an alternative approach:

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

The 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?

In 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