The case for objects in OCaml: duck typed values
Personally, I really dislike the object paradigm in programming and believe it’s bound to disappear.
It’s an over-engineered paradigm that was introduced in a very early stage of abstract programming languages and, more and more, programmers and engineers are learning to go for leaner abstractions that are easier to reason about.
To me, this all boils down to inheritance, which is a bad paradigm and a great footgun. Not being able to easily track down what version of a given method will be executed is really bad for reasoning about your code. At least that’s my opinion.
The object model in OCaml, in that regard, is not the most popular aspect of the language. However, I have learned to love using them for a specific type of use.
The reason being that objects are typed by what they do and not what they are named. This is perhaps one of the few, if not the only part of the OCaml typing system that is like that. Even records are nominatives (see below for a discussion about that).
In type theory, I believe that this is the difference between duck typing and nominative typing.
Quick example
Take an example: let’s say that your program has two implementations of timestamps.
- One that is using the
Unix
API: timestamps are floating point numbers and you useUnix
functions to sleep, regular arithmetic to manipulate them etc. - One that is using posix’s timespec.
You can then define a generic type for time intervals:
type t =
< implementation : string
; now : t
; sleep_until : unit
; of_float : float -> t
; to_float : float
; add : t -> t
; subtract : t -> t
; multiply : t -> t
; lt : t -> bool
; lte : t -> bool >
And have the application pick whatever implementation is available:
val implementations : (string, t) Hashtbl.t
Then, each API can implement its own object:
let unix x : t =
object (self)
val x = x
method implementation = "builtin (low-precision)"
method now = {<x = Unix.gettimeofday ()>}
method sleep_until =
let delay = x -. self#now#to_float in
if 0. < delay then (
try Thread.delay delay
with Unix.Unix_error (Unix.EINTR, _, _) -> self#sleep_until)
method of_float x = {<x>}
method to_float = x
method add x' = {<x = x +. x'#to_float>}
method subtract x' = {<x = x -. x'#to_float>}
method multiply x' = {<x = x *. x'#to_float>}
method lt x' = x < x'#to_float
method lte x' = x <= x'#to_float
end
The posix version is sligthly larger and left out for conciseness. However, it simply implements the same object type.
This is very versatile and much more convenient to use dynamically at runtime compared to navigating the complexities of first-class modules, functors and etc.
What about records?
It is also worth noting that records can technically achieve the same type of API. However, records are still nominative so, you need to declare a common interface that all users of the record type have to refer to.
Being able to simply declare “I want an object that I can call with this and that” is also much more flexible and reduces inter-dependencis between your source files.
This is particularly useful to break recursive dependencies. Say that you want to define a type of values that are called source
, that each source
needs to be attached to a clock
and that each clock
can return a list of sources attached to them.
Add to this that the implementation for source
and clock
is pretty big and you want to have them in separate files…
Under these assumptions, it becomes pretty complicated to setup your source code tree to support this. However, with an object type you can simply do:
clock.ml
:
type source = < id: string; ... >
type clock = {
sources: source Queue.t;
...
}
let create () = ...
let attach clock source =
Queue.push clock.sources source
source.ml
:
class source id =
let clock = Clock.create () in
object(self)
initializer
Clock.attach clock (self :> Clock.source)
method id = id
method clock = clock
...
end
You’ve immediately broken up your cyclic dependencies: clocks simply need to declare what they want sources to implement and do not care about how they are named…