OCaml on the Fringes - You can do that??
So, I got bored this afternoon and was contemplating why I was enjoying diving into OCaml more than say Elixir/Erlang for functional programming. The quality of the language that’s most captured my attention is the type system.
When you start playing around with the type system in OCaml you can go from some basic definitions of user-defined types to some pretty cool looking constructs. Let’s start of with something pretty transparent and move into something that might actually melt your brain.
So you likely recall that user-defied types are declared using the **type keyword. Let’s declare a simple type:
type thing = int;;
Here we’ve declared a new type,
thing, that is of type int. Nothing too fancy. If we create an
int expression (say in utop), we see the following representation of type:
utop # 5;; - : thing = 5
Let’s modify our type definition of
thing using an OCaml idiom that prefixes our type definition with a label:
type thing = Int of int;;
Now in order to declare a thing we would need to use our label,
utop # Int 3;; - : thing = Int 3
So what’s so special about using our Label? Well, we can extend our
thing definition using variants and labels as follows:
type thing = Int of int | Float of float;;
So what’s so special about our new definition? Well, now that we’ve extended it we can match on the label inside a function and act accordingly. For example, let’s define an absolute value function using our
thing type and return an integer regardless of whether or not the parameter is an integer or a float:
let abs_f (a:thing): int = match a with |Int(b) -> abs b |Float(b) -> int_of_float(abs_float(b)) utop # abs_f(Int(-3));; - : int = 3 utop # abs_f(Float(-3.5));; - : int = 3
Okay, so we end up with a function that takes the absolute value of either an integer or a float, but notice what happens when our float is not a integral value? Turns out our
int_of_float chops off the decimal portion of our float. Not really a good way to go. Can we do better? Rather than specify that our function,
abs_f return an
int, let’s return a
let abs_f (a:thing):thing = match a with |Int(b) -> Int(abs b) |Float(b) -> Float(abs_float(b))
So now if we call our function, notice what we get in return:
utop # abs_f(Float(-3.3));; - : thing = Float 3.3 utop # abs_f(Int(-3));; - : thing = Int 3
You may be wondering if there is a way to define a function that returns either an
int or a
float – without a label. That answer is no! We can’t define a type, t, that is of type
int | float.
Polymorphic Type Definitions
Check this out. Aside from using labels in our type definitions, let’s use polymorphic variables to declare types that can be of anytype. Let’s define a type for a tree:
type 'a tree = Leaf of 'a | Node of 'a tree * 'a tree;;
Let’s dig into our definition. We define a
tree type but what’s with the
'a in front of the type definition? This is our polymorphic type specification. We we define a
tree of type
'a. Also, notice that our type definition is recursive. A
tree is either a Leaf of our
'a type, or it’s a Node consisting of a tuple (
'a tree, 'a tree).
So, let’s make a concrete tree:
# Node(Leaf(3), Node(Leaf(2), Leaf(4)));; - : int tree = Node (Leaf 3, Node (Leaf 2, Leaf 4))
Notice what happens if we attempt to make a
Leaf of a type not all the same.
# Node(Leaf(3), Node(Leaf(2), Leaf("5")));; Error: This expression has type string but an expression was expected of type
One way to solve this is to make our
tree of type
# Node(Leaf(Int(3)), Leaf(Float(4.)));; - : thing tree = Node (Leaf (Int 3), Leaf (Float 4.))
Another way to make our data-structures polymorphic is using polymorphic variants. We can define a polymorphic variant time as:
utop # type t = [`Int of int|`Float of float |`String of string];;
We enclose our type definitions with labels prefixed with a back-tick, (`). Now when we define a function using the type,
t, we can create a list of type
t. For example, let’s create a concatination function,
c, that appends an element to our list of type
t and returns the newly concatenated list:
# let c (a:t) (l:t list) = l@[a] val c : t -> t list -> t list = <fun> # utop # c (`Int 3) ([`String "s"]);; - : t list = [`String "s"; `Int 3]
Here you can see that we have created a list of both Integers and Strings using polymorphic variants. So what’s the difference between the use of labeled variants and polymorphic variants? I propose that if we specify types to our function parameters, then they behave virtually identically. But when we don’t we can introduce open variants allowing a catch-all we run into problems.
type animal = [ `Dog of string | `Cat of string ] let name = function | `Dog n -> "dog named " ^ n | `Cat n -> "cat named " ^ n | _ -> "huh?" utop # name (`Dog("s"));; - : string = "dog named s" utop # name (`Dogs("s"));; - : string = "huh?"
Here if we misspell Dogs rather than the match-variant Dog we get a value that we weren’t expecting and we get no warning from the compiler.
But if we modify our function definition, and specify the type for our function,
animal, notice what we get from the compiler:
let name (a:animal) = match a with | `Dog n -> "dog named " ^ n | `Cat n -> "cat named " ^ n | _ -> "huh?";; Characters 95-96: Warning 11: this match case is unused. val name : animal -> string = <fun>
Bottom line is that we’re likely safer using type-specifications and closed polymorphic variants whenever possible.
Let’s Get Funky with Types and Functions
We’ve seen in the above examples that the type system in OCaml gives us tremendous latitude. We can declare rather generic types and specify functions on those types, even overriding built-in functions that have well-defined meaning. Take the following for example. Let’s redefine the + operator.
Given a type,
t, that’s very generic.
type 'a t = 'a. And then redefine our plus function very simply as:
# let (+) (a:string t) (b:string t) = [a]@[b];; val ( + ) : string -> string -> string list = <fun>
Here we see that our infix plus function takes two parameters,
b and returns a list of the two as strings. This isn’t really polymorphic. Let’s modify our type definition using polymorphic variants and try again.
type t = [`Int of int | `Float of float | `String of string] let (+) (a:t) (b:t) = [a]@[b];; val ( + ) : t -> t -> t list = <fun>
And now we can add our type values of
utop # `Int(3) + `Float(4.);; - : t list = [`Int 3; `Float 4.]
We can go beyond just redefining our infix operator, (+), and define new operators altogether:
let (>>+) (a:t) (b:t list) = a::b;; val ( >>+ ) : t -> t list -> t list = <fun>
And do crazy operations like an albelian addition of an element of type
t with a list of type
utop # `String("hi") >>+ (`Int(3) >>+ [`Float(3.)]);; - : t list = [`String "hi"; `Int 3; `Float 3.]
Lots of possibilities and it’s definitely something worth experimenting - both on type definitions and function declarations. Go play!! Oh, and have fun!!!