The other day I saw this post on OCaml discussed in Hacker News and Lobsters.
Almost two years ago I rewrote the Austral compiler from Standard ML to OCaml, so I thought I’d share my thoughts on OCaml after using it in writing a complex software project, explaining what is good and what is bad and how it compares mainly to Haskell.
If this seems overwhelmingly negative, it’s because the things OCaml does right are really just uncontroversial. They’re obviously right and hardly worth pointing out. It’s actually a weirdly optimistic thing: that a language with so many glaring deficiencies stands far above everything else.
Contents
- Syntax
- Modules: Better is Worse
- Semantics
- Pragmatics
- At Least It’s Not Haskell
- My OCaml Style
- Should You Use OCaml?
Syntax
Yeah, yeah, de gustibus, and people spend way too much time whining about syntax and other superficial issues, rather than focusing on language semantics and pragmatics.
But I’m not a partisan about syntax. I genuinely think code written in C, Java, Lisp, Pascal, and ML can be beautiful in different ways. Some of these complaints will be personal, others will be more objective.
Aesthetics
ML was born as the implementation language of a theorem prover, so naturally the syntax is meant to look like whiteboard math.
And it does look good for math. If you’re writing something like a symbolic differentiation engine:
type expr =
| Const of float
| Add of expr * expr
| Sub of expr * expr
| Mul of expr * expr
| Div of expr * expr
let rec diff (e: expr): expr =
match e with
(* c' = 0 *)
| Const _ ->
Const 0.0
(* (f + g)' = f' + g' *)
| Add (f, g) ->
Add (diff f, diff g)
(* (f - g)' = f' - g' *)
| Sub (f, g) ->
Sub (diff f, diff g)
(* (fg)' = f'g + fg' *)
| Mul (f, g) ->
Add (Mul (diff f, g), Mul (f, diff g))
(* (f/g)' = (f'g - g'f)/gg *)
| Div (f, g) ->
Div (Sub (Mul (diff f, g), Mul (f, diff g)), Mul (g, g))
Then it’s simply delightful. It does tend to fall apart for everything else however.
OCaml, like Haskell, is expression-oriented, meaning that there is no separation of statements (control flow, variable assignment) and expressions (evaluate to values) and instead everything is an expression. Most expressions in OCaml tend not to have terminating delimiters.
This is very vague, but ML-family (meaning Standard ML, OCaml, Haskell and
derivatives) code often feels like the expressions are “hanging in the air”, so
to speak. Terminating delimiters (like semicolons in C or end
in
Wirth-family languages) make the code feel more “solid” in a way.
And expression orientation (which most modern languages advertise as a feature)
cuts both ways. The benefit is simplicity and symmetry: you don’t need both an
if
statement and a ternary if expression. You can have a big expression that
computes a value and then assigns it to a containing let
, like so:
let a: ty =
match foo with
| Foo a ->
(* ... *)
let bar =
(* ... *)
(* imagine deeply nested expressions *)
in
(* etc *)
Without having to use an uninitialized variable or refactor your code into too-small functions. However, this generality comes at a cost: you can write arbitrarily deep and complex expressions, where a statement-oriented language would force you to keep your code flatter and break it down into small functions.
It takes discipline to write good code in an expression-oriented language. I often see e.g. Common Lisp code with functions hundreds of lines long. It’s almost impossible to track the flow of data in that context. This, by the way, is why Austral is statement-oriented, despite every modern language moving towards expression-oriented syntax.
Declaration Order
In OCaml, like in C, declaration must appear in dependency order. That is, you can’t write this:
let foo _ =
bar ()
let bar _ =
baz ()
let baz _ =
print_endline "muh one-pass compilation"
Instead you must write:
let baz _ =
print_endline "muh one-pass compilation"
let bar _ =
baz ()
let foo _ =
bar ()
Alternatively, you can use and
to chain your declarations:
let rec foo _ =
bar ()
and bar _ =
baz ()
and baz _ =
print_endline "muh one-pass compilation"
And the same thing is true of types:
type foo = Foo of bar
and bar = Bar of baz
and baz = Baz of unit
But, you can’t interleave an and
-chain of functions with one of types. So
you have a choice:
-
You can write all of your code backwards, with the utility functions and the leaf-nodes of the call graph up front, and the important code at the bottom.
-
Or, you can write a big
and
-chain of types at the start of the file, followed by a bigand
-chain of functions for the remainder of the file.
Option one makes the code harder to read, and option two feels incredibly brittle.
Haskell gets this right: declaration order is irrelevant. Austral also allows declarations to appear in any order, partly because of my frustration with this aspect of OCaml.
Note that having a module interface doesn’t save you here, because interfaces
and modules are compiled separately. So if you have a Foo.mli
file like this:
val foo : unit -> unit
val bar : unit -> unit
val baz : unit -> unit
The corresponding .ml
file still has to have the declarations appear in
dependency order.
Comments
OCaml has no single-line comment syntax. Instead, you have block comment syntax, like so:
(* I'm a comment. *)
The double parenthesis-asterisk pair is torture to write on my fingers. Again, Haskell does this right: single-line comments are a double hyphen. Quick and easy.
Unlike C and other languages, comments can be nested, like in Common Lisp:
(* I'm a (* nested *) comment. *)
This is useful for commenting-out large chunks of code.
Type Specifiers
The syntax for type specifiers and type annotation is a bit of a pain.
First, there’s inconsistency: a * b
is the type specifier for a tuple
(asterisk as in product), but the syntax for constructing a tuple is
(a, b)
:
let derp: int * string = (0, "")
Again, Haskell gets this right:
derp :: (Int, String)
derp = (0, "")
Similarly, the unit type is unit
but its value is ()
. And, again, Haskell
gets this right: the unit type is the empty tuple, denoted ()
, and its sole
value is ()
.
Generic Types
Generics are weird. Most modern languages are moving towards Name[Arg, ...,
Arg]
as the syntax for a generic type specifier. So in Swift you’d write
List[Int]
, but in OCaml you write int list
. The order is inverted, but I
think the argument is that you can read it like it’s English?
Haskell is not much better: List Int
. This obsession with terseness is a big
problem: please give me punctuation.
Type Annotations
Type annotations go in the same line as functions:
let derp (a: foo) (b: bar option) (c: baz * quux): herp =
(* ... *)
Which isn’t bad, but it’s a functional language, so you end up passing more stuff in. Haskell makes this a bit more comfortable:
derp :: Foo -> Maybe Bar -> (Baz, Quux) -> Herp
derp a b c =
-- ...
Semicolons Work Sometimes
Semicolons let you sequence statements. They work inconsistently. This works:
let foo _ =
print_endline "Hello, world!";
true
This doesn’t:
let foo _ =
if true then
print_endline "Hello, world!";
true
else
false
Which makes it hard to insert debugging print
statements. You have to
transform the above into the more tiresome:
let foo _ =
if true then
let _ = print_endline "Hello, world!" in
true
else
false
There’s an easy way to solve this: add an end if
delimiter. Again: terseness
bites.
Inconsistencies
As above: the syntax for tuple and unit types and values is inconsistent.
The syntax for a list literal is [1; 2; 3]
. This is because the comma is an
infix operator, so if you typo this as [1, 2, 3]
you don’t get a syntax error,
that’s a singleton list with a tuple as its element type.
Types are defined with type
, both in module interfaces and module bodies:
module type FOO = sig
type t
end
module Foo: FOO = struct
type t
end
But values are defined with let
and declared with val
:
module type FOO = sig
val a: int
end
module Foo: FOO = struct
let a: int = 10
end
And, as you can see above, the syntax for modules is inconsistent. In Standard
ML module interfaces are called signatures, and module bodies are called
structures. In OCaml, these are called module types and modules
respectively—but it’s like they forgot to fully update the syntax, so sig
defines a module type
and struct
defines a module
.
In Standard ML you’d write:
signature FOO = sig
(* ... *)
end
structure Foo: FOO = struct
(* ... *)
end
Which is at least consistent.
Nested Match Expressions
Again, because match
statements have no terminating delimiter, you can’t nest
them in the obvious way:
let rec safe_eval (e: expr): float option =
match e with
| Const f -> Some f
| Add (a, b) ->
match safe_eval a, safe_eval b with
| Some a, Some b -> Some (a +. b)
| _, _ -> None
This will yield a confusing type (not syntax!) error. Instead, you have to parenthesize:
let rec safe_eval (e: expr): float option =
match e with
| Const f -> Some f
| Add (e1, e2) ->
(match safe_eval e1, safe_eval e2 with
| Some f1, Some f2 -> Some (f1 +. f2)
| _, _ -> None)
(* ... *)
So everything gets slightly out of alignment, and when you have a few nested
match
statements, the code starts to look like Lisp, with a trailing train of
close parentheses on the last line.
You can avoid this by refactoring each match into a separate function, but that has other costs.
Do Notation
OCaml would benefit from having this. Putting IO aside, do
notation is
fantastic for writing succint error handling in functional, exception-free code,
and also for doing the “mutation-free mutation” pattern. A lot of the Austral compiler looks like this:
let rec monomorphize_stmt (env: env) (stmt: tstmt): (mstmt * env) =
match stmt with
| TSkip _ ->
(MSkip, env)
| TLet (_, name, ty, value, body) ->
let (ty, env) = strip_and_mono env ty in
let (value, env) = monomorphize_expr env value in
let (body, env) = monomorphize_stmt env body in
(MLet (name, ty, value, body), env)
| TAssign (_, lvalue, value) ->
let (lvalue, env) = monomorphize_lvalue env lvalue in
let (value, env) = monomorphize_expr env value in
(MAssign (lvalue, value), env)
(* ... *)
Which in do
notation could be written more succinctly.
Modules: Better is Worse
Fig 1. Society if OCaml had type classes instead of modules.
The module system is the central feature that sets OCaml and Standard ML apart. This is how OCaml does ad-hoc polymorphism with early binding.
The module system consists of:
- Module types, which define the interface of a module. A module type is a collection of types and functions.
- Modules, which conform to an interface and define its types and functions.
- Functors, which are functions from modules to modules. They take modules as arguments and combine them into new modules.
Few other languages have anything like this. Modula-2 and Ada work kind of like this, but they are much lower-level languages than OCaml.
Modules Are Better
Modules are similar to type classes in Haskell, but they are more general:
- A module can have multiple types, not just one.
- Multiple modules can implement the same interface, while in Haskell, a type can only implement a type class in one way.
Modules Are Worse
The drawback is you lose implicit instantiation. You have to manually
instantiate modules, and manually refer to them. You can’t write show x
, you
have to write FooShow.show x
. This adds a baseline level of line noise to all
code that uses modules.
It makes composing code harder. In Haskell, you can define a type class and say
that the type parameters can only accept types that implement other type
classes. This lets you naturally compose implementations: for example, you can
make it so that if a type A
implements the equality type class Eq
, and a
type B
also implements Eq
, then the tuple (A, B)
implements Eq
in the
obvious way.
In OCaml the only way to do this is with functors, which, again, have to be manually instantiated, and the resulting module referred to by name.
And this is also anti-modular, since you can have multiple modules created by instantiating the same functor over the same structure, and this duplication may not be trivial to erase. In Haskell, the type class database is global and there is no duplication.
So modules are more general, more flexible, and more powerful. They are also vastly more inconvenient to use, and their added power is more than undone by how cumbersome they are to use. Type classes, on the other hand, give you 80% of the features, let you implement the remaining 20% without much trouble, and are easier to use and to compose.
It’s not even fair to say type classes are worse is better: type classes are better is better.
Equality
You’d think equality in OCaml would work like this:
module type EQUALITY = sig
type t
val eq : t -> t -> bool
end
module IntEquality: EQUALITY = struct
type t = int
let eq (a: int) (b: int): bool =
a = b
end
Rather, equality in OCaml is special-cased. You have a magical function
with signature 'a -> 'a -> bool
that the compiler implements for every
type. Standard ML does the same. Compare this to Haskell, where equality is
implemented entirely in userspace via a type class.
This should be a sign that modules are not good enough. You should either have the courage of your convictions—and make equality into a module type—or you should implement some bridging solution like modular implicits to make modules have the convenience of type classes.
Modular implicits for OCaml were first proposed in 2015. There’s an open pull request from 2019 implementing a prototype. I don’t think this is going to be merged any time soon.
Multiple Implementations Are Unnecessary
In Haskell, typically each type can implement each type class in one obvious way. It’s rare you need multiple distinct instances.
When you do, you can just use newtype
wrappers:
newtype IntAsc = IntAsc Int
deriving (Eq, Show)
newtype IntDesc = IntDesc Int
deriving (Eq, Show)
instance Ord IntAsc where
compare (IntAsc a) (IntAsc b) = compare a b
instance Ord IntDesc where
compare (IntDesc a) (IntDesc b) = compare b a
Semantics
Haskellers don’t read this.
Currying is Bad
Currying is bad. Punctuation is good. Adjacency is not punctuation.
It’s “cute”, I guess, if you like terse math notation, but it comes at huge
costs. In a normal language where you write f(x,y,z)
, if you forget an
argument, or add another one, you get an error saying the arity doesn’t
match. If you swap the order of two arguments of distinct types, you get a type
error.
In OCaml, if you make any of these mistakes, you don’t get an error to that effect. You get a type error downstream of your typo. Consider:
let foo (a: int) (b: float) (c: string): unit =
let _ = (a, b, c) in ()
Here’s the error message for each kind of mistake:
Error | Code | Message |
---|---|---|
Missing | foo 0 1.0 |
This expression has type string -> unit but an expression was expected of type unit . |
Extra | foo 0 1.0 "" 1 |
This function has type int -> float -> string -> unit . It is applied to too many arguments; maybe you forgot a ; . |
Swap | foo 1 "" 0.0 |
This expression has type string but an expression was expected of type float . |
Only in the case where you swap two arguments do you get a reasonable error message.
Gradually you learn, through trial an error, to pattern-match on the error
messages. When I see something like “this has type a -> b
” I know I forgot an
argument. When I see “applied to too many arguments” I know I added an extra
one.
Partly this is a consequence of type inference, which is why I put this under semantics rather than syntax. Also because I’ve complained enough about syntax.
You can avoid currying with tuples, but it makes function type annotations harder to write. And it doesn’t play well with much of the standard library.
Type Inference is Bad
It’s bad because the type system doesn’t know which type constraints are correct and which are the result of errors the programmer made. So when you make a mistake, you’re still giving constraints to the inference engine. Erroneous type inferences propagate in every direction. Error messages appear hundreds of lines of code away from their origin.
In the worst cases, you end up adding type annotations, one let
binding at a
time, until the error messages start zeroing in on the problem. And so you’ve
wrapped back around to writing down the types.
It’s bad because types are (part of the) documentation, and so you end up annotating the types of function arguments and return types anyways.
It’s bad because when reading code, you either have to mentally reconstruct the type of each variable binding, or rely on the tooling to tell you the type, and the more obscure the language, the less reliable the tooling.
It’s bad because there are many circumstances (reading a patch file, a GitHub diff, a book, a blog post) where you doin’t have access to a language server, so you have to mentally reconstruct the types.
It’s bad because for any non-trivial type system it’s undecidable. It is not robust to changes to the type system, and seemingly minor changes will tip you over to undecidability.
Finally: type inference is not fundamental to functional programming, contrary to popular belief. You can just annotate types. If the type of an expression is hard to predict, that’s probably a signal that the type system is too complex, but you can always force a type error (or use typed holes in languages that support them) to see the actual type.
Mutation
OCaml is impure: you can mutate memory and perform IO anywhere. Unlike Java or Python, references are not implicit any time you have an object. Rather, you have a (garbage-collected) reference type that you can dereference and store things into.
This is good. In theory purely-functional languages have a higher performance ceiling, because computation can be scheduled in parallel. In practice this is rarely realized because the “sufficiently smart compiler” doesn’t exist.
People have been saying, for some 20 years now, that single-core scaling has stalled and the von Neumann architecture has no future and we’d better port everything so parallel-by-default functional languages. What will actually happen is we’ll get better at designing semantics for fundamentally-imperative languages with controlled aliasing and side effects, more Rust than Haskell. The borrow checker might be hard, but I’ll choose that over some trans-dimensional monad optics stack that takes six GiB of RAM to print “Hello, world!”.
Pragmatics
The stuff outside the spec: the tooling, community etc.
PPX
OCaml doesn’t have macros built into the language. Instead you use PPX, which lets you write programs that manipulate OCaml source code at the AST level.
Mostly this is used for the equivalent of Haskell’s derive
. So you can write:
type expr =
| Const of float
| Add of expr * expr
| Sub of expr * expr
| Mul of expr * expr
| Div of expr * expr
[@@deriving show, eq]
And the @@deriving
annotation is replaced with the functions:
show_expr : expr -> string
equal_expr : expr -> expr -> bool
There’s not much to say about this. It’s convenient. But it doesn’t play well with functors.
Tooling
My standard for language tooling:
- I should be able to run the commands from the docs, in the order in which they appear in the docs, and things should work.
-
The standard Swiss army knife tool should have a project skeleton generator that gives me a project with:
- Library code.
- A basic “Hello, world!” CLI entrypoint.
- Unit tests.
- Stub documentation.
And all relevant commands (
build
,test
,generate-docs
) should work from the project skeleton immediately. entrypoint, and unit tests.
Surprisingly few languages clear this short bar.
What is OCaml tooling like? There’s dune
, the build system, and
opam
, the package manager.
They’re alright. I managed to get them working, somewhat, for Austral. But I haven’t touched the configuration since and every time I have to I sigh.
Merlin works with Emacs except when it doesn’t. I recently switched to NixOS so I might be able to get things working more reliably.
This is infinitely better than the state of the art for e.g. Python (and the
difference is even more stark when you consider how much money and time has been
invested into the Python ecosystem compared to OCaml). The worst I experience
with OCaml tooling is, oh, dune
is so tiresome to use, I don’t know how to do
X, I’m lazy; whereas with Python any time you see an error message from pip
you might as well take a blowtorch to your laptop because that’s going to be
easier to repair.
How Do I Profile?
Seriously. I looked this up. All the documentation seems to assume
you’re running ocamlc
manually, like it’s the 1990’s and you’re building a
one-file script, not a big application with tens of transitive dependencies. I
need to know how to do this at the dune
level.
I managed to cobble together something using prof
and successfully profiled
the Austral compiler, but then I forgot what I did to get that to work.
Testing
Different tasks have different activation energy—the amount of effort to accomplish them. And in the end, the code that gets written is the code that is easy to write.
That’s why OCaml programs will have 10x more types than Java programs: because in OCaml you can define a type in three lines of code, while in Java defining the humblest class requires opening a new file and writing the entire declaration out in triplicate.
Languages, tooling, and the community best practices affect the shape of the activation energy landscape, and channel you into a particular way of writing code.
In particular: the experience of writing unit tests varies markedly by
language. In Python I tend to write more tests, because Python is very dynamic
and test frameworks take advantage of that. I just add a class and write a few
methods with names starting with test_
and I have my unit tests.
Test autodiscovery is a huge boon. Test frameworks where you have to manually register tests make it more tiresome and time-consuming to write tests, and I end up writing fewer.
Maybe there’s a way to do quick, succinct tests in OCaml with autodiscovery. If
there is, I haven’t found it, because setting up even the most basic unit tests
with dune
was already a pain.
The existence of tooling is worthless. The Right Way to do things should be included in the project skeleton generator, so it’s not just experts who know how to do it.
Minor Complaints
-
compare
returns in anint
: like in C,compare a b
returns 0 to indicatea = b
, a negative integer to indicatea < b
, and a positive integer to indicatea > b
. This is so you can implement integer comparison by doingb - a
.And needless to say this is an archaism. It’s too late to change, but, for the _n_th time, Haskell does this right: comparison returns a type
Ordering
with constructorsLT
,EQ
,GT
. -
compare
is a special case: like equality, it’s special-cased into the language. The type iscompare: 'a -> 'a -> int
which doesn’t make sense.This should be implemented by a module analogous to Haskell’s
Ord
type class. -
The zoo of conversion functions:
string_of_int
,bool_of_string
, etc. Again, have the courage of your convictions and make this a module type.
At Least It’s Not Haskell
Haskell is the main competitor to OCaml. The areas where Haskell is superior to OCaml are:
- Consistent syntax.
- Declarations can appear in any order.
- Better import system.
- Tooling might be better (low-confidence, haven’t used Haskell in anger much).
- Type classes are better than modules.
do
notation is great for error handling.
Where Haskell is worse:
- Infix operators are bad. Custom infix operators are worse.
- Haskell is very indentation-sensitive, more so than Python. Slight, harmless-looking cosmetic changes can break the parser.
- Lazy evaluation is bad.
- Lazy data structures are bad.
- Is purity worth it? Not really.
- Every file starts with declaring thirty language extensions.
My OCaml Style
I have a very conservative OCaml style. Types, functions, let
, match
,
if
. That’s it. What else do you need?
I never use polymorphic variants, or named arguments (just define a new record type lol). I factor things out and try to keep expression nesting to a minimum. I don’t like functions that span multiple pages, but sometimes that’s hard to avoid, especially when you have large sum types.
Much of the OCaml I see in the wild is a mess of un-annotated, deeply-nested, functorized code that’s been PPX’d to death.
Should You Use OCaml?
While there’s a lot that makes me shake my head, there’s really nothing in OCaml that makes me scream in terror. The language:
- Is statically typed.
- Has a solid type system, by which I mean algebraic data types with exhaustiveness checking.
- Is garbage collected.
- Compiles to native.
- Has good out of the box performance.
- Is not too galaxy brained.
- Lets you mutate and perform IO to your heart’s content.
- Has a decent enough ecosystem.
And surprisingly few languages check these boxes that don’t also have significant drawbacks.
If you want a statically and strongly typed garbage-collected language that compiles to native and doesn’t require you to change the way you work too much, you should use OCaml.
Particularly if you what you want is “garbage collected Rust that’s not Go”, OCaml is a good choice.