Skip to content

Lecture 2.2: Pattern matching and Pairs

Some more fun with functions: type inference

Static typing is excellent.

let int_greater_than_float x y = float_of_int x < y;;
The type is inferred. OCaml can use the information of float_of_int to capture the correct information.

In contrast, the following will be is a type error:

let int_greater_than_float x y = float_of_int x < (y + 1);;

Of course, as you have seen, we can always annotate our functions. DO THIS! It will help you understand the mismatch between your intention and the compiler output.

let int_greater_than_float (x : int) (y : float) : bool = float_of_int x < y;;

Pattern matching

Pattern matching allows us to perform a case analysis on a piece of data. We can use it for primitive types, but the real power of this feature is in dealing with composite datatypes. Let's look at a simple example of an if with multiple alternatives:

let foo (x : int) : string =
  if x = 1 then "one" else
  if x = 2 then "two" else
  if x = 3 then "three" else
  "greater than three" ;;

Since we are checking for specific values, we can achieve the same much more concisely with pattern matching:

let foo (x : int) : string =
  match x with
  | 1 -> "one"
  | 2 -> "two"
  | 3 -> "three"
  | _ -> "greater than three" ;;

What are the pros and cons between these two approaches? A collection of if-/switch- statements might be straightforward, but with matching, we let the OCaml compiler to do the work for us as the type gives it more information about the structure of the data. Let's next take a look at pattern matching in the context of pairs.

Tuples

Up to now, we have been mostly dealing with built-in types: ints, floats, bools, strings. The only type composed of other types were functions. We often want to composed data types to create new types.

A basic datatype that allows us to package values of different types together are tuples. For example here is a pair (a 2-tuple) of ints:

utop # (2, 3);;
- : int * int = (2, 3)

Note

Strictly speaking, the parentheses are not needed:

utop # 2,3;;
- : int * int = (2, 3)
but we will always use them.

We can see that the type of a pair of two ints is int * int, which corresponds to the concept of Cartesian products. Given a pair, we can get the components by using fst and snd:

utop # let x = (2, 3);;
val x : int * int = (2, 3)

utop # fst x;;
- : int = 2

utop # snd x;;
- : int = 3

Additionally, we can destruct tuples through let-bindings:

utop # let p = (2, true) in 
       let (x, y) = p in 
       x;;
- : int = 2

We can have tuples with more than two elements

utop # let p = (1, "hello", false, 4.56) in 
       let (x, y, z, w) = p in 
       z;;
- : bool = false

We can also deconstruct tuples using pattern matching

let teaches_2800 (name_instr : string * bool) : bool =
  match name_instr with
  | ("sam", true) -> true
  | ("mira", false) -> false
  | ("max", false) ->  false
  | ("aryn", false) -> false
  | (_    , false) -> false
  ;;

let is_instructor (name_instr : string * bool) : bool =
  match name_instr with
  | (_, b) -> b
  ;;

To help our programs make sense, it is often useful to give custom names to existing types. Let's make an alias to a type.

type point2d = int * int

let shift_x (p : point2d) (delta : int) : point2d =
  match p with
  | (x, y) -> (x + delta, y)
  ;;

Just like deconstructing tuples in a let-binding, we can deconstruct them in function arguments:

type point2d = int * int

(* good examples: *)
let shift_x ((x, y) : point2d) (delta : int) : point2d = (x + delta, y) ;;
let shift_x (x, y : point2d) (delta : int) : point2d = (x + delta, y) ;;

You can also do compound pattern matching

let shift (x, y : point2d) (dx, dy : point2d) : point2d = (x + dx, y + dy)
let shift (xy : point2d) (dxdy : point2d) : point2d = 
  match (xy, dxdy) with
  | ((x, y), (dx, dy)) -> (x + dx, y + dy)

Basic Abstract Datatypes (ADTs)

Let's look at few more examples of ADTs, and how you might use them. Suppose you're creating a graphics application that lets you create various shapes and give them colors. First, we can define colors by an enumeration:

type color = Red | Green | Blue | Orange | Yellow | Purple
Here, the type is just an enumeration between six choices, without any data attached. While we could represent color with an integer (e.g., 0 for red, 1 for green, and so on), using an explicit enumeration with names is useful for code clarity. For example, let's write a function to compute complementary colors:
let complementary (c : color) =
    match c with
    | Red -> Green
    | Green -> Red
    | Blue -> Orange
    | Orange -> Blue
    | Yellow -> Purple
    | Purple -> Yellow

(As you might have noticed, this is another example of an involutive function.)

Next, let's define a type for shapes:

type shape = | Rectangle of int * int (* width and height *)
             | Circle of int (* radius *)
             | Square of int (* side length *)

Finally, we want to define a type that holds both a color and a shape. While we could do this with a pair type (color * shape), there is another way: by using a record:

type blob = 
    { b_color : color; b_shape : shape }

This defines a record, which must hold a field b_color of type color, and b_shape of type shape. Let's create one and analyze it:

let red_rect = { b_color = Red; b_shape = Rectangle (2, 3) }

let _ = 
    match red_rect.b_shape with 
    | Rectangle (_, _) -> print_endline "Got a rectangle!"
    | Circle _ -> print_endline "Got a circle!"
    | Square _ -> print_endline "Got a square!"