Earlier this year, we created a ppx_let, a PPX rewriter that introduces a syntax for working with monadic and applicative libraries like Command, Async, Result and Incremental. We’ve now amassed about six months of experience with it, and we’ve now seen enough to recommend it to a wider audience.
For those of you who haven’t seen it, let syntax lets you write this:
let%bind <var> = <expr1> in <expr2>
instead of this:
<expr1> >>= fun <var> -> <expr2>
with analogous support for the map operator, via
let%map. The choice of monad
is made by opening the appropriate
Let_syntax module, e.g., you might open
Incr.Let_syntax. Note that
Deferred.Let_syntax by default.
There’s also support for match statements, e.g.:
match%bind <expr0> with | <pattern1> -> <expr1> | <pattern2> -> <expr2>
is equivalent to:
<expr0> >>= function | <pattern1> -> <expr1> | <pattern2> -> <expr2>
There’s also support for parallel binds and maps, using the
and syntax. So
let%map <var1> = <expr1> and <var2> = <expr2> in <expr3>
is (roughly) equivalent to this:
map3 <expr1> <expr2> ~f:(fun <var1> <var2> -> <expr3>)
This pattern generalizes to arbitrarily wide maps. It’s implemented using
both operator, which sacrifices some performance in exchange for
generality, vs the explicit
My experience with the new syntax has been quite positive. Here’s my summary of the wins.
For libraries like
Incremental, where multi-way map
functions (like `map2` and `map3`) are important, it’s been a
pretty big win in terms of the comprehensibility of the resulting code. This
tends to be the case for applicatives like
Command.Param, which are just
monads without bind. The big advantage is that by writing:
let%map x1 = some_very long expression and x2 = some_other long expression and x3 = yet another_thing in x1 + x2 / x3
we get to put the variable names directly next to the expressions they’re being bound. Using an explicit mapN operator, the result is more awkward:
map3 (some_very long expression) (some_other long expression) (yet another_thing) ~f:(fun x1 x2 x3 -> x1 + x2 / x3)
This is error prone, since it’s easy to mix up the variables and the
expressions. To avoid the corresponding issue in the original Command library,
we used some fancy combinators and the dreaded
step operator, leading to some
hard to understand code. The let-syntax equivalents are materially easier to
Using a let-like syntax lets you put the variable before the definition, which follows the pattern of ordinary OCaml code, and makes it a bit easier to read. This cleans up some otherwise awkward patterns that are pretty common in our code. In particular, instead of this:
begin <expr1>; let <var> = <expr2> in <expr3> end >>= fun meaningful_variable_name -> <expr4>
You can write this:
let%bind meaningful_variable_name = <expr1>; let <var> = <expr2> in <expr3> in <expr4>
which flows a bit more naturally, in part because the meaningful variable name
comes first, and in part because the extra
end are dropped.
Connecting bind to let
Let binds are a lot like monadic binds, even before you add in any special syntax. i.e., this
<expr1> >>= fun x -> expr2
is a lot like this.
let x = <expr1> in <expr2>
This is why monads are sometimes described as “programmable let-binds” (or, relatedly, “programmable semicolons”, which are just let-binds with a unit argument.)
I’ve found this to be a useful analogy in understanding monads, and the analogy is made clearer with let syntax. We have some preliminary reports of this making monadic code more approachable for beginners, which lines up with my intuition.
The similarity between ordinary lets and monadic lets also makes diffs easier to read. e.g., in Async, if some function goes from being synchronous to deferred, the change at the call point would now be from this
let x = some_synchronous_thing () in more things
some_asynchronous_thing () >>= fun () -> more things
With let-syntax, we would instead change it to this.
let%bind x = some_asynchronous_thing () in more things
i.e., the only thing that would change would be the addition of
resulting diff is more targeted, making the substance of the change a bit easier
to see, making refactoring that adds or remove blocking easier to do and
It’s not all wine and roses. There are some downsides to let-syntax:
It’s new and different
It’s kinda ugly
This is a matter of taste, but I’ve heard some distaste for the percent sign itself. That’s something forced on us by PPX, but I don’t exactly disagree.
%map are a little wordy. There’s been some talk of
adding the ability to define alternate let syntaxes in OCaml proper, which would
allow you to write something like this.
let* x = some_asynchronous_thing () in let* y = some_other_thing () in let+ z = a_third thing in x + y + z
let* would be equivalent to
let+ is equivalent to
let%map. Again, it’s not clear to me that this would all in be a win.
I personally find the new syntax all in less ugly than using infix operators everywhere, but again, tastes vary.
Unit binds aren’t great
In particular, because we have no “monadic semicolon”; in the syntax, you have to go from this:
<expr1> >>= fun () -> <expr2>
let%bind () = <expr1> in <expr2>
which is not ideal, since it’s not parallel to the normal semicolon syntax for this outside of the monad. We’ve looked at making it possible to do something like:
<expr1> ;%bind <expr2>
which would be more parallel with ordinary OCaml syntax, but that’s not yet possible, and it’s not clear it’s a net win.
It changes how you think about interleaving in Async
In Async, when you write:
load_something () >>= fun x -> process_thing x
you can think of the point where interleaving can happen as the place where the bind operator is found. With let-syntax:
let%bind x = load_something () in process_thing x
the location is different, and somewhat less obvious. My experience has been that this was easy to adjust to and hasn’t tripped me up in practice, but it’s a concern.
A few thoughts on how to use let syntax effectively.
Let syntax for variables, infix for point-free
One might wonder whether there’s any use for the infix operators once you are
Let_syntax. I believe the answer is yes. In particular, the style we’ve
adopted is to use let syntax when binding a variable.
let%bind x = some_expression in
and infix operators when going point-free, i.e., when not binding variables.
let v = some_function x >>| ok_exn in
One special case of this is binding unit, where some people prefer to use the following pattern, since we don’t have a nice monadic semi-colon yet.
let%bind x = some_operation in some_other_operation >>= fun () -> let%bind y = yet_another_thing () in a_final_thing y
let%bind x = some_operation in let%bind () = some_other_operation in let%bind y = yet_another_thing () in a_final_thing y
One change we made recently was to add the return function and the monadic infix
operators to the
Let_syntax module that one opens to choose a monad. This has
the useful property of causing one to basically switch cleanly from one monad to
another when you open the
Let_syntax module. Mixing multiple monads in the
same scope is hard to think about.
Command.Param and Deferred
A few interesting cases that come up are mixing the
Command.Param syntax with
Deferred syntax. This one is pretty easy to solve, because you don’t
typically need to mix them together, really. It’s just that in the body of the
command, you often want
Deferred, but in the definition of the command line
parser, you want to use
Command.Param. This can be handled by doing a local
Deferred.Let_syntax as necessary.
Deferred and Deferred.Result
A more complicated case is choosing between the
monads. In Async, there are infix operators that let you use both sets of bind
and map operators (basically, with question-marks at the end of the ordinary
infix operators for the
Mixing these operators together in a single scope can be a little awkward, often
leaving people to add and remove question-marks until things compile. With let
syntax, you really have to pick a single monad, which is easier to read, but
then requires some changes in behavior. In particular, you often need to move
things from one monad to another. For example, if you’re in the
and get a result of type
Deferred.Or_error.t, you might want to do something
let open Deferred.Let_syntax in let%bind v = some_operation x y >>| ok_exn in
Here, mapping over
ok_exn will take the error and raise it, if necessary.
Similarly, if you’re using an operation that’s in the ordinary
but you’re operating in the
Deferred.Result monad, you might want to lift that
operation up, i.e.:
let open Deferred.Result.Let_syntax in let%bind v = some_other_operation x y |> Deferred.map ~f:(fun x -> Ok x) in
This is something of a mouthful, so we just added the
Deferred.ok function, so
on our latest release you can write:
let open Deferred.Result.Let_syntax in let%bind v = some_other_operation x y |> Deferred.ok in
This idiom is useful is useful whether or not you’re using let syntax.