CSE341 Notes for Wednesday, 2/7/07

We finished our discussion of the rational example. We had left off with this version:

        signature RATIONAL =
        sig
            type rational
            exception NotARational
            val makeFraction : int * int -> rational
            val add : rational * rational -> rational
            val toString : rational -> string
        end

        structure Rational :> RATIONAL =
        struct
            ...
        end
We found that this worked fairly well, but we no longer could use the Whole constructor to construct a rational number. This is fairly easy to fix. We can simply add a signature for the Whole constructor in the RATIONAL signature:

        signature RATIONAL =
        sig
            type rational
            exception NotARational
            val makeFraction : int * int -> rational
            val Whole : int -> rational
            val add : rational * rational -> rational
            val toString : rational -> string
        end
We don't have to expose the details of the rational type to let ML and clients know that there is something called Whole that allows them to construct a rational number from a single int. This allowed us to again write client code like the following:

        val x = Rational.Whole(23);
        val y = Rational.makeFraction(27, 8);
        val z = Rational.add(x, y);
We these changes, we have guaranteed that clients must use either Whole or makeFraction to construct a rational number. That means that we have the invariant we were looking for:

        (* invariant: for any Fraction(a, b), b > 0 *)
We still need to call reduce in the add function because the arithmetic involved in add can lead to a fraction that needs to be reduced, but we don't have to call reduce in functions like toString because we know that it's not possible for a client to construct a rational number that violates our invariant.

Here is the final version of the structure:

        structure Rational :> RATIONAL =
        struct
            datatype rational = Whole of int | Fraction of int * int;
            exception NotARational;
        
            fun gcd(x, y) =
                    if x < 0 orelse y < 0 then gcd(abs(x), abs(y))
                    else if y = 0 then x
                    else gcd(y, x mod y);
        
            fun reduce(Whole(i)) = Whole(i)
            |   reduce(Fraction(a, b)) =
                    let val d = gcd(a, b)
                    in if b < 0 then reduce(Fraction(~a, ~b))
                       else if b = d then Whole(a div d)
                       else Fraction(a div d, b div d)
                    end;
        
            fun makeFraction(a, 0) = raise NotARational
            |   makeFraction(a, b) = reduce(Fraction(a, b));
        
            fun add(Whole(i), Whole(j)) = Whole(i + j)
            |   add(Whole(i), Fraction(c, d)) = Fraction(i * d + c, d)
            |   add(Fraction(a, b), Whole(j)) = Fraction(a + j * b, b)
            |   add(Fraction(a, b), Fraction(c, d)) =
                    reduce(Fraction(a * d + c * b, b * d));
        
            fun toString(Whole(i)) = Int.toString(i)
            |   toString(Fraction(a, b)) =
                    Int.toString(a) ^ "/" ^ Int.toString(b);
        end;
I mentioned that using a signature with an abstract type, you can use a completely different internal implementation and the client would never even know it. For example, here is an alternative implementation of the signature that implements rationals as a tuple of two ints:

        structure Rational2 :> RATIONAL =
        struct
            type rational = int * int;
            exception NotARational;
        
            fun gcd(x, y) =
                    if x < 0 orelse y < 0 then gcd(abs(x), abs(y))
                    else if y = 0 then x
                    else gcd(y, x mod y);
        
            fun reduce(a, b) =
                    let val d = gcd(a, b)
                    in if b < 0 then reduce(~a, ~b)
                       else (a div d, b div d)
                    end;
        
            fun makeFraction(a, 0) = raise NotARational
            |   makeFraction(a, b) = reduce(a, b);
        
            fun Whole(a) = (a, 1);
        
            fun add((a, b), (c, d)) = reduce(a * d + c * b, b * d);
        
            fun toString(a, b) =
                if b = 1 then Int.toString(a)
                else Int.toString(a) ^ "/" ^ Int.toString(b)
        end;
Here we use a type definition rather than a datatype definition because we are introducing a type synonym rather than a new type. It also means that we have to define Whole as a function rather than a constructor. This new structure provides the same functionality to a client as the original and the client would have no way of telling them apart because the signature uses an abstract type. This is a powerful and useful mechanism.

