Processing math: 100%

Variants


Topics:


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.

Static semantics.

Pattern matching.

We add the following new pattern form to the list of legal patterns:

And we extend the definition of when a pattern matches a value and produces a binding as follows:

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

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

Further reading