CSE341 Notes for Friday, 1/19/07

I devoted the first part of the lecture to 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. So I asked people what a type error might look like. One person mentioned the idea that in C and C++ you can cast values rather freely. That's a pretty good example of the kind of type error that we're talking about.

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. Consider, for example, the following C++ code that initializes a character array of length 4 and prints it out:

        char x[4];
        x[0] = 'f';
        x[1] = 'u';
        x[2] = 'n';
        x[3] = '\0';
        cout << x << endl;
The line that begins with "cout" is the C++ equivalent of a Java println statement. As you'd expect, this program produces the following output:

        fun
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 C++, you can ask the language to reinterpret those 4 bytes as an int rather than as an array of 4 characters:

        cout << (int) x << endl;
This program works in C++. It produces the following output:

        -1079956272
The thing that makes this so bad in C++ is that the compiler doesn't do anything to convert the data from one form to another. It is allowing you to simply pretend that those 4 bytes represented an int instead of an array of characters.

But this isn't the worst kind of type error that can happen and some wouldn't consider it an error at all. At least with a cast the programmer has clearly requested that a value of one type be reinterpreted as being of some other type. Java allows casting of types and that doesn't prevent it from being type safe (although in the case of Java, the compiler actually does something useful when you cast and there are limits on what can be cast to what).

I showed some more egregious examples. First I showed this pair of functions:

        void f1() {
            int x = 8193;
            int y = 224466;
            double n = 407.23;
        }

        void f2() {
            char a[4];
            int b;
            double c;
            cout << a << " " << b << " " << c << endl << endl;
        }
The first function doesn't produce any output. The second prints variables that were never initialized. I then set up the main function as follows:

        int main() {
            f2();
            f1();
            f2();
        }
For the first call on f2, we got very strange output:

        X???r?C 1138324175 6.64617e-316
This shouldn't be terribly surprising because we're printing variables that were never initialized (something that is impossible to do in a type safe language like Java). I asked people why printing the character array ended up printing more than four characters. The reason is that in C and C++, a string is supposed to be terminated by a special character with ordinal value 0 ('\0'). Since this is just using random values stored in memory, the print statement continues to print characters until it comes across a byte that happens to be 0.

We then looked at the output that was produced by the second call on f2 after f1 had been called:

  224466 407.23
Here the character string encountered a null character almost immediately and then it printed out two numbers that look familiar. The values 224466 and 407.23 appear in the other function, f1. How did they end up here in f2? Someone pointed out that the values were still sitting on the stack. In this case, we just happened to have a new int variable corresponding to something that used to be an int and a new double that corresponds to something that used to be a double, but if we were to reverse the order of the declarations, we'd get the opposite result. This is an example of a major type error, where "old bits" from a previous function call are reinterpreted in a new function call.

Then we ran a function f3:

        void f3() {
            int a[4];
            cout << a[0] << " " << a[1] << " " << a[2] << " " << a[3] << endl;
            for (int i = 0; i < 10; i++) {
                a[i] = 0;
            }
        }
This function also prints out an uninitialized character array and then has a loop that attempts to set array elements 0 to 9 to 0. The problem, of course, is that the array has only 4 elements. In Java this would throw an IndexOutOfBoundsException. Not in C and C++. In fact, it generated an infinite loop. I added a print statement in the loop to print the value of i and found that it printed out the values 0, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, and so on. Why would that happen? Obviously a[4] (which is an illegal array index) is referring to i. When we set it to 0, we set i back to 0. Then i is incremented by the loop update part and we start the loop back at 1. It goes up to 4, resets i to 0, and we do it again.

As a final example, I showed this variation that assigned using indexes that are much larger than the size of the array:

        void f4() {
            int a[4];
            for (int i = 1500; i < 3000; i++) {
                cout << ".";
                a[i] = 0;
            }
          }
It was interesting to run this program. Sometimes we'd get 1500 dots. Other times we'd immediately get a "segmentation fault" (a fatal error). Other times we'd get some dots and then a segmentation fault. Each execution was different.

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 they started making changes to the libraries and 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.

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 then discussed a built-in polymorphic type known as the option type. It solves a certain problem that comes up in programming. Consider the problem of reading data from a file. Generally there is data to read, but what about when you reach the end of the file? What should be returned in that case? I asked people if they knew what happens when you call the Scanner class' method nextLine when end of file is true. Someone pointed out that it throws an exception. I asked if people knew what the built-in System.in variable returns when you call its read method. Nobody seemed to know. I said that the read method returns a value of type int. When you reach end of file, the method returns -1 as a way to say, "There is no more legal input to read."

Neither of these approaches is particularly elegant. What we want is the ability to return different things in different cases. If reading succeeds, we return a value. If it fails, we return a value that would correspond to "nothing". This is what the option type is used for in ML.

As Ullman explains in chapter 4, the TextIO structure has a function called openIn that can be used to open a text file. Once you do that, you can use the function readLine to read individual lines. The return type of readLine is a "string option". That means that sometimes it returns a string, sometimes it doesn't. The two constructors that are used for the option type are NONE and SOME. In fact, you can ask about these in the ML interpreter:

        - NONE;
        val it = NONE : 'a option
        - SOME;
        val it = fn : 'a -> 'a option
The constructor NONE is like our color constants. It doesn't have a value associated with it. But the SOME constructor takes a value of type 'a and returns a 'a option. Here are some examples:

        - SOME 3;
        val it = SOME 3 : int option
        - SOME "hello";
        val it = SOME "hello" : string option
        - SOME 45.8;
        val it = SOME 45.8 : real option
It takes a while for people to get used to the option type because languages like Java don't have anything that is like it. You can think of it as a list that will always be either empty or of length 1. Then why not use a list? Because that's not as specific as saying that it will always be exactly 0 or 1 of these values.

To extract a value from an option, you can call the function valOf. So you might say:

        - val x = SOME 82;
        val x = SOME 82 : int option
        - valOf(x);
        val it = 82 : int
We often don't need the valOf function because we instead use pattern matching to define a function that operates on an option, as in: fun f(NONE) = 0 | f(SOME n) = 2 * n; 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.

To insert a sequence of values, you can use list recursion calling the insert function repeatedly:

        fun insertAll([]) = Empty
        |   insertAll(x::xs) = insert(x, insertAll(xs));
I mentioned that this can be rewritten with a call on List.foldl or List.foldr. We'll discuss that in Monday's lecture.

We were running short of time, so I quickly showed people the following function for finding the depth of a binary tree:

    fun depth(Empty) = 0
    |   depth(Node(root, left, right)) = 1 + Int.max(depth(left), depth(right));
In the interpreter, I constructed a tree with 100,000 random values and asked for its depth by saying:

        val x = insertAll(randList(100000));
        depth(x);
We found that the depth was around 40 even though we haven't done anything special to balance the tree. I said that we'd discuss this example in more detail in lecture on Monday.


Stuart Reges
Last modified: Sun Jan 21 22:40:51 PST 2007