CSE341 Notes for Monday, 10/15/07

Then I said that I wanted to discuss an efficiency issue. I began with the concept of tail recursion. I said to consider a simple counting function:

        fun f1(n) =
            if n = 0 then 0
            else 2 + f1(n - 1);
This is a silly function to write because it just computes 2 * n, but it will allow us to perform an experiment. I then asked people to think about how we might write something like this with a loop. Someone said that we'd use some kind of sum variable, so it might look like this:

        int sum = 0;
        for (int i = 0; i < n; i++) {
            sum += 2;
        }
I said that for convenience, I wanted to rewrite this to go backwards:

        int sum = 0;
        for (int i = n; i > 0; i--) {
            sum += 2;
        }
Several times I've tried to make the point that you can turn this kind of loop code into a functional equivalent. If it was useful for the loop to have an extra variable for storing the current sum, then we can do the same thing with a helper function. We can have a 2-argument function that keeps track of the current sum in addition to the value of i. Using that idea, I wrote the following variation of f1:

        fun f2(n) =
            let fun helper(0, sum) = sum
        	|   helper(i, sum) = helper(i - 1, sum + 2)
            in helper(n, 0)
            end;
They both compute 2 * n in a similar manner, but they have very different behavior in the interpreter. The f1 function ran noticeably slower than f2, especially when we used very large input values like f1(5000000) vs f2(5000000). Why would that be? Think about what happens when we compute f1(5):

        f1(5) =
        2 + f1(4) =
        2 + 2 + f1(3) =
        2 + 2 + 2 + f1(2) =
        2 + 2 + 2 + 2 + f1(1) =
        2 + 2 + 2 + 2 + 2 + f1(0) =
        2 + 2 + 2 + 2 + 2 + 0 = 10
Notice how the computation expands as we make recursive calls. After we reach the base case, we'll have a lot of computing left to do on the way back out. But notice the pattern for f2:

        f2(5) =
        helper(5, 0) =
        helper(4, 2) =
        helper(3, 4) =
        helper(2, 6) =
        helper(1, 8) =
        helper(0, 10) = 10
There is no expansion to the computation. The key thing to notice is that once we reach the base case, we have the overall answer. There is no computation left as we come back out of the recursive calls. This is a classic example of tail recursion. By definition, a tail recursive function is one that performs no additional computation after the base case is reached.

It is well known that tail recursive functions are easily written as a loop. Functional languages like Scheme and ML optimize tail recursive calls by internally executing them as if they were loops (which avoids generating a deep stack of function calls).

I also mentioned that the versions of map, filter and reduce that I've shown and that appear in the Ullman book are not tail-recursive. The standard operators like List.map, List.filter, List.foldl and List.foldr are written in a tail-recursive manner to make them more efficient.

Then we turned to a new topic: data types. This moves us into chapter 6 of the Ullman book. The chapter begins by showing how to use the keyword "type" to introduce a type synonym. For example, if you found yourself often dealing with a tuple of 4 ints, you could say:

        type int4 = int * int * int * int;
This sets up a synonym int4 that stands for int * int * int * int. That means that you can then say things like:

        fun f(x:int4) = ...
The Ullman book also describes how to define polymorphic synonyms. The more interesting use of a type is to use the keyword "datatype" to define a set of constructors for a type. For example, you could define a color class by saying:

        - datatype color = Red | Blue | Green;
        datatype color = Blue | Green | Red
We again use the vertical bar or pipe character ("|") to separate different possibilities for the type. This type has three possible forms. This is the ML equivalent of an enum type. This definition introduces a new type called "color". by convention, we use lowercase letters for the first letter of a type. It also introduces three constructors called Red, Blue and Green. You can find out about them in the interpreter:

        - Red;
        val it = Red : color
You can also write functions that use these identifiers, as in:

        - fun f(x) = x = Red;
        val f = fn : color -> bool
This function is a predicate that tells you whether or not a certain function is Red. It has fairly predictable results:

        - f(Red);
        val it = true : bool
        - f(Blue);
        val it = false : bool
        - f(Green);
        val it = false : bool
        - f(Yellow);
        stdIn:10.3-10.9 Error: unbound variable or constructor: Yellow
I showed another example that involved assigning each color a tuple of integers that correspond to standard RGB sequences (three integers in the range of 0 to 255 that represent the red, blue, and green components of each):

        fun rgb(Red) = (255, 0, 0)
        |   rgb(Blue) = (0, 0, 255)
        |   rgb(Green) = (0, 255, 0);
I mentioned that ML has a construct known as a case expression that was described in chapter 5 of the textbook. The pattern matching that we are using in function definitions like rgb is really just
syntatic sugar for a case expression. The function definition above is converted into the following equivalent definition:

        fun rgb(c) =
            case c of
        	Red => (255, 0, 0)
              | Blue => (0, 0, 255)
              | Green => (0, 255, 0);
I am not a fan of the case expression, so I don't use it a lot, although other ML programmers like it.

I pointed out that the bool type in ML involves one of these datatype definitions:

        datatype bool = true | false;
And the if/else construct is really just another case of syntactic sugar. For an if/else of this form:

       if e1 then e2 else e3
ML replaces this with the following case expression:

       case e1 of
           true => e2
         | false => e3
I then turned to a more complex example. I said that I wanted to explore the definition of a binary search tree in ML. Ullman uses the example in the book, but he does it with curried functions and makes it polymorphic. I am going to keep it simple by having uncurried functions and a simple tree of ints.

I asked people what binary trees look like and someone said that they can be empty or they have a node with left and right subtrees. This becomes the basis of our type definition:

        datatype intTree = Empty | Node of int * intTree * intTree;
The name of the type is intTree. It has two different forms. The first form uses the constructor Empty and has no associated data. The second form uses the constructor Node and takes a triple composed of the data for this node (an int), the left subtree and the right subtree. Notice how the keyword "of" is used to separate the constructor from the data type description.

Given this definition, we could make an empty tree or a tree of one node simply by saying:

        - Empty;
        val it = Empty : intTree
        - Node(38, Empty, Empty);
        val it = Node (38,Empty,Empty) : intTree
Notice that we use parentheses to enclose the arguments to the Node constructor. The Node constructor is similar to a function, as the ML interpreter will verify:

        - Node;
        val it = fn : int * intTree * intTree -> intTree
It has a slightly different status, as we'll see. In particular, we can use constructors in patterns, which makes our function definitions much clearer.

For example, we wrote the following function to insert a value into a binary search tree of ints.

        fun insert(n, Empty) = Node(n, Empty, Empty)
        |   insert(n, Node(root, left, right)) =
                  if n <= root then Node(root, insert(n, left), right)
                  else Node(root, left, insert(n, right));
If we are asked to insert a value into an empty tree, we simply create a leaf node with the value. Otherwise, we compare the value against the root and either insert it into the left or right subtrees. In a language like Java, we would think of the tree as being changed (mutated). In ML, we instead think of returning a new tree that includes the new value.

I said that we would explore more about the intTree type in the next lecture.


Stuart Reges
Last modified: Fri Apr 17 15:03:50 PDT 2009