Implementing Functor in OCaml
This post explores the implementation of Functor structures (not to be confused with functor) in OCaml.
Let’s begin with some definitions,
- A Functor is (somewhat informally): a structure that provides a mapping operation that applies to a value in a given context, preserving that context.
- Put differently: given a function
val f: 'a -> 'b
, a Functor allows us to applyf
to values in some contextt
such that the mapping operation is of type'a t -> 'b t
. - This mapping operation is typically named
fmap
and has the type signatureval fmap: ('a -> 'b) -> 'a t -> 'b t
. The signature of
fmap
may be familiar: whentype 'a t = 'a list
, thefmap
function is simplyList.map
,# List.map;; - : ('a -> 'b) -> 'a list -> 'b list = <fun>
Begin the Begin
The option
type seems as good a place to start as any. Let’s state the problem clearly: given some function f
that maps from values of type 'a -> 'b
, how can we apply this f
to optional values of type 'a option
and mantain the optional context? Here’s a preliminary definition of OptionFunctor
and some demo code where f
is abs
:
module OptionFunctor = struct
let fmap f = function
Some a -> Some (f a)
| _ -> None
end
module Demo1 = struct
open OptionFunctor
let eg1 = fmap abs (Some 5 )
let eg2 = fmap abs (Some (-5))
let eg3 = fmap abs None
end
In keeping with the definition of Functor, OptionFunctor
provides an fmap
function that takes two arguments (we’ll gloss over currying): some function f
which maps from 'a -> 'b
and an argument of type 'a option
. When the second argument is present (in the form Some a
), f
is applied to a
and wrapped with the Some
constructor; otherwise (where the second argument is None
), the result is the same.
Less verbosely, fmap
has type: ('a -> 'b) -> 'a option -> 'b option
. Here it is in practice,
# Demo1.eg1;;
- : int option = Some 5
# Demo1.eg2;;
- : int option = Some 5
# Demo1.eg3;;
- : int option = None
To make this example a little more interesting, let’s introduce another type, validation
, and implement a Functor instance for it:
type 'a validation = Success of 'a | Failure of string
module OptionFunctor = struct
let fmap f = function
Some a -> Some (f a)
| _ -> None
end
module ValidationFunctor = struct
let fmap f = function
Success a -> Success (f a)
| Failure e -> Failure e
end
module Demo1 = struct
open OptionFunctor
let eg1 = fmap abs (Some 5 )
let eg2 = fmap abs (Some (-5))
let eg3 = fmap abs None
end
module Demo2 = struct
open ValidationFunctor
let eg4 = fmap abs (Success 5 )
let eg5 = fmap abs (Success (-5))
let eg6 = fmap abs (Failure "Something went wrong")
end
Our validation type has two constructors: Success
and Failure
– a slight enrichment of the builtin option
type through the provision of reason for failure (= absence of value). ValidationFunctor
strongly resembles OptionFunctor
, the only difference being that a failure string is propagated in the second prong of the function
match. The new Demo2
module demonstrates usage; calls to fmap
look identical, though validation
constructors are employed in Demo2
:
# Demo2.eg4;;
- : int validation = Success 5
# Demo2.eg5;;
- : int validation = Success 5
# Demo2.eg6;;
- : int validation = Failure "Something went wrong"
So far, so good – but can we introduce a generic function and use it with both option
and validation
types? Unsurprisingly, the answer is yes: To do so, let’s introduce a Functor
signature and augment our two Functor modules with type specifications.
type 'a validation = Success of 'a | Failure of string
module type Functor = sig
type 'a t
val fmap : ('a -> 'b) -> 'a t -> 'b t
end
module OptionFunctor = struct
type 'a t = 'a option
let fmap f = function
Some a -> Some (f a)
| _ -> None
end
module ValidationFunctor = struct
type 'a t = 'a validation
let fmap f = function
Success a -> Success (f a)
| Failure e -> Failure e
end
module Demo3(F: Functor) = struct
let eg7 x = F.fmap abs x
let eg8 f x = F.fmap f x
end
module OptionDemo3 = Demo3(OptionFunctor)
module ValidationDemo3 = Demo3(ValidationFunctor)
Demo3
is a functor
in the OCaml sense – a module that is parameterised by another module. Since the parameter F
is required to implement the Functor
signature (here we’re using structural typing to unify with OptionDemo
and ValidationDemo
), fmap
can be used in the module body. The abs
examples simply take a value in context,
# OptionDemo3.eg7 (Some 10);;
- : int OptionFunctor.t = Some 10
# OptionDemo3.eg7 None;;
- : int OptionFunctor.t = None
# ValidationDemo3.eg7 (Success 12);;
- : int ValidationFunctor.t = Success 12
# ValidationDemo3.eg7 (Failure "Something went wrong");;
- : int ValidationFunctor.t = Failure "Something went wrong"
while eg8
allows us to provide a function,
# let square x = x * x;;
val square : int -> int = <fun>
# OptionDemo3.eg8 square (Some 4);;
- : int OptionFunctor.t = Some 16
# OptionDemo3.eg8 square None;;
- : int OptionFunctor.t = None
# ValidationDemo3.eg8 square (Success 5);;
- : int ValidationFunctor.t = Success 25
# ValidationDemo3.eg8 square (Failure "Oops");;
- : int ValidationFunctor.t = Failure "Oops"
Finally, here’s how we can define a ListFunctor
at the toplevel to make use of Demo3
:
# module ListFunctor = struct type 'a t = 'a list let fmap = List.map end;;
module ListFunctor :
sig type 'a t = 'a list val fmap : ('a -> 'b) -> 'a list -> 'b list end
# module ListDemo3 = Demo3(ListFunctor);;
module ListDemo3 : sig val eg7 : int ListFunctor.t -> int ListFunctor.t end
# ListDemo3.eg7 [1; 2; 3];;
- : int ListFunctor.t = [1; 2; 3]
# ListDemo3.eg7 [1; 2; -3];;
- : int ListFunctor.t = [1; 2; 3]
# ListDemo3.eg7 [];;
- : int ListFunctor.t = []
# ListDemo3.eg8 square [1; 2; 3];;
- : int ListFunctor.t = [1; 4; 9]
# ListDemo3.eg8 square [];;
- : int ListFunctor.t = []
Providing signatures for Functors
Above, we used structural typing to match OptionFunctor
and ValidationFunctor
against the Functor
signature. How about defining these modules with the Functor
signature at the outset?
First of all, consider what happens when we attempt to create a new module, OF
, by signing OptionFunctor
with Functor
:
# module OF = (OptionFunctor : Functor);;
module OF : Functor
# OF.fmap abs (Some 5);;
Error: This expression has type 'a option
but an expression was expected of type int OF.t
In specifying a signature, the type representation in OptionFunctor
has been abstracted over. The same problem is evidenced in the following code,
# module type A = sig type t val id: t -> t end;;
module type A = sig type t val id : t -> t end
# module B = struct type t = int let id x = x end;;
module B : sig type t = int val id : 'a -> 'a end
# B.id;;
- : 'a -> 'a = <fun>
# B.id 10;;
- : int = 10
# module C = (B: A);;
module C : A
# C.id;;
- : C.t -> C.t = <fun>
# C.id 10;;
Error: This expression has type int but an expression was expected of type
C.t
By defining module C
as having signature A
, type C.t
has been rendered abstract. What we meant to express is that C
is a kind of A
… and that type t = int
. To achieve this, a sharing constraint is necessary:
# module D = (B: A with type t = int);;
module D : sig type t = int val id : t -> t end
# D.id 10;;
- : D.t = 10
Returning to OptionFunctor
and its cousin OF
, let’s look more closely at each module’s signature (by using the assign-to-peek trick in the toplevel):
# module T = OptionFunctor;;
module T :
sig
type 'a t = 'a option
val fmap : ('a -> 'b) -> 'a option -> 'b option
end
# module T = OF;;
module T :
sig
type 'a t = 'a OF.t
val fmap : ('a -> 'b) -> 'a t -> 'b t
end
As anticipated above, OF.t
is abstract; to “fix” fmap
we’ll need to add a sharing constraint as follows,
# module OF2 = (OptionFunctor : Functor with type 'a t = 'a option);;
module OF2 :
sig
type 'a t = 'a option
val fmap : ('a -> 'b) -> 'a t -> 'b t
end
Notice that t
has a concrete type in OF2
while it remains abstract in OF
(as seen through T
). The provision of a sharing constraint solves the type incompatibility problem,
# OF.fmap abs (Some 5);;
Error: This expression has type 'a option
but an expression was expected of type int OF.t
# OF2.fmap abs (Some 5);;
- : int OF2.t = Some 5
So: is it worth explicitly specifying the signature or not in light of the potential confusion caused by sharing constraints? On the one hand, explicit specification buys encapsulation and compile-time safety – the compiler will alert us to missing definitions. On the other, constraint provision is required to prevent nasty surprises from arising – perhaps we could make do with structural typing.
Since ensuring that implementations satisfy the conditions of formal categories (in this case, Functor) is desirable, explicit signatures will be employed for the remainder of the post. This is by no means required for what follows though.
Back to the code
As a final step, let’s introduce an either
type – more general than validation
in that its failure type is parametric – and Functor
signatures for all of our implementations:
type 'a validation = Success of 'a | Failure of string
type ('a, 'b) either = Left of 'a | Right of 'b
module type Functor = sig
type 'a t
val fmap : ('a -> 'b) -> 'a t -> 'b t
end
module OptionFunctor : Functor
with type 'a t = 'a option = struct
type 'a t = 'a option
let fmap f = function
Some a -> Some (f a)
| _ -> None
end
module ValidationFunctor : Functor
with type 'a t = 'a validation = struct
type 'a t = 'a validation
let fmap f = function
Success a -> Success (f a)
| Failure e -> Failure e
end
module ListFunctor : Functor
with type 'a t = 'a list = struct
type 'a t = 'a list
let fmap = List.map
end
(* Signature of any module providing its type in `t' *)
module type Typed = sig type t end
module EitherFunctor(T: Typed) : Functor
with type 'a t = (T.t, 'a) either = struct
type 'a t = (T.t, 'a) either
let fmap f = function
Right r -> Right (f r)
| Left l -> Left l
end
module Demo3(F: Functor) = struct
let eg7 x = F.fmap abs x
let eg8 f x = F.fmap f x
end
module OptionDemo3 = Demo3(OptionFunctor)
module ValidationDemo3 = Demo3(ValidationFunctor)
module ListDemo3 = Demo3(ListFunctor)
module EitherDemo3 = Demo3(EitherFunctor(String))
Here EitherFunctor
is an (OCaml) functor
(i.e., a module parameterised with another module). The module argument must satisfy the signature Typed
by providing a type member t
; this is used for the Left
(failure) case.
# EitherDemo3.eg7 (Right 12);;
- : int EitherFunctor(String).t = Right 12
# EitherDemo3.eg7 (Left "Something went wrong");;
- : int EitherFunctor(String).t = Left "Something went wrong"
# EitherDemo3.eg8 square (Right 12);;
- : int EitherFunctor(String).t = Right 144
# EitherDemo3.eg8 square (Left "Oh dear");;
- : int EitherFunctor(String).t = Left "Oh dear"
Why do we need to use a functor
here? The answer lies with the arity of Functor.t
. Recall that either
is defined as,
while Functor
expects a type matching
Without the functor
, we’d receive a signature mismatch error.
Applicative Functors
Next time we’ll look at Applicative Functor, built on top of the Functor
modules described in this post.
For more information on Functor, the Haskell Typeclassopedia is an excellent reference.
comments powered by Disqus