I then spent a few minutes revisiting the idea of mutable state. I mentioned this early in the quarter. In functional programming, we generally try to avoid mutable state. A classic example of mutable state is this statement that is common in C, C++, and Java:

        x++;
The variable x has a memory location and this statement changes what is stored in that location. If you ask for the value of x later, you'll find that it has changed (mutated). As I mentioned earlier in the quarter, this is not possible to do using the simple mechanisms in ML. The following definition looks similar to x++, but is quite different:

        val x = x + 1;
As Ullman was careful to point out in the book, this introduces a second binding for x rather than changing a shared memory location. For example, we've seen that when a function has a free variable:

        val x = 3;
        fun f(n) = x * n;
ML creates a closure for this function, remembering the bindings that were part of the environment when the function was defined and remembering the code to be evaluated. So it doesn't matter if we rebind x. The function uses the original value of x:

        - val x = 3;
        val x = 3 : int
        - fun f(n) = x * n;
        val f = fn : int -> int
        - f(8);
        val it = 24 : int
        - val x = x + 1;
        val x = 4 : int
        - f(8);
        val it = 24 : int
I had discussed this idea in terms of a side effect. The technical term is that we want referential transparency (no side effects). For example, under what circumstances can you take a function f that returns an int and know that:

        f(x) + f(x) = 2 * f(x)
The answer is that this will be true if there are no side effects. I asked where we had seen side effects so far and someone mentioned printing. In general, input and output operations produce side effects. Using a sequence expression, we could define a function f as:

        fun f(n) = (print("hello\n"); 3 * n);
This function computes 3 times its argument, but it also produces output. Reading functions have the side effect of consuming input so that later calls on the same input stream will read values that appear later in the input.

Outside of input and output, we haven't seen a way to introduce a side effect. The only way to get such a side effect would be to introduce mutable state. That is done in ML using arrays, vectors and references. I briefly discussed the array example. I opened up the Array structure to see a list of available functions and we found that we could construct an array as follows:

        - val x = array(3, 0);
        val x = [|0,0,0|] : int array
Using the update function, we were able to change the value of array element 0 to be 42:

        - update(x, 0, 42);
        val it = () : unit
        - x;
        val it = [|42,0,0|] : int array
Then I showed what happens when we refer to element 0 in a function definition:

        - fun f(n) = n * sub(x, 0);
        val f = fn : 'a -> int
Not surprisingly, this uses the value 42 when I make calls on the function:

        - f(3);
        val it = 126 : int
        - f(5);
        val it = 210 : int
But notice what happens when I reset the value of array element 0 and call the function again:

        - update(x, 0, 4);
        val it = () : unit
        - x;
        val it = [|4,0,0|] : int array
        - f(3);
        val it = 12 : int
        - f(5);
        val it = 20 : int
The array provides a way to refer to a location in memory that we can change. Our reference to the object (the array) is still immutable. For example, if we rebind x to refer to a different array, that has no effect on the function:

        - val x = array(3, 0);
        val x = [|0,0,0|] : int array
        - update(x, 0, 19);
        val it = () : unit
        - x;
        val it = [|19,0,0|] : int array
        - f(3);
        val it = 12 : int
        - f(5);
        val it = 20 : int
Our function f is still using the old array, not the new array. But the array itself has elements that are mutable, and that's what allows us to have code that changes its behavior in subtle ways. That allows us to write functions with side effects. For example, the function could use a sequence expression to increment element 0 of the array:

        - val x = array(3, 0);
        val x = [|0,0,0|] : int array
        - fun f(n) = (update(x, 0, sub(x, 0) + 1); n * sub(x, 0));
        val f = fn : int -> int
        - f(3);
        val it = 3 : int
        - f(3);
        val it = 6 : int
        - f(3);
        val it = 9 : int
        - f(3);
        val it = 12 : int
This is exactly the kind of side effect we were trying to avoid and the source is the mutable state we get with an array. I said that the reference type provides a more atomic way to do this (a single value versus an array of values).

Then I spent some time discussing what would be on the midterm. I distributed a sample midterm, which is the best thing to study in preparing for the exam (handouts 11 and 12). I made three points that you should keep in mind:


Stuart Reges
Last modified: Thu Feb 8 14:55:08 PST 2007