So far the only real data structures we can build are made of
tuples. But tuples don't let us make data structures whose
size is not known at compile time. For that we need a new language
feature.
One simple data structure that we're used to is singly linked lists.
It turns out that SML
has lists built in. For example, in SML the expression []
is an
empty list. The expression [1,2,3]
is a list containing three
integers. Another name for the empty list is nil
. This is just the same thing as []
.
In SML, all the elements of a list have to have the same type. For
example, a list of integers has the type int list
.
Similarly, the list ["hi", "there", "312"]
would have the type
string list
. But [1, "hi"]
is not a legal
list. Lists in SML are therefore homogeneous lists, as opposed to
heterogeneous lists in which each element can have a different
type.
Lists are immutable: you cannot change the elements of a list, unlike an array in Java.
Often we want to make a list out of smaller lists. We can concatenate
two lists with the @
operator. For example, [1,2,3] @
[4,5,6]
= [1,2,3,4,5,6]
. However, this operator
isn't very fast because it needs to build a copy of the entire first
list. More often we use the ::
operator, which prepends an
element to the front of an existing list (“prepend” means “append onto the front”).
The expression 1::[2,3]
is 1
prepended onto
the list [2,3]
. This is just the list [1,2,3]
.
For historical reasons going back to the language Lisp, we usually
call the ::
operator “cons”.
The fact that lists are immutable is in keeping with SML being a
functional language. It is also actually useful for making SML more efficient,
because it means that different list data structures can share parts of their
representation in the computer's memory. For example, evaluating h::t
only requires allocating space for a single extra list node in the computer's
memory. It shares the rest of the list with the existing list t
.
The best way to extract elements from a list is to use pattern matching.
The operator ::
and the bracket constructor can be used as
patterns in a case
expression. For example, if we wanted to
get the value 1 if we had a list of one element, and zero if we had an
empty list, we could write:
case lst of [] => 0 | [x] => 1
Here, x
would be bound to the single element of the list if
the second case arm were evaluated.
Often, functions that manipulate lists are recursive, because they need
to do something to every element. For example, suppose that we wanted to
compute the length of a list of strings. We could write a recursive function
that accomplishes this (in fact, the library function List.length
does just this):
(* Returns the length of lst *) fun length(lst: string list): int = case lst of [] => 0 | h::t => 1 + length(t)
The logic here is that if a list is empty ([]
),
its length is clearly zero. Otherwise, if it is the appending of an
element h onto another list t, its length must be one greater than the
length of t.
It's possible to write patterns using the bracket syntax. This is
exactly the same as writing a similar pattern using the ::
operator. For example, the following patterns are all equivalent:
[x,2]
, x::2::nil
, x::2::[]
,
x::[2]
. These expressions are also all equivalent when
used as terms.
The SML structure
List
contains many useful functions for
manipulating lists. Before using lists, it's worth taking a look.
Some of them we'll talk about later in more detail. Two functions
that should be used with caution are hd
and tl
.
These functions get the head and tail of a list, respectively. However,
they raise an exception if applied to an empty list. They make it
easy to forget about the possibility that the list might be empty, creating
expected exceptions that crash your program. So it's usually best to avoid them.
(* Returns the nth element of lst *)
fun nth(lst: string list, n: int): int =
case lst of
h::t => if n=0 then h else nth(t, n-1)
Lists are very useful, but it turns out they are not really as special as they look. We can implement our own lists, and other more interesting data structures, such as binary trees.
In recitation you saw 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 brackets like this to indicate optional syntax: [ ])
datatype
Y= X_{1}[
of
t_{1}] | ... | X_{n }[of
t_{n}]
and new term and pattern forms:
e ::= ... | X
(
e)
|case
eof
p_{1}=>
e_{1}_{ }| ... | p_{n}=>
e_{n} _{ } p ::= X | X(
x_{1}:t_{1}...,
x_{n}:t_{n})
Note that the vertical bars in the expression
"case
e of
p_{1}=>
e_{1 }| ... | p_{n}=>
e_{n}"
are part the syntax of this construct; the other vertical bars (|) are part of the BNF
notation. We can use datatypes to define many useful data structures. We saw in
recitation that the bool
type is really just a datatype, so we don't need to
have booleans built into SML the way we do in Java.
datatype intlist = Nil | Cons of (int * intlist)
This datatype has two constructors, Nil
and Cons
.
It is a recursive datatype because it mentions itself in its own
definition (in the Cons
constructor), just like a recursive
function is one that mentions itself in its own definition.
Any list of integers can be represented by using this datatype. For example,
the empty list is just the constructor Nil
, and Cons
corresponds to the operator ::
. Here are some examples of lists:
val list1 = Nil (* the empty list: []*) val list2 = Cons(1,Nil) (* the list containing just 1: [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)))))
So we can construct any lists we want. We can also take them apart using
pattern matching. For example, our length
function above can be
written for our lists by just translating the list patterns into the
corresponding patterns using datatype constructors:
(* Returns the length of lst *) fun length(lst: intlist): int = case lst of Nil => 0 | Cons(h,t) => 1 + length(t)Similarly, we can implement many other functions over lists, as shown in the following examples.
datatype intlist = Nil | Cons of (int * intlist)
(* 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. *)
(* Return the sum of the elements in the list *)
fun sum(xs:intlist):int =
case xs of
Nil => 0
| Cons(i:int,rest:intlist) => i + sum(rest)
(* 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)
(* Return the ith element of the list *)
fun ith(is: intlist, i:int):int =
case (i,is) of
(_,Nil) => raise Fail("empty list!")
| (1,Cons(i,tl)) => i
| (n,Cons(i,tl)) =>
if (n <= 0) then raise Fail("bad index")
else ith(tl, i - 1)
(* Append two lists: append([1,2,3],[4,5,6]) = [1,2,3,4,5,6] *)
fun append(list1:intlist, list2:intlist):intlist =
case list1 of
Nil => list2
| Cons(i,tl) => Cons(i,append(tl,list2))
(* Reverse a list: reverse([1,2,3]) = [3,2,1].
* Notice that we compute this by reversing the tail of the
* list first (e.g., compute reverse([2,3]) = [3,2]) and then
* append the singleton list [1] to the end to yield [3,2,1]. *)
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;
(* given [i1,i2,...,in] return [i1+1,i2+1,...,in+n] *)
fun addone_to_all(list:intlist):intlist =
case list of
Nil => Nil
| Cons(hd,tl) => Cons(inc(hd), addone_to_all(tl))
(* given [i1,i2,...,in] return [i1*i1,i2*i2,...,in*in] *)
fun square_all(list:intlist):intlist =
case list of
Nil => Nil
| Cons(hd,tl) => Cons(square(hd), square_all(tl))
(* given a function f and [i1,...,in], return [f(i1),...,f(in)].
* 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);
(* given [i1,i2,...,in] return i1+i2+...+in (also defined above) *)
fun sum(list:intlist):int =
case list of
Nil => 0
| Cons(hd,tl) => hd + sum(tl)
(* given [i1,i2,...,in] return i1*i2*...*in *)
fun product(list:intlist):int =
case list of
Nil => 1
| Cons(hd,tl) => hd * product(tl)
(* given f, b, and [i1,i2,...,in], return f(i1,f(i2,...,f(in,b))).
* Again, we factored out the common parts 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
(* Here, we use an anonymous function instead of declaring add and mul.
* After all, what's the point of giving those functions names if all
* we're going to do is pass them to collapse? *)
fun sum(list:intlist):int =
collapse((fn (i1:int,i2:int) => i1+i2),0,list);
fun product(list:intlist):int =
collapse((fn (i1:int,i2:int) => i1*i2),1,list);
(* And 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);
In fact, if we want our lists to look even more like SML lists, we could
even overload the ::
operator.
datatype intlist = nil | :: of int*intlist
Operator overloading can be convenient, but it is also dangerous because it can confuse someone trying to read the code. This particular use is probably not worth it.
Trees are another very useful data structure, and unlike lists, they are not built into SML. A binary tree is a node containing a value and two children that are trees. A binary tree can also be an empty tree, which we also use to represent the absence of a child node. We can write this down directly as a datatype:
datatype inttree = Empty | Node of { value: int, left: inttree, right: inttree }
We can use this datatype to represent any binary tree:
2 / \ Node {value = 2, left = Node {value=1, left = Empty, right = Empty}, 1 3 right= Node {value=3, left = Empty, right = Empty}}
Because there are several things stored in a tree node, it's helpful to use a record rather than a tuple to keep them all straight. But a tuple would also have worked.
We can use pattern matching to write the usual algorithms for recursively traversing trees. For example, here is a recursive search over the tree:
(* Return true if the tree contains x. *) fun search(t: inttree, x:int): bool = case t of Empty => false | Node {value, left, right} => (value = x) orelse search(left, x) orelse search(right, x)
Of course, if we knew the tree obeyed the binary search tree invariant, we could have written a more efficient algorithm.
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);
And 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
.
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 raise 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
.
Similarly we can define a function to add two numbers: (See if the students can come up with this with some coaching.)
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(n) => 1 + toInt(n)
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(n) => odd(n) and odd (n:nat) : bool = case n of Zero => false | Succ(n) => even(n)
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.
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))
It turns out that the syntax of ML patterns is richer than what we saw in the last lecture. In addition to new kinds of terms for creating and projecting tuple and record values, and creating and examining datatype values, we also have the ability to match patterns against values to pull them apart into their parts.
When used properly, ML pattern matching leads to concise, clear
code. This is because ML pattern matching allows
one pattern to 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 second 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.
Similarly, in our implementation of the nth
function, earlier,
a neat trick is to use pattern matching to do the
if n=0
and the case
at the same time.
We pattern-match on the tuple (lst, n)
:
(* Returns the nth element of lst *)
fun nth(lst: string list, n: int): int =
case (lst, n) of
(h::t, 0) => h
(h::t, _) => nth(t, n-1)
| (nil, _) => raise Fail "Can't get nth element of empty list"
Here, we've also added a clause to catch the empty list and raise
an exception. We're also using the wildcard pattern _
to match on the n
component of the tuple, because we don't
need to bind the value of n
to another variable—we already
have n
. We can make this code even shorter; can you see how?
Natural numbers aren't quite as good as integers, but we can simulate integers in terms of the naturals by using a representation consisting of a sign and magnitude:
datatype sign = Pos | Neg type integer = { sign : sign, mag : nat }
The type
keyword simply defines a name for a
type. Here we've defined integer to refer to a record type with two
fields: sign and mag. Remember that records are unordered, so
there is no concept of a "first" field.
Note that a
type
declaration is different from adatatype
declaration; it creates a new way to name an existing type, whereas adatatype
declaration creates a new type and also happens to give it a name, which is needed to support recursion. For example, we could write a declarationtype natural = nat
. The typenatural
andnat
would then be exactly the same type and usable interchangeably.
We can use the definition of integer
to write some integers:
val zero = {sign=Pos, mag=Zero} val zero' = {sign=Neg, mag=Zero} val one = {sign=Pos, mag=Succ(Zero)} val negOne = {sign=Neg, mag=Succ(Zero)}
Now we can write a function to determine the successor of any integer:
fun inc(i:integer) : integer = case i of {sign = _, mag = Zero} => {sign = Pos, mag = Succ(Zero)} | {sign = Pos, mag = n} => {sign = Pos, mag = Succ(n)} | {sign = Neg, mag = Succ(n)} => {sign = Neg, mag = n}
Here we're pattern-matching on a record type. Notice that in the third
pattern we are doing pattern matching because the mag
field is
matched against a pattern itself, Succ(n)
.
Remember that the patterns are tested in order. How does the meaning of
this function change if the first two patterns are swapped?
The predecessor function is very similar, and it should be obvious that we could write functions to add, subtract, and multiply integers in this representation.
Taking into account the ability to write complex patterns, we can now write down a more complete syntax for SML.
syntactic class | syntactic variables and grammar rule(s) | examples |
---|---|---|
identifiers | x, y | a , x , y , x_y , foo1000 , ... |
datatypes, datatype constructors | X, Y | Nil , Cons , list |
constants | c | ...~2 , ~1 , 0 , 1 , 2 (integers)1.0 , ~0.001 , 3.141 (reals)true , false (booleans)
"hello" , "" , "!" (strings)
#"A" , #" " (characters) |
unary operator | u | ~ , not , size , ... |
binary operators | b | + , * , - , > , < ,
>= , <= , ^ , ... |
expressions (terms) | e ::= c
| x | u e | e_{1} b e_{2}
| if e_{1} then e_{2} else e_{3} |
let d_{1}...d_{n} in e end |
e ( e_{1}, ..., e_{n}) |
( e_{1}, ..., e_{n})
| # n e | { x_{1}= e_{1}, ..., x_{n}= e_{n}} |
# x e | X( e) |
case e of p_{1}=> e_{1}_{ }| ... | p_{n}=> e_{n} |
~0.001 , foo , not b ,
2 + 2 , Cons(2, Nil) |
patterns |
p ::= c
| x | |
a:int , (x:int,y:int), I(x:int) |
declarations | d ::= val p
= e | fun y
p : t = e |
da tatype Y = X_{1} [of t_{1}] | ... | X_{n }[of
t_{n}] |
val one = 1 |
types | t ::= int | real
| bool
| string | char
| t_{1}-> t_{2}
| t_{1}* ...* t_{n}
| { x_{1}:t_{1}, x_{2}:t_{2},..., x_{n}:t_{n}} |
Y |
int , string , int->int , bool*int->bool |
values | v ::= c | ( v_{1}, ..., v_{n})
| { x_{1}= v_{1}, ..., x_{n}= v_{n}} |
X( v) |
2 , (2,"hello") , Cons(2,Nil) |
Note: pattern-matching real constants is not possible. So in the production "p ::= c | .." above, c is an integer, boolean, string, or character constant, but not real.