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):
da
tatype
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.
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) endThis pattern, sometimes referred to as "reduce", is also widely used in large-scale parallel processing.
(* 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))