Variants
Topics:
- type synonyms
- variants
- catch-all cases
- recursive variants
- parameterized variants
- built-in types that are variants
- exceptions
- trees
- natural numbers
Type synonyms
A type synonym is a new name for an already existing type. For example, here are some type synonyms that might be useful in representing some types from linear algebra:
type point = float * float
type vector = float list
type matrix = float list list
Anywhere that a float*float
is expected, you could use point
, and vice-versa.
The two are completely exchangeable for one another. In the following code,
getx
doesn't care whether you pass it a value that is annotated as
one vs. the other:
let getx : point -> float =
fun (x,_) -> x
let pt : point = (1.,2.)
let floatpair : float*float = (1.,3.)
let one = getx pt
let one' = getx floatpair
Type synonyms are useful because they let us give descriptive names to complex types. They are a way of making code more self-documenting.
Variants
Thus far, we have seen variants simply as enumerating a set of constant values, such as:
type day = Sun | Mon | Tue | Wed
| Thu | Fri | Sat
type ptype = TNormal | TFire | TWater
type peff = ENormal | ENotVery | Esuper
But variants are far more powerful that this. Our main goal today is to see all the various things that variants can do.
As a running example, here is a variant type that does more than just enumerate values:
type shape =
| Point of point
| Circle of point * float (* center and radius *)
| Rect of point * point (* lower-left and
upper-right corners *)
This type, shape
, represents a shape that is either a point, a circle,
or a rectangle. A point is represented by a constructor Point
that
carries some additional data, which is a value of type point
.
A circle is represented by a constructor Circle
that carries
a pair of type point * float
, which according to the comment
represents the center of the circle and its radius. A rectangle
is represented by a constructor Rect
that carries a pair of type
point*point
.
Here are a couple functions that use the shape
type:
let area = function
| Point _ -> 0.0
| Circle (_,r) -> pi *. (r ** 2.0)
| Rect ((x1,y1),(x2,y2)) ->
let w = x2 -. x1 in
let h = y2 -. y1 in
w *. h
let center = function
| Point p -> p
| Circle (p,_) -> p
| Rect ((x1,y1),(x2,y2)) ->
((x2 -. x1) /. 2.0,
(y2 -. y1) /. 2.0)
The shape
variant type is the same as those we've seen before in that
it is defined in terms of a collection of constructors. What's different
than before is that those constructors carry additional data along with them.
Every value of type shape
is formed from exactly one of those constructors.
Sometimes we call the constructor a tag, because it tags the data it carries
as being from that particular constructor.
Variant types are sometimes called tagged unions. Every value of the type
is from the set of values that is the union of all values from the underlying
types that the constructor carries. For the shape
type, every value
is tagged with either Point
or Circle
or Rect
and carries a value
from the set of all point
valued unioned with the set of all point*float
values unioned with the set of all point*point
values.
Another name for these variant types is an algebraic data type. "Algebra" here refers to the fact that variant types contain both sum and product types, as defined in the previous lecture. The sum types come from the fact that a value of a variant is formed by one of the constructors. The product types come from that fact that a constructor can carry tuples or records, whose values have a sub-value from each of their component types.
Using variants, we can express a type that represents the union of several
other types, but in a type-safe way. Here, for example, is a type that
represents either a string
or an int
:
type string_or_int =
| String of string
| Int of int
If we wanted to, we could use this type to code up lists (e.g.) that contain either strings or ints:
type string_or_int_list = string_or_int list
let rec sum : string_or_int list -> int = function
| [] -> 0
| (String s)::t -> int_of_string s + sum t
| (Int i)::t -> i + sum t
let three = sum [String "1"; Int 2]
Variants thus provide a type-safe way of doing something that might before have seemed impossible.
Variants also make it possible to discriminate which tag a value was constructed with, even if multiple constructors carry the same type. For example:
type t = Left of int | Right of int
let x = Left 1
let double_right = function
| Left i -> i
| Right i -> 2*i
Syntax.
To define a variant type:
type t = C1 [of t1] | ... | Cn [of tn]
The square brackets above denote the the of ti
is optional. Every
constructor may individually either carry no data or carry date.
We call constructors that carry no data constant; and those that
carry data, non-constant.
To write an expression that is a variant:
C e
---or---
C
depending on whether the constructor name C
is non-constant or constant.
Dynamic semantics.
- if
e==>v
thenC e ==> C v
, assumingC
is non-constant. C
is already a value, assumingC
is constant.
Static semantics.
- if
t = ... | C | ...
thenC : t
. - if
t = ... | C of t' | ...
and ife : t'
thenC e : t
.
Pattern matching.
We add the following new pattern form to the list of legal patterns:
C p
And we extend the definition of when a pattern matches a value and produces a binding as follows:
- If
p
matchesv
and produces bindings b, thenC p
matchesC v
and produces bindings b.
Catch-all cases
One thing to beware of when pattern matching against variants is what Real World OCaml calls "catch-all cases". Here's a simple example of what can go wrong. Let's suppose you write this variant and function:
type color = Blue | Red
(* a thousand lines of code in between *)
let string_of_color = function
| Blue -> "blue"
| _ -> "red"
Seems fine, right? But then one day you realize there are more colors in the world. You need to represent green. So you go back and add green to your variant:
type color = Blue | Red | Green
But because of the thousand lines of code in between, you forget that
string_of_color
needs updating. And now, all the sudden, you are
red-green color blind:
# string_of_color Green
- : string = "red"
The problem is the catch-all case in the pattern match inside string_of_color
:
the final case that uses the wildcard pattern to match anything. Such code
is not robust against future changes to the variant type.
If, instead, you had originally coded the function as follows, life would be better:
let string_of_color = function
| Blue -> "blue"
| Red -> "red"
Now, when you change color
to add the Green
constructor, the OCaml type checker
will discover and alert you that you haven't yet updated string_of_color
to
account for the new constructor:
Warning 8: this pattern-matching is not exhaustive.
Here is an example of a value that is not matched:
Green
The moral of the story is: catch-all cases lead to buggy code. Avoid using them.
Recursive variants
Variant types may mention their own name inside their own body.
For example, here is a variant type that could be used to represent
something similar to int list
:
type intlist = Nil | Cons of int * intlist
let lst3 = Cons (3, Nil) (* similar to 3::[] or [3]*)
let lst123 = Cons(1, Cons(2, l3)) (* similar to [1;2;3] *)
let rec sum (l:intlist) : int=
match l with
| Nil -> 0
| Cons(h,t) -> h + sum t
let rec length : intlist -> int = function
| Nil -> 0
| Cons (_,t) -> 1 + length t
let empty : intlist -> bool = function
| Nil -> true
| Cons _ -> false
Notice that in the definition of intlist
, we define the Cons
constructor to carry a value that contains an intlist
. This makes
the type intlist
be recursive: it is defined in terms of itself.
Record types may also be recursive, but plain old type synonyms may not be:
type node = {value:int; next:node} (* OK *)
type t = t*t (* Error: The type abbreviation t is cyclic *)
Types may be mutually recursive if you use the and
keyword:
type node = {value:int; next:mylist}
and mylist = Nil | Node of node
But any such mutual recursion must involve at least one variant or record type that the recursion "goes through". For example:
type t = u and u = t (* Error: The type abbreviation t is cyclic *)
type t = U of u and u = T of t (* OK *)
Parameterized variants
Variant types may be parameterized on other types. For example,
the intlist
type above could be generalized to provide lists (coded
up ourselves) over any type:
type 'a mylist = Nil | Cons of 'a * 'a mylist
let lst3 = Cons (3, Nil) (* similar to [3] *)
let lst_hi = Cons ("hi", Nil) (* similar to ["hi"] *)
Here, mylist
is a type constructor but not a type: there is no
way to write a value of type mylist
. But we can write value of
type int mylist
(e.g., lst3
) and string mylist
(e.g., lst_hi
).
Think of a type constructor as being like a function, but one that
maps types to types, rather than values to value.
Here are some functions over 'a mylist
:
let rec length : 'a mylist -> int = function
| Nil -> 0
| Cons (_,t) -> 1 + length t
let empty : 'a mylist -> bool = function
| Nil -> true
| Cons _ -> false
Notice that the body of each function is unchanged from its previous
definition for intlist
. All that we changed was the type annotation.
And that could even be omitted safely:
let rec length = function
| Nil -> 0
| Cons (_,t) -> 1 + length t
let empty = function
| Nil -> true
| Cons _ -> false
The functions we just wrote are an example of a language feature
called parametric polymorphism. The functions don't care what the 'a
is in 'a mylist
, hence they are perfectly happy to work
on int mylist
or string mylist
or any other (whatever) mylist
.
The word "polymorphism" is based on the Greek roots "poly" (many) and
"morph" (form). A value of type 'a mylist
could have many forms,
depending on the actual type 'a
.
As soon, though, as you place a constraint on what the type 'a
might be,
you give up some polymorphism. For example,
# let rec sum = function
| Nil -> 0
| Cons(h,t) -> h + sum t;;
val sum : int mylist -> int
The fact that we use the (+)
operator with the head of the list
constrains that head element to be an int
, hence all elements
must be int
. That means sum
must take in an int mylist
, not any other
kind of 'a mylist
.
It is also possible to have multiple type parameters for 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"}
OCaml's built-in variants
OCaml's built-in list data type is really a recursive, parameterized variant. It's defined as follows:
type 'a list = [] | :: of 'a * 'a list
So list
is really just a type constructor, with (value) constructors
[]
(which we pronounce "nil") and ::
(which we pronounce "cons").
The only reason you can't write that definition yourself in your own
code is that the compiler restricts you to constructor names that begin
with initial capital letters and that don't contain any punctuation
(other than _
and '
).
OCaml's built-in option data type is really a parameterized variant. It's defined as follows:
type 'a option = None | Some of 'a
So option
is really just a type constructor, with (value) constructors
None
and Some
.
You can see both list
and option
defined in the Pervasives module
of the standard library.
OCaml's exception values are really extensible variants.
All exception values have type exn
, which is a variant
defined in the Pervasives module. It's an unusual
kind of variant, though, called an extensible variant, which allows
new constructors of the variant to be defined after the variant type
itself is defined. See the OCaml manual for more information about
extensible variants if you're interested.
Exception semantics
Since they are just variants, the syntax and semantics of exceptions is already covered by the syntax and semantics of variants—with one exception (pun intended), which is the dynamic semantics of how exceptions are raised and handled.
Dynamic semantics. As we originally said, every OCaml expression either
evaluates to a value
raises an exception
or fails to terminate (i.e., an "infinite loop").
So far we've only presented the part of the dynamic semantics that handles
the first of those three cases. What happens when we add exceptions?
Now, evaluation of an expression either produces a value or produces an
exception packet. Packets are not normal OCaml values; the only pieces
of the language that recognizes them are raise
and try
. The exception value
produced by (e.g.) Failure "oops"
is part of the exception packet produced
by raise (Failure "oops")
, but the packet contains more than just the exception value;
there can also be a stack trace, for example.
For any expression e
other than try
, if evaluation of a subexpression of e
produces an exception packet P
, then evaluation of e
produces packet P
.
But now we run into a problem for the first time: what order are subexpressions
evaluated in? Sometimes the answer to that question is provided by the semantics
we have already developed. For example, with let expressions, we know that the
binding expression must be evaluated before the body expression. So the following
code raises A
:
exception A
exception B
let x = raise A in raise B
And with functions, the argument must be evaluated before the function. So
the following code also raises A
:
(raise B) (raise A)
It makes sense that both those pieces of code would raise the same exception,
given that we know let x = e1 in e2
is syntactic sugar for (fun x -> e2) e1
.
But what does the following code raise as an exception?
(raise A, raise B)
The answer is nuanced. The language specification does not stipulate what order the
components of pairs should be evaluated in. Nor did our semantics exactly determine
the order. (Though you would be forgiven if you thought it was left to right.)
So programmers actually cannot rely on that order. The current implementation of OCaml,
as it turns out, evaluates right to left. So the code above actually raises B
.
If you really want to force the evaluation order, you need to use let expressions:
let a = raise A in
let b = raise B in
(a,b)
That code will raise A
.
One interesting corner case is what happens when a raise expression itself has a subexpression that raises:
exception C of string
exception D of string
raise (C (raise D "oops"))
That code ends up raising D
, because the first thing that has to happen is
to evaluate C (raise D "oops")
to a value. Doing that requires evaluating
raise D "oops"
to a value. Doing that causes a packet containing D "oops"
to be produced, and that packet then propagates and becomes the result of
evaluating C (raise D "oops")
, hence the result of evaluating
raise (C (raise D "oops"))
.
Once evaluation of an expression produces an exception packet P
, that packet
propagates until it reaches a try
expression:
try e with
| p1 -> e1
| ...
| pn -> en
The exception value inside P
is matched against the provided patterns using the
usual evaluation rules for pattern matching—with one exception
(again, pun intended). If none of the patterns matches, then instead of producing
Match_failure
inside a new exception packet, the original exception packet P
continues propagating until the next try
expression is reached.
Pattern matching. There is a pattern form for exceptions. Here's an example of its usage:
match List.hd [] with
| [] -> "empty"
| h::t -> "nonempty"
| exception (Failure s) -> s
Note that the code is above is just a standard match
expression, not a try
expression.
It matches the value of List.hd []
against the three provided patterns. As we know,
List.hd []
will raise an exception containing the value Failure "hd"
.
The exception pattern exception (Failure s)
matches that value. So the above
code will evaluate to "hd"
.
In general, exception patterns are a kind of syntactic sugar. Consider this code:
match e with
| p1 -> e1
| ...
| pn -> en
Some of the patterns p1..pn
could be exception patterns of the form exception q
.
Let q1..qn
be that subsequence of patterns (without the exception
keyword),
and let r1..rm
be the subsequence of non-exception patterns. Then we can rewrite the
code as:
match
try e with
| q1 -> e1
| ...
| qn -> en
with
| r1 -> e1
| ...
| rm -> em
Which is to say: try evaluating e
. If it produces an exception packet, use the
exception patterns from the original match expression to handle that packet.
If it doesn't produce an exception packet but instead produces a normal value,
use the non-exception patterns from the original match expression to match that value.
Case study: Trees
Trees are another very useful data structure. Unlike lists, they are not built into OCaml. A binary tree, as you'll recall from CS 2110, 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. In recitation you used a triple to represent a tree node:
type 'a tree =
| Leaf
| Node of 'a * 'a tree * 'a tree
Here, to illustrate something different, let's use a record type to represent a tree node. In OCaml we have to define two mutually recursive types, one to represent a tree node, and one to represent a (possibly empty) tree:
type 'a tree =
| Leaf
| Node of 'a node
and 'a node = {
value: 'a;
left: 'a tree;
right: 'a tree
}
The rules on when mutually recursive type declarations are legal are a
little tricky. Essentially, any cycle of recursive types must include at
least one record or variant type. Since the cycle between 'a tree
and
'a node
includes both kinds of types, it's legal.
Here's an example tree:
(* represents
2
/ \
1 3 *)
let t =
Node {
value = 2;
left = Node {value=1; left=Leaf; right=Leaf};
right = Node {value=3; left=Leaf; right=Leaf}
}
We can use pattern matching to write the usual algorithms for recursively traversing trees. For example, here is a recursive search over the tree:
(* [mem x t] returns [true] if and only if [x] is a value at some
* node in tree [t].
*)
let rec mem x = function
| Leaf -> false
| Node {value; left; right} -> value = x || mem x left || mem x right
The function name mem
is short for "member"; the standard library
often uses a function of this name to implement a search through a
collection data structure to determine whether some element is a member of that
collection.
Here's a function that computes the preorder traversal of a tree, in which each node is visited before any of its children, by constructing a list in which the values occur in the order in which they would be visited:
let rec preorder = function
| Leaf -> []
| Node {value; left; right} -> [value] @ preorder left @ preorder right
Although the algorithm is beautifully clear from the code above, it takes
quadratic time on unbalanced trees because of the @
operator. That
problem can be solved by introducing an extra argument acc
to accumulate
the values at each node, though at the expense of making the code less clear:
let preorder_lin t =
let rec pre_acc acc = function
| Leaf -> acc
| Node {value; left; right} -> value :: (pre_acc (pre_acc acc right) left)
in pre_acc [] t
The version above uses exactly one ::
operation per Node
in the tree,
making it linear time.
Case study: Natural numbers
We can define a recursive variant that acts like numbers, demonstrating that we don't really have to have numbers built into OCaml! (For sake of efficiency, though, it's a good thing they are.)
A natural number is either zero or the successor of some other
natural number. This is how you might define the natural numbers in a
mathematical logic course, and it leads naturally to the
following OCaml type nat
:
type nat = Zero | Succ of nat
We have defined a new type nat
, and Zero
and Succ
are
constructors for values of this type. This allows us to
build expressions that have an arbitrary number of nested Succ
constructors. Such values act like natural numbers:
let zero = Zero
let one = Succ zero
let two = Succ one
let three = Succ two
let four = Succ three
When we ask the compiler what four
is, we get
# four;;
- : nat = Succ (Succ (Succ (Succ Zero)))
Now we can write functions to manipulate values of this type.
We'll write a lot of type annotations in the code below to help the reader
keep track of which values are nat
versus int
; the compiler, of course,
doesn't need our help.
let iszero (n : nat) : bool =
match n with
| Zero -> true
| Succ m -> false
let pred (n : nat) : nat =
match n with
| Zero -> failwith "pred Zero is undefined"
| Succ m -> 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 n_minus_1 -> add n_minus_1 (Succ n2)
We can convert nat
values to type int
and vice-versa:
let rec int_of_nat (n:nat) : int =
match n with
| Zero -> 0
| Succ m -> 1 + int_of_nat m
let rec nat_of_int(i:int) : nat =
if i < 0 then failwith "nat_of_int is undefined on negative ints"
else if i = 0 then Zero
else Succ (nat_of_int (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 m -> odd m
and
odd (n:nat) : bool =
match n with
| 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.
Summary
Variants are a powerful language feature. They are the workhorse of representing data in a functional language. OCaml variants actually combine several theoretically independent language features into one: sum types, product types, recursive types, and parameterized (polymorphic) types. The result is an ability to express many kinds of data, including lists, options, trees, and even exceptions.
Terms and concepts
- algebraic data type
- binary trees as variants
- carried data
- catch-all cases
- constant constructor
- constructor
- exception
- exception as variants
- exception packet
- exception pattern
- exception value
- leaf
- lists as variants
- mutually recursive functions
- natural numbers as variants
- node
- non-constant constructor
- options as variants
- order of evaluation
- parameterized variant
- parametric polymorphism
- recursive variant
- tag
- type constructor
- type synonym
Further reading
- Introduction to Objective Caml, chapters 6 and 7
- OCaml from the Very Beginning, chapters 7, 10, and 11
- Real World OCaml, chapters 6 and 7