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
Deferred.Result.Let_syntax
, or Incr.Let_syntax
. Note that Async.Std
now
opens 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
this:
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 map
and the both
operator, which sacrifices some performance in exchange for
generality, vs the explicit mapN
operators.
Advantages
My experience with the new syntax has been quite positive. Here’s my summary of the wins.
Parallel binds
For libraries like Command.Param
and 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
use.
Variables first
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 begin
and 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
to this.
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 %bind
. The
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
understand.
Disadvantages
It’s not all wine and roses. There are some downsides to let-syntax:
It’s new and different
Enough said.
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.
Also, the %bind
and %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
Here, let*
would be equivalent to let%bind
, and 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>
to
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.
Idioms
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
using 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
rather than:
let%bind x = some_operation in
let%bind () = some_other_operation in
let%bind y = yet_another_thing () in
a_final_thing y
Mixing monads
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
the 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
open of Command.Param.Let_syntax
or Deferred.Let_syntax
as necessary.
Deferred and Deferred.Result
A more complicated case is choosing between the Deferred
and Deferred.Result
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 Deferred.Result
operators.)
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 Deferred
monad
and get a result of type Deferred.Or_error.t
, you might want to do something
like this:
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 Deferred
monad
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.