CSE341 Notes for Monday, 10/19/09

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 I discussed another programming language concept that is important to understand: the concept of type safety. The concept of type safety has generally replaced the older terminology about a language being strongly typed.

Type safety is a concept that is usually described in terms of its opposite. We talk about the concept of type errors and say that a language is type safe if it doesn't allow any type errors to occur. The poster child for type errors is C and its close relative C++, so it's easiest to beat up on C and C++ in talking about how not to achieve type safety.

I mentioned that Corky Cartwright who maintains the DrScheme program we'll be using later in the quarter (a fan of functional programming) once described type safety to me by saying that any given set of bits stored on the computer should not be interpreted in two different ways.

In C and C++, for example, you can use casting to make variables of one type refer to variables of another type. We first looked at this short program:

        #include <iostream>
        using namespace std;
        
        int main() {
          cout << "hello world" << endl;
          char text[4];
          text[0] = 'f';
          text[1] = 'u';
          text[2] = 'n';
          text[3] = '\0';
        
          int* p = (int*) text;
        
          cout << text << endl;
          cout << *p << endl;
        
          return 0;
        }
This program declares a variable that stores an array of four characters. Each character takes up one byte of memory in the computer (8 bits). So the overall array takes up 4 bytes in memory. That also happens to be the amount of space that an int takes up and in most implementations of C and C++. In the program above, I use casting to have an int pointer point to the same address as the array. Then when I ask it to print the value of p*, it reinterprets those 4 bytes as an int rather than as an array of 4 characters.

This code works in C++. It reports that p* is 14478028. What's happening underneath is that we store four characters as bits using their ASCII codes:

        'f' has code 01100110
        'u' has code 01110101
        'n' has code 01101110
        '\n' has code 00000000
When we reinterpret this as an int, we simply look at all 32 bits as:

        00000000011011100111010101100110
When viewed this way, it looks like the int value 14478028. So even though we used characters to construct these bits, we are now interpreting them in a completely different way.

Things got worse when we doubled the int value by adding this line of code:

        *p *= 2;
When we did that, the string no longer contained printing characters.

In a type-safe language like Java, casting is limited to casts that make sense. You can cast an int to a double or a double to an int, but in that case an actual conversion takes place (the bits in one form are converted to appropriate bits of the other form). You aren't allowed to cast a String to an int or to otherwise reinterpret bits without conversion.

Probably the more egregious error occurs in C and C++ when you access unitialized variables. For example, if you construct an array using C's malloc operator or using C++'s new operator, you are just assigned a chunk of memory. The memory isn't initialized in any way. But the memory you are allocated may have been used previously for some other data, so you have a set of "dirty" bits that are going to be potentially reinterpreted as being of another type. Java avoids this by initializing all arrays and objects when they are constructed and by insisting that local variables be intialized before they are used.

Another place that this comes up is with local variables. When you make two different method calls in a row:

        f1();
        f2();
The computer has to allocate the local variables for f1 while it executes and then deallocate them when it's done and then do the same for f2. This is generally done with a stack. You allocate space on the stack for f1's local variables while it executes, then pop the stack. Then do the same for f2. But that can have a curious side effect. If you fail to initialize your local variables in f2, then they have whatever value is left over from f1. In general, this will be a garbage value because the types won't match. In a type-safe language like Java, it is illegal to examine the value of a local variable without first initializing it.

I showed the following program to demonstrate that we can get the same reinterpretation of bits through the use of local variables:

        #include <iostream>
        using namespace std;
        
        void foo() {
          char text[4];
          text[0] = 'f';
          text[1] = 'u';
          text[2] = 'n';
          text[3] = '\0';
          cout << text << endl;
        }
        
        void bar() {
          int n;
          cout << n << endl;
        }
        
        int main() {
          foo();
          bar();
        
          return 0;
        }
The output was the same as in the previous program. The character array is a local variable in f1 and when f2 is called, those same bits are reused as a local variable of type int.

Then I showed the following C++ program that manages to pass values from one function to another through the use of local variables:

        #include <iostream>
        using namespace std;
        
        void f1() {
          int x;
          double y;
          x = 15;
          y = 38.9;
          cout << x << endl;
          cout << y << endl;
        }
        
        void f2() {
          int a;
          double b;
          cout << a << endl;
          cout << b << endl;
        }
        
        int main() {
          f1();
          f2();
          return 0;
        }
This program printed the values 15 and 38.9 twice even though they were initialized in f1 but not in f2. Of course, this only works because we declared the local variables in the exact same order (an int followed by a double). If you switch the order in f2, then you get something very different.

In fact, when we even just got rid of the printing in f1, we found that we had to throw in an extra dummy variable to get things to line up properly:

        #include <iostream>
        using namespace std;
        
        void f1() {
          int x;
          double y;
          double z;
          x = 15;
          y = 38.9;
        }
        
        void f2() {
          int a;
          double b;
          cout << a << endl;
          cout << b << endl;
        }
        
        int main() {
          f1();
          f2();
          return 0;
        }
This program also printed 15 and 38.9 twice but behaved differently if you remove the local variable z. Once you're in the Wild West of unsafe code, it's easy to find odd results like this.

One final example is that C and C++ don't check the bounds for simple arrays. You might have an array that is declared to have 100 elements, but C and C++ allow you to ask for element 5000 or element -250. It simply computes a location in memory and interprets the bits it finds there as being of the element type of the array. That's one of the reasons that it is so common for C and C++ programs to crash with a "segmentation fault", because the language allows you to make potentially dangerous references to random parts of memory.

We ran this short program to explore that:

        #include <iostream>
        using namespace std;
        
        int main() {
          int data[100];
          int n;
          cout << "n? ";
          cin >> n;
        
          cout << data[n] << endl;
          data[n] = 3;
          cout << "have a nice day" << endl;
        
          return 0;
        }
This program printed different values on different executions for all values of n and when n was outside the bounds of the array, we often got a segmentation fault.

These are examples of ways in which C and C++ are not type safe. You should not be able to refer to variables that were never initialized, you should not be able to arbitrarily reinterpret bits in a different way and you shouldn't be able to reach into random parts of memory and to treat it as any kind of data you like.

These concerns became far more important when the world wide web came along because all of a sudden we wanted to include applets in our web pages. To convince a user to run an arbitrary bit of code on their computer, you have to give them some kind of assurance that your program is well behaved. That was possible to do with Java because the designers of the language took type safety very seriously. The were able to make a convincing case that Java programs ran in a "sandbox" that severely limited the potential damage they could do. It's almost impossible to give similar guarantees about code written in languages like C and C++.

It was interesting to see Microsoft deal with this issue in the 1990's. At first they were experimenting with Java, but when they started making changes to the libraries Sun sued them. Instead, Microsoft designed a new language called C# that ended up looking a lot like Java. C# can make similar guarantees of type safety with one notable exception. C# has a special keyword "unsafe" that can be attached to methods and classes. This allows C# programs to work with legacy code written in unsafe languages, but programmers are encouraged to avoid unsafe code whenever possible.


Stuart Reges
Last modified: Tue Oct 20 14:17:36 PDT 2009