CS312 Lecture 3
Recursive Datatypes: Modeling Integers and Lists

Recursive datatypes

In recitation you should have seen some simple examples of datatypes, which are SML types that can have more than one kind of value. This involved a new kind of declaration, a datatype declaration (where we've written bold brackets to indicate optional syntax):

datatype Y = X1 [of t1| ... | Xn [of tn]

We can use datatypes to define many useful data structures.  For instance the bool type is really just a datatype, so we don't need to have booleans built into SML. To illustrate recursive datatypes we can even define data structures that act like numbers, demonstrating that we don't really have to have numbers built into SML either! A natural number is either the value zero or the successor of some other natural number. This definition leads naturally to the following definition for values that act like natural numbers nat:

datatype nat = Zero | Succ of nat

This is how you might define the natural numbers in a mathematical logic course. We have defined a new type nat, and Zero and Succ are constructors for values of this type. This datatype is different than the ones we saw in recitation: the definition of nat refers to nat itself. In other words, this is a recursive datatype. This allows us to build expressions that have an arbitrary number of nested Succ constructors. Such values act like natural numbers:

val zero = Zero
val one = Succ(Zero)
val two = Succ(Succ(Zero))
val three = Succ(two)
val four = Succ(three)

When we ask the compiler what four represents, we get

- four;
val it = Succ (Succ (Succ (Succ Zero))) : nat

Thus four is a nested data structure. The equivalent Java definitions would be

public interface nat { }
public class Zero implements nat { }
public class Succ implements nat { nat v; Succ(nat v) { v = this.v; } }

nat zero = new Zero();
nat one = new Succ(new Zero());
nat two = new Succ(new Succ(new Zero()));
nat three = new Succ(two);
nat four = new Succ(three);

In fact the Java objects representing the various numbers are actually implemented similarly to the SML values representing the corresponding numbers.

Now we can write functions to manipulate values of this type.

fun iszero(n : nat) : bool = 
  case n of
    Zero => true
  | Succ(m) => false

The case expression allows us to do pattern matching on expressions. Here we're pattern-matching a value of type nat. If the value is Zero we evaluate to true; otherwise we evaluate to false. Note that Succ(m) is a pattern match, not a function call.

fun pred(n : nat) : nat = 
  case n of
    Zero => raise Fail "predecessor on zero"
  | Succ(m) => m

Here we determine the predecessor of a number. If the value of n matches Zero then we indicate an error by raising an exception, since zero has no predecessor in the natural numbers. If the value matches Succ(m) for some value m (which of course also must be of type nat), then we return m.

raise is a built-in primitive that raises an exception.  Generally exceptions must be declared before they can be raised, but Fail is a built-in type of exception.  One could define Fail as:

exception Fail of string 

In addition to raising exceptions, exceptions can be handled. This provides a non-local form of control, so that errors can be anticipated and dealt with appropriately.  An exception that is not handled causes an error at the top level interpreter prompt.  We will cover more about exceptions soon, but here just use them to indicate an error situation.

We can also define a function to add two numbers: (See if the students can come up with this.)  Note there are several ways to solve this.

fun add(n1:nat, n2:nat) : nat = 
  case n1 of
    Zero => n2
  | Succ(n_minus_1) => add(n_minus_1, Succ(n2))

If you were to try evaluating add(four,four), the compiler would respond with:

- add(four,four);
val it = Succ (Succ (Succ (Succ (Succ #)))) : nat

The compiler correctly performed the addition, but it has abbreviated the output because the data structure is nested so deeply. To easily understand the results of our computation, we would like to convert such values to type int:

fun toInt(n:nat) : int = 
  case n of
    Zero => 0
  | Succ(m) => 1 + toInt(m)

That was pretty easy. Now we can write toInt(add(four,four)) and get 8. How about the inverse operation?

fun toNat(i:int) : nat =
  if i < 0 then raise Fail "toNat on negative number"
  else if i = 0 then Zero
  else Succ(toNat(i-1))

To determine whether a natural number is even or odd, we can write a pair of mutually recursive functions:

fun even(n:nat) : bool =
  case n of
    Zero => true
  | Succ(m) => odd(m)
and odd (n:nat) : bool =
  case n of
    Zero => false
  | Succ(m) => even(m)

You have to use the keyword and to combine mutually recursive functions like this. Otherwise the compiler would flag an error when you refer to odd before it has been defined (as normally functions in SML need to be defined before they can be used).

Finally we can define multiplication in terms of addition. (See if the students can figure this out.)

fun mul(n1:nat, n2:nat) : nat =
  case n1 of
    Zero => Zero
  | Succ(n1MinusOne) => add(n2, mul(n1MinusOne,n2))

When used properly, ML pattern matching leads to concise, clear code.  This is because  ML supports deep pattern matching in which one pattern can appear as a subexpression of another pattern. For example, we see above that Succ(n) is a pattern, but so is Succ(Succ(n)). This pattern matches only on a value that has the form Succ(Succ(v)) for some value v (that is, the successor of the successor of something), and binds the variable n to that something, v.


Another recursive datatype: integer lists

We can use recursive datatypes and pattern matching to define some useful data structures. One simple data structure that we're used to is singly linked lists. It turns out that SML has lists built in and we will use them rather extensively. Today however, rather than using SML's built-in lists we will write our own list of integers using datatypes.  We want to define values that act like linked lists of integers. A linked list is either empty, or it has an integer followed by another list containing the rest of the list elements. This leads to a very natural datatype declaration.

(* This datatype defines integer lists as either Nil (empty) or
 * a "Cons" cell containing an integer and an integer list.  The
 * term "Cons" comes from Lisp.
 *)
datatype intlist = Nil | Cons of (int * intlist)

(* Here are some example lists *)
val list1 = Nil 		(* the empty list *)
val list2 = Cons(1,Nil) 	(* the list containing just 1  *)
val list3 = Cons(2,Cons(1,Nil)) (* the list 2,1 *)
val list4 = Cons(2,list2)       (* also the list 2,1 *)
(* the list 1,2,3,4,5 *)
val list5 = Cons(1,Cons(2,Cons(3,Cons(4,Cons(5,Nil)))))
(* the list 6,7,8,9,10 *)
val list6 = Cons(6,Cons(7,Cons(8,Cons(9,Cons(10,Nil)))))

(* test to see if the list is empty *)
fun is_empty(xs:intlist):bool = 
    case xs of
      Nil => true
    | Cons(_,_) => false

(* Return the number of elements in the list *)
fun length(xs:intlist):int = 
    case xs of
      Nil => 0
    | Cons(i:int,rest:intlist) => 1 + length(rest)

(* Notice that the case expressions for lists all have the same
 * form -- a case for the empty list (Nil) and a case for a Cons.
 * Also notice that for most functions, the Cons case involves a
 * recursive function call. *)

(* Create a string representation of a list *)
fun toString(xs: intlist):string = 
    case xs of
      Nil => ""
    | Cons(i:int, Nil) => Int.toString(i)
    | Cons(i:int, Cons(j:int, rest:intlist)) => 
       Int.toString(i) ^ "," ^ toString(Cons(j,rest))
    
(* Return the first element (if any) of the list *)
fun head(is: intlist):int = 
    case is of
      Nil => raise Fail "empty list!"
    | Cons(i,tl) => i

(* Return the rest of the list after the first element *)
fun tail(is: intlist):intlist = 
    case is of
      Nil => raise Fail "empty list!"
    | Cons(i,tl) => tl

(* Return the last element of the list (if any) *)
fun last(is: intlist):int = 
    case is of
      Nil => raise Fail "empty list!"
    | Cons(i,Nil) => i
    | Cons(i,tl) => last(tl)

(* Append two lists to form a single list with the elements of both*)
fun append(list1:intlist, list2:intlist):intlist = 
    case list1 of
      Nil => list2
    | Cons(i,tl) => Cons(i,append(tl,list2))

(* Reverse a list.
 * Notice that we compute this by reversing the tail of the
 * list first. *)
fun reverse(list:intlist):intlist = 
    case list of
      Nil => Nil
    | Cons(hd,tl) => append(reverse(tl), Cons(hd,Nil)) 

fun inc(x:int):int = x + 1;
fun square(x:int):int = x * x;

fun addone_to_all(list:intlist):intlist = 
    case list of
      Nil => Nil
    | Cons(hd,tl) => Cons(inc(hd), addone_to_all(tl))

fun square_all(list:intlist):intlist = 
    case list of
      Nil => Nil
    | Cons(hd,tl) => Cons(square(hd), square_all(tl))
Note that there is a common pattern to addone_to_all and square_all.  We can abstract this pattern out using what is called a higher-order function, a function that takes a function as an argument or returns a function as a value.  This is a simple case of a function that takes one function as an argument.  Note the type of the function is based on the types of its argument(s) and value, as we have already seen.
(* given a function f and a list, return a new list that results
 * from applying f to each element of the input list.
 * Notice how we factored out the common parts of addone_to_all
 * and square_all. *)
fun do_function_to_all(f:int->int, list:intlist):intlist = 
    case list of
      Nil => Nil
    | Cons(hd,tl) => Cons(f(hd), do_function_to_all(f,tl))

(* now we can define addone_to_all in terms of do_function_to_all *)
fun addone_to_all(list:intlist):intlist = 
    do_function_to_all(inc, list);

(* same with square_all *)
fun square_all(list:intlist):intlist = 
    do_function_to_all(square, list);

This pattern is often called "map", and it is widely used, not only in functional programming but also in large-scale parallel processing (think thousands of processors, like a Google server-farm).  We will talk more about this paradigm on Thursday.

Another pattern appropriate for higher-order functions is:

(* given a list return the sum of all the elements in the list *)
fun sum(list:intlist):int = 
    case list of
      Nil => 0
    | Cons(hd,tl) => hd + sum(tl)

(* given a list return the product of all the elements in the list *)
fun product(list:intlist):int = 
    case list of
      Nil => 1
    | Cons(hd,tl) => hd * product(tl)

(* given a function f and a list use the function to combine the
 * elements into a single value.
 * Again we factored out the common parts, this time of sum and product. *)
fun collapse(f:(int * int) -> int, b:int, list:intlist):int = 
    case list of
      Nil => b
    | Cons(hd,tl) => f(hd,collapse(f,b,tl))

(* Now we can define sum and product in terms of collapse *)
fun sum(list:intlist):int = 
    let fun add(i1:int,i2:int):int = i1 + i2
    in 
        collapse(add,0,list)
    end

fun product(list:intlist):int = 
    let fun mul(i1:int,i2:int):int = i1 * i2
    in
        collapse(mul,1,list)
    end
This pattern, sometimes referred to as "reduce", is also widely used in large-scale parallel processing. 

Here are some more involved things using collapse that we will spend more time on in recitation:
(* Here we just pass the operators directly... *)
fun sum(list:intlist):int = collapse(op +, 0, list);

fun product(list:intlist):int = collapse(op *, 1, list);

(* We can define length in terms of collapse and do_function_to_all*)
fun length(list:intlist):int =
    collapse(op +, 0, do_function_to_all((fn (i:int) => 1),list))