Now on topic. The IO Monad is an ugly and furry byproduct of fighting between the beautiful pass-by-name purely mathematical semantics of Haskell and the ugly real world. The IO Monad is still cute, because it is furry, other solutions were ugly naked. The fur hides the ugliness.
Let’s shave it. The semantics of Haskell is very weak1, it doesn’t guarantee the order of execution, the execution itself (i.e., that a value is evaluated at all), and reserves the right to rearrange any computations in any order which preserves explicit data dependencies. This gives a lot of power to the language implementer, which, unfortunately, had to be stolen from the language user. And the mere existence of the IO Monad showcases this.
The language designers still need to give their uses an ability to tell - “this must be called after this, in that order”. Without that it would be impossible to write computer programs that manipulate and interact with the real world, since in the real world the order of events matters. That is basically the raison d’être of the IO Monad. The IO Monad is the only way to impose an ordering on computations.
On the other hand, OCaml has a much stronger semantics, it precisely defines the order of evaluation and let the computations have any side-effects and guarantees that it won’t reorder them in a way which will be observable. This steals a lot of power from the hands of the language implementer, making it much harder to implement the compiler, but gives this power to the users. This a fair trade off, since usually most of the human effort is spent on using the language, rather than developing it. I.e., the compiler is written once, but used many times. So it is probably fine to invest a lot in the compiler.
Such strict semantics, comes with a price of its own. Not only the developers of the language is paying it, but unfortunately the users have to pay their share. Things like language restriction, lack of referential transparency, monocore implementation, and missed optimization opportunities by the compiler, is the price that we, as the language users, have to pay for the impurity. So, if we’re already paying this price, what would be the reason for not using what we payed for? It would be like buying a plane ticket and not taking the flight, preferring a sailboat instead, as sails are pure and do not pollute the environment. But we already paid the fare, and the plane is still flying, just without us.
But enough metaphors. Let’s assume we will write an IO Monad in OCaml. In fact, I believe, that a few people did this, given how trivial it is to implement it in OCaml with its strict semantics. The main question is whether it will receive any traction? Probably not, especially, since our IO Monad will be subsumed by the Async monad without being asynchronous.
So let’s implement the IO Monad right now and here. Given that OCaml is strict and that the order of function applications imposes the order of evaluation, the IO Monad is just a thunk, e.g.,
type 'a io = unit -> 'a
A pure value could be lifted into the IO Monad as
let return x = fun () -> x
and the computations are chained using the bind
operator
let (>>=) c1 c2 = fun () -> c2 (c1 ())
And this is actually the Fun monad, which is so useless that I even forgot to add to the table of contents in the Monads Library Indeed, in OCaml, it looks superfluous, because of its meta-circular definition, it says that an application is an application, roughly. And lifting an effectful computation into the IO monad distills just to adding an extra ()
argument, e.g.,
let print_endline x () = print_endline x
But, let’s not underestimate the power of abstraction, as indeed, we can still benefit from the fur that monads give us and hide the fact that IO is just a thunk. This will make our IO operations explicit and employ the OCaml typechecker to track side-effects for us. Sounds good, let’s use the Monads library for that. E.g.,
open Monads.Std
module IO = Monad.Fun
open IO.Syntax
let print_endline s =
!$print_endline !!s
Now, we have a tedious task of lifting every single operation into our IO Monad. But wait, we have exceptions, which we don’t want to escape from our monad. They are side-effects anyway. So let’s sprinkle the exception monad, fortunately, Fun
is also a transformer, so let’s just mix it in,
module IO = Monad.Fun.Make(Monad.Result.Exception)
Now we have a full-fledged IO Monad, which is totally subsumed with the Async monad. So why would anyone prefer it to async, given that async offers more and is also asynchronous?
Ah, probably because we forgot to add variables. Well, that’s easy, we can mix in the state monad, with the state represented as a heterogenous map, e.g., Univ_map.t
from the Base library. And our reference type will be the type 'a var = Type_equal.Id.t
… but this is the particular point where our nice IO monad is turning into an ugly monster.
So first of all, what we did is wrote a metacircular embedding of OCaml inside of OCaml with the only benefit of having an explicit denotation of side-effects in the computation type, i.e., given proper discipline (i.e., assuming a user won’t use built-in function such as print_endline
) we can guarantee that any side-effectful computation will have the 'a io
type. But unfortunately, this won’t have any meaning to the compiler itself, which won’t be able to benefit from our pure code and generate better machine code, as the compiler can’t trust in users’ discipline.
However, the most important part is that we were able to implement the IO monad using pure OCaml, while it is impossible to implement the IO Monad in Haskel. Basically, in Haskell, the IO monad is a language primitive. A primitive that is very fat and mixes too many concepts in one cocktail, including the order of evaluation, side-effects, and state. Too much to my taste. OCaml lets us build much nicer abstractions, that included only things that we need. So we don’t really need the IO Monad, instead, we can have monads that do what we want. Besides, OCaml has a rich module system with powerfull abstraction mechanisms, so monad is not the only way to add some fur to your computations to make them fancy. But this is a completely different story.
1) Weak doesn’t mean bad here, I’m using this word with the precise mathematical meaning, Haskell is as a weaker theory than many other languages, as Haskell gives us very few guarantees. This weak theory is leveraged by the language, as much more assumptions could be made - in other words, the language implementer is having much more freedom, than in coventional languages with strict call-by-value semantics.