Lecture 18: Side Effects

Administrivia

PS#4 is now due on Friday at 6:59PM.

Consulting tonight, 7-10PM.

Side effects in SML

Thus far, we've been working with the purely functional fragment of SML.  That is, we've been working with the subset of the language that does not include computational effects (also known as side effects) other than printing. 

In particular, whenever we coded a function, we never changed variables or data.  Rather, we always computed new data.  For instance, when we wrote code for an abstract data type such as a stack or queue, the operations to insert an item into the data structure didn't effect the old copy of the data structure. 

Instead, we always built a new data structure with the item appropriately inserted.  (Note that the new data structure might refer to the old data structure, so this isn't as inefficient as it first sounds.)

For the most part, coding in a functional style (i.e., without side effects) is a "good thing" because it's easier to reason locally about the behavior of the code.  For instance, when we code purely functional queues or stacks, we don't have to worry about a non-local change to a queue or stack.  However, in some situations, it is more efficient or clearer to destructively modify a data structure than to build a new version.  In these situations, we need some form of mutable data structures.

Like most imperative programming languages, SML provides support for mutable data structures, but unlike languages such as C, C++, or Java, they are not the default.  Thus, programmers are encouraged to code purely functionally by default and to only resort to mutable data structures when absolutely necessary. 

In addition, unlike imperative languages, SML provides no support for mutable variables.  In other words, the value of a variable cannot change in SML.  Rather, all mutations must occur through data structures.

The ref parameterized type

There are only two built-in mutable data structures in SML:  refs and arrays. SML supports imperative programming through the primitive parameterized ref type. A value of type "int ref" is a pointer to a location in memory, where the location in memory contains an integer.  It's analogous to "int*" in C/C++ or "Integer" in Java (but not "int" in Java).   Like lists, refs are polymorphic, so in fact, we can have a ref (i.e., pointer) to a value of any type. 


A partial signature for refs is below:

  signature REF = 
    sig
      type 'a ref
 
      (* ref(x) creates a new ref containing x *)
      val ref : 'a -> 'a ref     
 
      (* !x is the contents of the ref cell x *)
      val op ! : 'a ref -> 'a     
 
      (* Effects: x := y updates the contents of x
       * so it contains y. *)
      val op := : 'a ref * 'a -> unit
    end

A ref is like a box that can store a single value. By using the := operator, the value in the box can be changed as a side effect. It is important to distinguish between the value that is stored in the box, and the box itself. A ref is the simplest mutable data structure. A mutable data structure is one that be changed imperatively, or mutated.


The following code shows an example where we use a ref:

    let val x : int ref = ref 3
        val y : int = !x
    in
        x := (!x) + 1;
        y + (!x)
    end

The code above evaluates to 7.  Let's see why:  The first line "val x:int ref = ref 3" creates a new ref cell, initializes the contents to 3, and then returns a reference (i.e., pointer) to the cell and binds it to x.  The second line "val y:int = !x" reads the contents of the cell referenced by x, returns 3, and then binds it to y.  The third line "x := (!x) + 1;" evaluates "!x" to get 3, adds one to it to get 4, and then sets the contents of the cell referenced by x to this value.  The fourth line "y + (!x)" returns the sum of the values y (i.e., 3) and the contents of the cell referenced by x (4).  Thus, the whole expression evaluates to 7.

 

 

Using refs to implement a mutable imap

An imap is a finite map from integers to integers. You can create an imap and lookup a value in it. In the functional word, you can’t modify an imap. Here is the interface and functional implementation:

signature IMAP =
  sig
    type imap
    val new: unit -> imap
    val lookup: int * imap -> int option
    val add: (int * int * imap) -> (int * int * imap)
  end
 
structure oldmap :> IMAP  =
struct
  type imap = (int * int) list
  fun new():imap = []
  fun add(k:int,v:int,m:imap):(int * int * imap) =
     (k,v,(k,v)::m)
  fun lookup(key:int,m:imap):int option =
    case m of
      [] => NONE
    | (k,v)::rest => if (k = key) then SOME(v) else lookup(key, rest)
end
 

Here is how to do this with side effects:

structure newimap :> IMAP  =
struct
  type imap = (int * int) list ref
  fun new():imap = ref []
  fun add(k:int,v:int,m:imap):(int * int * imap) =
    (m := (k,v)::(!m);
     (k,v,m))
  fun lookup(key:int,m:imap):int option =
    case !m of
      [] => NONE
    | (k,v)::rest => if (k = key) then SOME(v) 
                        else lookup(key,ref rest)
end

OK, what can we do with the new imap that we can’t do with the old? How about memoization!

fun memoize(f:int->int):int->int =
  let val cache = newimap.new()
    in
      fn(x:int) =>
      let
        val found  = newimap.lookup(x,cache)
      in
        case found of
          SOME(v) => v
        | NONE => #2(newimap.add(x,f x, cache))
      end
  end

Try it:

- fun sq(x:int) = (print("computing sq of " ^ Int.toString(x) ^"\n"); x*x);
val sq = fn : int -> int
- sq(4);
computing sq of 4
val it = 16 : int
- val msq = memoize sq;
val msq = fn : int -> int
- msq 4;
computing sq of 4
val it = 16 : int
- msq 4;
val it = 16 : int

Using refs to “package” state in closures

datatype Action = Deposit | Withdrawl
 
datatype Action = Deposit | Withdrawl
 
fun mkacct(initial:int):(Action*int)->int =
  let val balance:int ref = ref initial
    in
      fn(a:Action,x:int) =>
      (print("Balance was " ^ Int.toString(!balance) ^"\n");
       (case a of
         Deposit => balance := !balance + x
       | Withdrawl => if (!balance < x) 
                       then raise Fail "Overdrawn!"
                      else balance := !balance - x);
          print("New balance: " ^ Int.toString(!balance) ^"\n");
          !balance)
  end
 
- val acct2 = mkacct 100;
val acct2 = fn : Action * int -> int
- val acct1 = mkacct 100;
val acct1 = fn : Action * int -> int
- acct1(Deposit,10);
Balance was 100
New balance: 110
val it = 110 : int
- acct2(Withdrawl,10);
Balance was 100
New balance: 90
val it = 90 : int