To Functor or not to Functor, that is the question

I’ve got some code that can be implemented using functors or not and am not sure when to use them. Here it is without functors.

module Ord = {
  type ord<'t> = {compare: ('t, 't) => int}
  let lte = (o, a, b) => o.compare(a, b) <= 0
  let between = (~o, ~low, ~high, ~value) => lte(o, low, value) && lte(o, value, high)
  let clamp = (~o, ~low, ~high, ~value) =>
    lte(o, value, low) ? low : lte(o, high, value) ? high : value
}

And here it is with functors…

module type OrdType = {
  type t
  let compare: (t, t) => int
}

module OrdUtilities = (O: OrdType) => {
  let lte = (a, b) => O.compare(a, b) <= 0
  let between = (~low, ~high, ~value) => lte(low, value) && lte(value, high)
  let clamp = (~low, ~high, ~value) => lte(value, low) ? low : lte(high, value) ? high : value
}

I read that functors are usually used for (1) data structures, and (2) to extend modules. This use case doesn’t seem to fit either. It is nice that the functor example wraps up all these related functions together and each function can be used without passing the ord instance. For example, if I created a StringOrdUtility I could use it to do a lot of work with strings without having to pass the compare function into every call. On the other hand if passing the ord instance to every call became cumbersome I easily create a utility record object like this…

type utilities<'t> = {
  between: (~low: 't, ~high: 't, ~value: 't) => bool,
  clamp: (~low: 't, ~high: 't, ~value: 't) => 't,
}
let makeUtilities = o => {
  between: (~low: 't, ~high: 't, ~value: 't) => between(~o, ~low, ~high, ~value),
  clamp: (~low: 't, ~high: 't, ~value: 't) => clamp(~o, ~low, ~high, ~value),
}

When should functors be used versus ordinary modules and/or records of functions?

====

I just realized I can read the web about OCaml and get answers to this kind of question. Found good information here that I just started reading…

https://dev.realworldocaml.org/functors.html

Every case is different, and I’m sure different people will have different opinions. One question I usually ask myself is: does the functor create a new type? If it doesn’t, then you’re probably not really getting much of a benefit from using it.

In this particular example, I don’t think that a collection of utility functions is quite enough justification for a functor. As you point out, you can achieve the exact same thing with higher-order functions.

Often the simplest pattern is the best. Here’s how I would write that code:

module Utils = {
  let lte = (a, b, cmp) => cmp(a, b) <= 0
  let between = (~compare as cmp, ~low, ~high, ~value) =>
    lte(low, value, cmp) && lte(value, high, cmp)
  let clamp = (~compare as cmp, ~low, ~high, ~value) =>
    lte(value, low, cmp) ? low : lte(high, value, cmp) ? high : value
}

// Usage:
type t = int
let compare = (a: int, b: int) => compare(a, b)
let between = Utils.between(~compare)
let clamp = Utils.clamp(~compare)

By defining the functions as top-level values, as opposed to record fields or functor values, then they’re also more tree-shakable in JS.

2 Likes