Objects use cases in OCaml

Classes provide open recursion with late binding with a row polymorphic self type. In a separate, all these facilities are provided by other language mechanisms that are more straightforward and easier to use and understand. So following Occam’s razor principle it is better to use the least heavy tool. Let me expand this definition before going any further.

  1. open recursion - is a technique when a recursive function calls itself not directly, but via an explicit parameter, e.g.,

let fact self  n = if n = 0 then 1 else n * self (n - 1)

The fixpoint combinator could be used to tight things up, e.g.,

let rec fix f n = f (fix f) n
let fact n = fix fact n 

So, as you can see, there is no need to use classes for open recursion as you can use explicit functions or records for that (as done in the OCaml’s AST rewriters).

  1. Late binding is a technique when a function is not called directly, but via a slot that could be overridden in runtime by any other function with a matching type. Technically, this means that the self parameter is mutable. That allows an algorithm to call a method that is dispatched based on some runtime information. Beside the obvious solution with mutable records, it is possible to use recursive modules, e.g.,
type student = {age : int}

module type Student = sig
  type t
  val create : int -> t
  val age : t -> int
  val say : t -> string
end with type t := student

module Base(S : Student) : Student  = struct
  let create age = {age}
  let age s = s.age
  let say t = "I'm " ^ string_of_int (S.age t) ^ " years old\n"
end

module Older(S : Student) : Student = struct
  include S
  let age s = S.age s + 1
end

module Younger(S : Student) : Student = struct
  include S
  let age s = S.age s / 2
end

module Same : Student = Older(Init)

module rec S1 : Student = Base(Younger(S1))
let s1 = S1.(say@@create 20);;
module rec S2 : Student = Base(Younger(S2))
let s2 = S2.(say@@create 20);;

As you can see we can override methods, and even explicitly specify the the order of inheritance. Underneath the hood the compiler allocates a function table for each operation in the interface and assigns each operation an implementation in the order of the functor applications. This is basically what happens with classes, so we nearly got OO with functors, however, we still have some limitations wrt to what classes can give us.

  1. polymporhic self type - as we can see from the previous example, we had to constrain the student type to some concrete implementation. But what if we need to keep it polymorphic and enable refinement (i.e., adding new operations or deleting constructors) of the self type in the derived classes? Thanks to the addition of private row types, we can actually do this with recursive modules, (depending on what we want we can use polymorphic variants or object types to denote row types). The object type example is provided in the OCaml manual, an example with a polymorphic variant is provided below:
module type Ops = sig
  type expr
  val eval : expr -> expr
  val show : expr -> string
end
type 'a expr0 = [`Num of int | `Plus of 'a * 'a]

module F(X : Ops with type expr = private [> 'a expr0] as 'a) = struct
  let () = print_string "eval F"
  type expr = X.expr expr0
  let eval = function
    | `Num _ as x -> x
    | `Plus (x,y) -> match X.eval x, X.eval y with
      | `Num m, `Num n -> `Num (m+n)
      | z -> `Plus z

  let show = function
    | `Num x -> string_of_int x
    | `Plus (x,y) -> "(" ^ X.show x ^ " + " ^ X.show y ^ ")"
end

module rec L : (Ops with type expr = L.expr expr0) = F(L)

So, using functors together with private row types we can implement arbitrary class architectures. However, at some point of time, using classes directly will become a cleaner and even more robust solution. For example, the classes infrastructure in OCaml comes with some prebuilt mechanisms, like virtual methods whose implementation is checked at compile time (recursive functors will fail in runtime if any method is not implemented).

So once your domain actually requires you to model complex and open recursive types, so that you need to use modularity to split the definitions of recursive algorithms for those types into different modules, and you have to keep the type polymorphic and open to extension (i.e., the hierarchy is not closed). Then you may choose classes for that.

In the real world such hierarchies are very rare and such design constraints are even rarer. A particular example is a complex and extensible recursive language, i.e., when you have to write an analysis for the language that is extensible (i.e., when the set of branches in AST is not closed). A particular example, would be camlp4 and camlp5 which are frameworks for writing extensible pretty printers and parsers.

However, when not only the language is extensible but the set of analysis (i.e., the set of operations applied to the language) is also meant to be extensible, then you hit the so called Expression Problem. In that case neither algebraic data types, nor class hierarchies will work and you have to switch to Object Algebras aka Tagless Final approach.

25 Likes