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 should have seen some simple examples of variant types sometimes known as algebraic datatypes or just datatypes. Variant types provide some needed power: the ability to have a variable that contains more than one kind of value.
Unlike tuple types and function types, but like record types, variant
types cannot be anonymous; they must be declared with their names. Suppose
we wanted to have a variable that could contain one of three values: Yes, No, or Maybe, very much like an enum in Java. Its type could be declared as a variant type:
# type answer = Yes | No | Maybe;; type answer = Yes | No | Maybe # let x: answer = Yes;; val x: ans = Yes
The type keyword declares a name for the new
type. The variant type is declared with a set of constructors that
describe the possible ways to make a value of that type. In this case, we
have three constructors: Yes, No, and Maybe.
Constructor names must start with an uppercase letter, and all other names in
OCaml must start with a lowercase letter.
The different constructors can also carry other values with them. For example, suppose we want a type that can either be a 2D point or a 3D point. It can be declared as follows:
type eitherPoint = TwoD of float * float
| ThreeD of float * float * float
Some examples of values of type eitherPoint are:
TwoD (2.1, 3.0) and ThreeD (1.0, 0.0, -1.0).
Suppose we have a value of type eitherPoint, which is either a TwoD of something or ThreeD of something. We need a way to extract the "something". This can be done with pattern matching. Example:
let lastTwoComponents (p : eitherPoint) : float * float =
match p with
TwoD (x, y) -> (x, y)
| ThreeD (x, y, z) -> (y, z)
We use X as a metavariable to represent the name of a constructor, and T to represent the name of a type. Optional syntactic elements are indicated by brackets []. Then a variant type declaration looks like this in general:
typeT= X1[oft1] | ... | Xn [oftn]
Variant types introduce new syntax for terms e, patterns p, and values v:
e ::= ... | X e |
matchewithp1->e1 | ... | pn->en
p ::= X | X(x1:t1...,xn:tn)
v ::= c | (v1,...,vn) |funp->e | X v
Note that the vertical bars in the expression
"match e with p1->e1 | ... | pn->en"
are part the syntax of this construct; the other vertical bars (|) are part of the BNF
notation.
We can use variant types to define many useful data structures. In fact, the
bool is really just a variant type with constructors named true and false.
type intlist = Nil | Cons of (int * intlist)
This type has two constructors, Nil and Cons.
It is a recursive type 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 type. For example,
the empty list is just the constructor Nil, and Cons
corresponds to the operator ::. Here are some examples of lists:
let list1 = Nil (* the empty list: [] *) let list2 = Cons (1, Nil) (* the list containing just 1: [1] *) let list3 = Cons (2, Cons(1,Nil)) (* the list [2; 1] *) let list4 = Cons (2, list2) (* also the list [2; 1] *) (* the list [1; 2; 3; 4; 5] *) let list5 = Cons (1, Cons (2, Cons (3, Cons (4, Cons (5, Nil))))) (* the list [6; 7; 8; 9; 10] *) let 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 intlists by just translating the list patterns into the
corresponding patterns using constructors.
Similarly, we can implement many other functions over lists, as shown in
the following examples.
Turn on Javascript to view the code.
Trees are another very useful data structure. Unlike lists, they are not built into OCaml. A binary tree is either
type inttree = Empty | Node of node
and node = { value: int; left: inttree; right: inttree }
The rule for when mutually recursive type declarations are
legal is a little tricky. Essentially, any cycle of recursive types must
include at least one record or variant type. Since the cycle between
inttree and node includes both kinds of types,
this declaration is legal.
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. *)
let rec search ((t: inttree), (x:int)): bool =
match t with
Empty -> false
| Node {value=v; left=l; right=r} ->
v = x || search (l, x) || search (r, 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 OCaml 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:
type 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. The type nat is a recursive type, which allows us to
build expressions that have an arbitrary number of nested Succ
constructors. Such values act like natural numbers:
let zero = Zero and one = Succ Zero and two = Succ(Succ Zero) let three = Succ two let four = Succ three
When we ask the interpreter what four represents, we get
four;; - : nat = Succ (Succ (Succ (Succ Zero)))
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 implementation is similar.
Now we can write functions to manipulate values of this type.
let isZero (n : nat) : bool =
match n with
Zero -> true
| Succ m -> false
Here we're pattern-matching a value with type nat.
If the value is Zero we evaluate to true; otherwise we evaluate to false.
let pred (n : nat) : nat =
match n with
Zero -> raise (Failure "Zero has no predecessor");
| 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:
let rec add (n1 : nat) (n2 : nat) : nat =
match n1 with
Zero -> n2
| Succ m -> add m (Succ n2)
If you were to try evaluating add four four, the interpreter would respond with:
add four four;; - : nat = Succ (Succ (Succ (Succ (Succ (Succ (Succ (Succ Zero)))))))
which is the nat representation of 8.
To better understand the
results of our computation, we would like to convert such values to type int:
let rec toInt (n : nat) : int =
match n with
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?
let rec toNat (i : int) : nat = if i < 0 then raise (Failure "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:
let rec even (n : nat) : bool =
match n with
Zero -> true
| Succ n -> odd n
and odd (n : nat) : bool =
match n with
Zero -> false
| Succ n -> even n
You have to use the keyword and to combine
mutually recursive functions like this. Otherwise the compiler would give an
error when you refer to odd before it has been defined.
Finally we can define multiplication in terms of addition.
let rec mul (n1 : nat) (n2:nat) : nat =
match n1 with
Zero -> Zero
| Succ m -> add n2 (mul m n2)
which gives
toInt (mul (toNat 5) (toNat 20));; - : int = 100
It turns out that the syntax of OCaml 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 variant type values, we also have the ability to match patterns against values to pull them apart into their parts.
When used properly, pattern matching leads to concise, clear
code. This is because OCaml 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 match at the same time.
We pattern-match on the tuple (lst, n):
(* Returns the nth element of lst *)
let rec nth lst n =
match (lst, n) with
(h :: t, 0) -> h
(h :: t, _) -> nth (t, n - 1)
| ([], _) -> raise (Failure "nth applied to 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:
type sign = Pos | Neg
type integer = { sign : sign; mag : nat }
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.
The declarations of sign and integer both
create new types. However, it is possible to write type declarations that
simply introduce a new name for an existing type. For example, if we wrote
type number = int, then the types number and
int could be used interchangeably.
We can use the definition of integer to write some integers:
let zero = {sign=Pos; mag=Zero}
let zero' = {sign=Neg; mag=Zero}
let one = {sign=Pos; mag=Succ Zero}
let negOne = {sign=Neg; mag=Succ Zero}
Now we can write a function to determine the successor of any integer:
let inc (i : integer) : integer =
match i with
{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 comprehensive syntax for OCaml.
| 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 (floats)true, false (booleans)
"hello", "", "!" (strings)
#"A", #" " (characters) |
| unary operator | u | ~, not, size, ... |
| binary operators | b | +, *, -, >, <,
>=, <=, ^, ... |
| expressions (terms) | e ::- c
| x | u e | e1 b e2
| if e1 then e2 else e3 |
let d1...dn in e end |
e (e1, ..., en) |
(e1,...,en)
| #n e | {x1=e1, ..., xn=en} |
#x e | X(e) |
match e with p1->e1 | ... | pn->en |
~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 |
datatype Y - X1 [of t1] | ... | Xn [of
tn] |
val one = 1 |
| types | t ::= int | float
| bool
| string | char
| t1->t2
| t1*...*tn
| {x1:t1, x2:t2,..., xn:tn} |
Y |
int, string, int->int, bool*int->bool |
| values | v ::= c | (v1,...,vn)
| {x1=v1, ..., xn=vn} |
X(v) |
2, (2,"hello"), Cons(2,Nil) |
Note: pattern-matching floating point constants is not possible. So in the production "p ::= c | .." above, c is an integer, boolean, string, or character constant, but not float.
There is a nice feature that allows us to avoid rewriting the same code over and over so that it works for different types. Suppose we want to write a function that swaps the position of values in an ordered pair:
let swapInt ((x : int), (y : int)) : int * int = (y, x) and swapReal ((x : float), (y : float)) : float * float = (y, x) and swapString ((x : string), (y : string)) : string * string = (y, x)This is tedious, because we're writing exactly the same algorithm each time. It gets worse! What if the two pair elements have different types?
let swapIntReal ((x : int), (y : float)) : float * int = (y, x) and swapRealInt ((x : float), (y : int)) : int * float = (y, x)And so on. There is a better way:
# let swap ((x : 'a), (y : 'b)) : 'b * 'a = (y, x);; val swap : 'a * 'b -> 'b * 'a = <fun>Instead of writing explicit types for
x and y, we write
type variables 'a
and 'b. The type of
swap is 'a * 'b -> 'b * 'a. This means that we can use swap as if it had any type that we could get by
consistently replacing 'a and 'b in its type with a
type for 'a and a type for 'b. We can use the new swap
in place of all the old definitions:
swap (1, 2) (* (int * int) -> (int * int) *)
swap (3.14, 2.17) (* (float * float) -> (float * float) *)
swap ("foo", "bar") (* (string * string) -> (string * string) *)
swap ("foo", 3.14) (* (string * float) -> (float * string) *)
In fact, we can leave out the type declarations in the definition of
swap, and OCaml will figure out the most general polymorphic
type it can be given, automatically:
# let swap (x, y) = (y, x);; val swap : 'a * 'b -> 'b * 'a = <fun>
The ability to use swap as though it had many different types is known as polymorphism, from the Greek for "many forms".
Notice that the type variables must be substituted consistently in any use of a polymorphic expression. For example, it is impossible for swap to have the type (int * float) -> (string * int), because that type would
consistently substitute for the type variable 'a but not for 'b.
OCaml programmers typically read the types 'a and 'b as "alpha" and
"beta". This is easier than saying "single quotation mark
a" or "apostrophe a".
They also they wish they could write Greek letters instead. A type variable may be any identifier preceded by a single quotation mark; for
example, 'key and 'value are also legal type
variables. The OCaml compiler needs to have these identifiers preceded by a single
quotation mark so that it knows it is seeing a type variable.
It is important to note that to be polymorphic in a parameter x, a function may not use x in any way that would identify its type. It must treat x as a black box. Note that swap doesn't use its arguments x or y in any
interesting way, but treats them as black boxes. When the OCaml type
checker is checking the definition of swap, all it knows is that x is of some
arbitrary type 'a. It doesn't allow any operation to be performed on
x that
couldn't be performed on an arbitrary type. This means that the code is
guaranteed to work for any x and y. However, we can apply other polymorphic functions. For example,
# let appendToString ((x : 'a), (s : string), (convert : 'a -> string)) : string =
(convert x) ^ " " ^ s;;
val appendToString : 'a * string * ('a -> string) -> string = <fun>
# appendToString (3110, "class", string_of_int);;
- : string = "3110 class"
# appendToString ("ten", "twelve", fun (s : string) -> s ^ " past");;
- : string = "ten past twelve"
We can also define polymorphic datatypes. For example, we defined lists of integers as
type intList = Nil | Cons of (int * intList)But we can make this more general by using a parameterized variant type instead:
type 'a list_ = Nil | Cons of ('a * 'a list_)
A parameterized datatype is a recipe for creating a family of related
datatypes. The name 'a is a type parameter for which any
other type may be supplied. For example, int list_ is a list of
integers, float list_ is a list of float, and so on. However, list_
itself is not a type. Notice also that we cannot use list_ to create
a list each of whose elements can be any type. All of the elements of a T
list_ must be T's.
let il : int list_ = Cons (1, Cons (2, Cons (3, Nil))) (* [1; 2; 3] *)
let fl : float list_ = Cons (3.14, Cons (2.17, Nil)) (* [3.14; 2.17] *)
let sl : string list_ = Cons ("foo", Cons ("bar", Nil)) (* ["foo"; "bar"] *)
let sil : (string * int) list_ =
Cons (("foo", 1), Cons (("bar", 2), Nil)) (* [("foo", 1); ("bar", 2)] *)
Notice list_ itself is not a type. We can think of list_ as a function that, when applied to a
type like int, produces another type (int list_). It is a parameterized type constructor: a function that takes in
parameters and gives back a type. Other languages have parameterized type
constructors. For example, in Java you can declare a parameterized class:
class List<T> {
T head;
List <T> tail;
...
}
In OCaml, we can define polymorphic functions that know how to manipulate any kind of list:
For trees,
type 'a tree = Leaf | Node of ('a tree) * 'a * ('a tree)
If we use a record type for the nodes, the record type also must be parameterized, and instantiated on the same element type as the tree type:
type 'a tree = Leaf | Node of 'a node
and 'a node = {left: 'a tree; value: 'a; right: 'a tree}
It is also possible to have multiple type parameters on a parameterized type, in which case parentheses are needed:
type ('a, 'b) pair = {first: 'a; second: 'b};;
let x = {first=2; second="hello"};;
val x: (int, string) pair = {first=2; second="hello"}
Earlier we noticed that there is a similarity between BNF declarations and variant type declarations. In fact, we can define variant types that act like the corresponding BNF declarations. The values of these variant types then represent legal expressions that can occur in the language. For example, consider a BNF definition of legal OCaml type expressions:
| (base types) | b ::= int | float | string
| bool | char
|
| (types) | t ::= b | t ->
t | t1 * t2
*...* tn
| { x1 : t1;
...;
xn : tn
} | X
|
This grammar has exactly the same structure as the following type declarations:
type id = string
type baseType = Int | Real | String | Bool | Char
type mlType = Base of baseType | Arrow of mlType*mlType
| Product of mlType list | Record of (id * mlType) list
| DatatypeName of id
Any legal OCaml type expression can be represented by a value of type Type
that contains all the information of the corresponding type expression. This value is known as
the abstract syntax for that expression. It is abstract because it
doesn't contain any information about the actual symbols used to represent the
expression in the program. For example, the abstract syntax for the expression int * bool -> {name : string} would be:
Arrow (Product (Cons (Base Int, Cons (Base Bool, Nil))),
Record (Cons (("name", Base String), Nil)))
The abstract syntax would be exactly the same even for a more verbose version
of the same type expression: ((int * bool) -> {name : string}). Compilers typically use abstract syntax internally to represent the program
that they are compiling. We will see a lot more abstract syntax later in the
course when we see how OCaml works.