CSE341 Notes for Monday, 1/6/07

I mentioned that I wanted to start using class time every once in a while to discuss important programming language concepts. I spent the first 15 minutes discussing the concept of mutable state. We try to avoid mutable state in functional programming. This will seem odd at first, because it is such a central technique in procedural programming that it's difficult to imagine how you can program without it. For example, in a language like Java we often have an integer variable n that we will increment by saying something like n++. This is a very typical example of mutable state. We have a memory location for the variable x that we change (or mutate) over time.

At first glance, ML variables appear to have the same ability. After all, we can say:

        val x = 3;
        val x = x + 1;
But this has a very different effect in ML. Ullman gives a careful discussion in the book and I encouraged people to pay attention to those sections of the book because they will help you to understand these distinctions. You can think of an ML program as a sequence of bindings that are stored in an environment. The code above introduces two different bindings for the variable x. The second binding makes use of the value from the first binding, but this is very different from sharing a single memory location that different code segments can all refer to. I said that this distinction is difficult to understand at first, but it turns out to be very important.

As an example, I asked people to consider a function f that returns an int and I asked under what circumstances we can replace this expression:

        f(x) + f(x)
with this expression:

        2 * f(x)
Someone said you can do the replacement if "f doesn't do anything else." That's a good way to look at it. Another phrase that is used for this is that we say that f has no side effects.

What kind of side effects might it have? It might change the value of a global variable that is used in the computation. For example, it might do something like this:

        globalCount++;
        return globalCount * x;
In that case, the second call on the function will return a different value than the first call because the computation depends on a variable whose value has changed. This is an example of the problems introduced by mutable state. If you use the simple mechanisms in ML, you won't get this kind of interference. There is no way to use simple variable binding, for example, to effect a global variable like this. ML does have some language elements that allow you to do this (references and arrays), but those are considered the "bad" mutable part of ML.

Another case where this would make a difference is if the function produces output. For example, in Java if it called System.out.println, then you'll get different behavior by calling it twice versus calling it once. This is another kind of side effect and we'd call it another example of mutable state (changing the state of the output stream). ML has functions for reading and writing and they, also, are considered aspects of ML that detract from the purely functional approach.

There is a technical term for the ability to replace the sum of the two function calls with 2 times a single call. It's called referential transparency.

I then introduced two new ML concepts. First we looked at how a let construct can be used to create a local set of bindings that aren't introduced into the overall environment. It's similar to the idea of local variables in Java that appear inside of a block (i.e., inside a set of curly braces {}). For example, we might say:

        let val x = 2.3488942323 in x * x * x * x end;
This is a convenient way to give the name x to the numeric value we want to use in this expression while also keeping that name local to just this expression. It simplifies the expression without introducing a binding for x in the global environment. The general form of the let construct is:

let <bindings> in <expression> end We could read it as, "Let the following bindings hold in evaluating this expression." You can have more than one binding without using any semicolons in the middle, as in:

        let val x = 2.2423 val y = 78.238 in x * y - 2.4 * x + 3.7 * y end;
You can include function bindings as well as variable bindings in a let construct. Often we use a let to define a helper function for some other function.

I then talked about ML's pattern matching facility. ML has the ability to match certain forms of expressions. For example, previously we bound a single variable to a tuple, as in

        val x = (3.4, "hello");
but ML allows you to define something that looks like a tuple on the left side with the actual tuple on the right:

        val (x, y) = (3.4, "hello");
This binds x to 3.4 and y to "hello". ML can even do this with lists. For example, if we say:

        val [x]  = [3];
ML will bind x to 3. In this case we get a warning about the matches not being exhaustive. The warning is more useful when we're writing functions. It's just letting us know that we aren't using every possible kind of list here. We can also match one-element lists:

        val x::xs = [1, 3, 5];
which binds x=1 and y=[3, 5]. Or we can bind a two-element list:

        val x::y::zs = [1, 3, 5];
which binds x=1, y=3, zs=[5].

Using pattern matching, we looked at how to write functions that specify their result through a series of cases. For example, we know that the Fibonacci sequence begins with the values 1, 1 and that each subsequent value is the sum of the previous two. We could write this with an if/else:

        fun fib(n) =
                if n = 1 orelse n = 2 then 1
                else fib(n - 1) + fib(n - 2);
but we can also write this as a series of three cases, each with a different pattern:

        fun fib(0) = 1 | fib(1) = 1 | fib(n) = fib(n - 1) + fib(n - 2);
Notice the two pipe or vertical bar characters ("|") that separate the three different cases. We usually write this with each case on a separate line and we line up the pipe characters with the keyword "fun", as in:

        fun fib(0) = 1
        |   fib(1) = 1
        |   fib(n) = fib(n - 1) + fib(n - 2);
We used this same approach to write the list length function with cases for an empty list versus a nonempty list:

        fun len([]) = 0
        |   len(x::xs) = 1 + len(xs);
Then we discussed how to write functions to return the values from a list in odd positions. I said that we'd assume that we are using one-based indexing, so the values in odd positions are the first, third, fifth, and so. Using patterns, we wrote the following function for odds:

        fun odds([]) = []
        |   odds([x]) = [x]
        |   odds(x::y::zs) = x::odds(zs);
As a final example, I talked about writing a split function that would split a list into two lists: those in odd positions and those in even positions. Since it has to return two things, we'll assume it returns a tuple. For example, the call split([1, 8, 6, 4, 9]) should return ([1, 6, 9], [8, 4]). Someone said that we could modify our odds function and write a similar evens function and then define split as follows:

        fun split(lst) = (odds(lst), evens(lst));
That would work, but it would be inefficient. It would scan through the list twice when it only needs to do so once. So we started to write it from scratch. We included a case for the empty list:

        fun split([]) = ([], [])
and then I asked people to think about the general case where we have at least two values:

        fun split([]) = ([], [])
        |   split(x::y::zs) = ?
I asked how we write this. Someone suggested that we make a recursive call passing in zs. That seems like a good idea. But then what do we do with the result? The result is a tuple. That's not particularly easy to work with. We could store the tuple in a variable using let:

        fun split([]) = ([], [])
        |   split(x::y::zs) = 
                let val result = split(zs)
                in ?
                end;
This would work, but we'd still have to use functions like #1 and #2 to pull apart the result. We can do even better by using a pattern that mentions the two parts of the tuple:

        fun split([]) = ([], [])
        |   split(x::y::zs) = 
                let val (M, N) = split(zs)
                in ?
                end;
This will recursively split the list and then bind the variables M and N to be the two parts of the resulting tuple. The overall result in this case is a new tuple that puts x at the front of M and y at the front of N:

        fun split([]) = ([], [])
        |   split(x::y::zs) = 
                let val (M, N) = split(zs)
                in (x::M, y::N)
                end;
When we tried to load this into the interpreter, we got the warning that the matches are not exhaustive. I said that for functions, you want to pay close attention to this. Maybe it's okay because you have a precondition that a certain case won't happen, but then be sure you've thought about it. In this case, when we tried to split a list, we got an error:

        uncaught exception Match [nonexhaustive match failure]
The problem is that we need a case in the original for a one-element list:

        fun split([]) = ([], [])
        |   split([x]) = ([x], [])>
        |   split(x::y::zs) = 
                let val (M, N) = split(zs)
                in (x::M, y::N)
                end;
This version of the function worked fine.


Stuart Reges
Last modified: Sun Jan 21 10:22:45 PST 2007