- ocaml
Note: A version of this article was originally written for Human Readable magazine in March 2020. That site no longer exists, so I decided to republish it on this blog. In the process I also converted the example code from ReasonML to OCaml syntax and updated the text to reflect those changes.
Since functional programming’s move into the mainstream, many developers have embraced concepts like higher-order functions. OCaml take this a step further and extends the concept to the module level. This article explores module functors, a powerful feature that offers a functional approach to code sharing, dependency injection, and more.
#A brief into to OCaml
OCaml is a mature functional programming language, which was created in 1996 by a team of mostly French computer scientists and programmers. It’s the main implementation of the Caml programming language, which it extends with many features, including object-oriented programming capabilities. It belongs to the ML language family (like Microsoft’s F#) and is used in diverse areas, from trading, over the MirageOS unikernel framework, to Facebook’s Flow type checker for JavaScript as well as the Hack and Haxe compilers. It also can be compiled to JavaScript via Js_of_ocaml.
#Module Functor Basics
Anyone who has ever dug into functional programming or category theory has probably stumbled upon the concept of a functor. Without getting too heavy on theory, a functor is a polymorphic “container” that can be mapped over and which follows certain rules. The interesting thing here is that the function being applied doesn’t need to be aware of the structure of the functor, as this detail is handled by the map implementation. Common examples for this are arrays and optionals:
let inc x = x + 1;;
List.map [1; 2; 3] ~f:inc;;
(* int list = [2; 3; 4] *)
Option.map (Some 3) ~f:inc;;
(* int option = Option.Some 4 *)
Note: All code example uses JaneStreet’s Base standard library.
OCaml’s module functors are a related but slightly different concept: one can think of them as functions that receive a module as an argument and then return a new module, therefore providing a sort of “mapping” between modules.
#Hello, Functor World!
Let’s see a simple module functor in action:
(* 1 *)
module type Greetable = sig
(* 2 *)
val greeting : string
(* 3 *)
val who : string
end
(* 4 *)
module MakeGreeter (M : Greetable) = struct
(* 5 *)
let greet () = M.greeting ^ ", " ^ M.who ^ "!"
end
(* 6 *)
module EnglishGreeting = struct
let greeting = "Hello"
let who = "world"
end
(* 7 *)
module HelloWorld = MakeGreeter (EnglishGreeting)
Here Greetable (1) defines the type of the module our functor expects. If you have an OOP background, you can think of this as similar to an interface. In the above example, our input modules just need to define two string values, representing a greeting phrase (2) and who we want to greet (3). MakeGreeter is our first module functor (4). It looks a lot like a function, with some important differences:
- We use the
modulekeyword instead ofletfor defining a functor. - Arguments to functors have to be type annotated.
- The names of functors must start with capital letters, like modules.
MakeGreeter proceeds to define a new function called greet (5), which uses the values defined in the input module to generate a greeting string. We then define a module called EnglishGreeting (6), which provides the two necessary string values. This module is later passed as an argument to MakeGreeter (7) and the resulting module gets assigned to HelloWorld.
HelloWorld.greet ();;
(* string = "Hello, world!" *)
However, if we don’t need to use the input module by itself, we don’t need to explicitly define it, but can pass an anonymous module to the functor instead. The following example uses this approach to create a Spanish version of our greeter:
module HolaMundo = MakeGreeter (struct
let greeting = "¡Hola"
let who = "mundo"
end)
;;
HolaMundo.greet ();;
(* string = "¡Hola, mundo!" *)
Note that none of the values of the input module are exposed in the module returned by the functor:
HolaMundo.who
(* Error: Unbound value HolaMundo.who *)
If we want to make them available, we’ll have to explicitly re-export them in the functor:
module MakeGreeter (M : Greetable) = struct
(* 5 *)
let who = M.who
let greet () = M.greeting ^ " " ^ M.who ^ "!"
end
module HolaMundo = MakeGreeter((* same as above *));
HolaMundo.who
(* string = "mundo" *)
#Functors as “Base Classes”
Functors on modules that only expose static values are not particularly interesting, so let’s look at a slightly more interesting example.
(* 1 *)
module type TempConvertible = sig
val in_temp : string
val out_temp : string
val convert : float -> float
end
(* 2 *)
module MakeConverter (M : TempConvertible) = struct
(* 3 *)
let convert = M.convert
(* 4 *)
let as_string t = Printf.sprintf "%.2f%s = %.2f%s" t M.in_temp (convert t) M.out_temp
end
Here we first define a signature for a TempConvertible type (1), which consists of two strings specifying the input and output temperatures, as well as a converter function with a float -> float signature. The MakeConverter functor (2) accepts a TempConvertible module, reexports the convert function (3) and adds a as_string function for display purposes (4). If you’re coming from an OOP background, this may remind you of an abstract base class that needs a concrete convert implementation. With everything in place, we can now define two modules, one to convert from Celsius to Fahrenheit and one for the opposite conversion:
module CtoF = MakeConverter (struct
let in_temp = "C"
let out_temp = "F"
let convert t = (t *. 1.8) +. 32.
end)
module FtoC = MakeConverter (struct
let in_temp = "F"
let out_temp = "C"
let convert t = (t -. 32.) /. 1.8
end)
The resulting modules work as expected:
CtoF.convert 40.;;
(* float = 104. *)
FtoC.as_string 104.;;
(* string/2 = "104.00F = 40.00C" *)
However, we can take this one step further and define a functor that combines two existing conversion modules into a new one. To do this, we first define a type for converters and update MakeConverter accordingly:
module type Converter = sig
val in_temp : string
val out_temp : string
val convert : float -> float
val as_string : float -> string
end
module MakeConverter (M : TempConvertible) = struct
(* new *)
let in_temp = M.in_temp
let out_temp = M.out_temp
(* unmodified *)
let convert = (* same as above *)
let as_string t = (* same as above *)
end
With these changes in place, we can define a new functor for combining converters:
module CombineConverters (M1 : Converter) (M2 : Converter) = struct
(* 1 *)
let convert f = M1.convert f |> M2.convert
(* 2 *)
let as_string t = Printf.sprintf "%.2f%s = %.2f%s" t M1.in_temp (convert t) M2.out_temp
end
Here we use the pipe forward operator (|>) to pipe the result of the first conversion function into the second (1). In as_string we accordingly use the first modules’ in_temp and the second modules’ out_temp (2). We can now define a new converter from Celsius to Kelvin:
(* redefine FtoC with the new MakeConverter functor *)
module FtoC = MakeConverter((* same as above *))
module FtoK =
CombineConverters
(FtoC)
(MakeConverter (struct
let in_temp = "C"
let out_temp = "K"
let convert t = t +. 273.15
end))
This uses our existing FtoC converter, as well as an anonymous converter from Celsius to Kelvin. We now have a converter from Fahrenheit to Kelvin, without ever explicitly defining a conversion function for it:
FtoK.as_string 0.;
(* string/2 = "0.00F = 255.37K" *)
While working, this solution is not particularly robust, as we have no guarantees that M1.out_temp lines up correctly with M2.in_temp. More solid approaches to enforce this invariant are available, but they’re outside the scope of this article.
#Functors as “Mixins”
OCaml’s modules can be extended with the include keyword, which copies the contents of one module into another, thus providing functionality similar to mixins in object-oriented languages.
Let’s see this in action:
(* 1 *)
type ordering =
| Less
| Equal
| Greater
(* 2 *)
module type Comparable = sig
type t
val compare : t -> t -> ordering
end
(* 3 *)
module MakeComparable (M : Comparable) = struct
let greater x y =
match M.compare x y with
| Greater -> true
| _ -> false
;;
let greater_or_equal x y =
match M.compare x y with
| Less -> false
| _ -> true
;;
end
First, we define a discriminated union for a type called ordering, which specifies the relationship between two values (1). We use this to specify a Comparable type (2), which consists of a base type t, as well as a compare function which expects two values and returns an ordering. The MakeComparable functor accepts such a module and defines various functions that make use of it.
We can now add this functor to create a comparable vector type:
module Vector = struct
(* 1 *)
module Base = struct
(* 2 *)
type t = { x : float; y : float }
let build x y = { x; y }
let length v = sqrt ((v.x *. v.x) +. (v.y *. v.y))
let compare v1 v2 =
match Float.compare (length v1) (length v2) with
| x when x < 0 -> Less
| x when x > 0 -> Greater
| _ -> Equal
end
(* 3 *)
include Base
(* 4 *)
include MakeComparable (struct
type t = Base.t
let compare = Base.compare
end)
end
The Vector module defines a nested module Base, which implements the necessary functions to be a Comparable. We then include this module (3), as well as the module returned by the MakeComparable functor (4). The resulting Vector type exposes all the expected functionality:
let v1 = Vector.build 1. 1.;;
let v2 = Vector.build 2. 2.;;
Vector.length v1;;
(* float/2 = 1.41421356237309515 *)
Vector.greater_or_equal v1 v2;;
(* bool/2 = false *)
Vector.greater v2 v1;;
(* bool/2 = true *)
#Module Functors in The Wild
Many module functors can be found in OCaml’s standard library or its various replacements like Core, Batteries, or Containers.
For example, map implementations are generally only polymorphic for values, but not keys. A functor is used to specify a version for a given key type. Using the following Vector type, we can create a map type called VectorMap, which uses vectors as keys:
module Vector = struct
type t =
{ x : float
; y : float
}
let build x y = { x; y }
let length v = sqrt ((v.x *. v.x) +. (v.y *. v.y))
let compare v1 v2 = Float.compare (length v1) (length v2)
end
module VectorMap = Map.Make (Vector)
The Make functor of the standard library’s Map module adds many functions, here’s a small subset of them:
val empty : 'a t
val is_empty : 'a t -> bool
val equal : ('a -> 'a -> bool) -> 'a t -> 'a t -> bool
val iter : (key -> 'a -> unit) -> 'a t -> unit
val fold : (key -> 'a -> 'b -> 'b) -> 'a t -> 'b -> 'b
We can now use Vectors as map keys and get a lot of functionality for free:
let v = Vector.build 1. 1.;;
(* val v : VectorMap.key = {Vector.x = 1.; y = 1.} *)
let v_map = VectorMap.add v "first" VectorMap.empty;;
(* val v_map : string VectorMap.t = <abstr> *)
VectorMap.is_empty v_map;;
(* bool = false *)
VectorMap.find_opt v v_map;;
(* string option = Some "first" *)
When exploring a new OCaml library, it’s always worth checking if it offers any functors that allow adding shared behavior to custom types.
#Summary
Module functors in OCaml are a powerful tool. They are like higher-order functions for modules, allowing us to generate new modules from existing ones. With great power comes great responsibility though, so don’t reach for functors until they become really necessary. Many problems can be solved with functions, records, and variant types, and one should always reach for these first before introducing a functor.
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.