Friday, January 4, 2013

Experiences using Result.t vs Exceptions in Ocaml

Disclaimer: I have not compiled any of the example code in this post. Mostly because they are snippets meant to illustrate a point rather than be complete on their own. If they have any errors then apologies.

Previously I gave an introduction to return values vs exceptions in Ocaml. But a lot of ideas in software engineering sound good, how does this particular one work out in real software?

I have used this style in two projects. The first is a project that was originally written using exceptions and I have converted most of it to using return values. The second is one that was written from the start using return values. They can be found here and here. I make no guarantees about the quality of the code, in fact I believe some of it to be junk. These are just my subjective opinions in writing software with a particular attribute.

The Good

Expected Result

The whole system worked as expected. I get compile-time errors for all failure cases I do not handle. This has helped me catch some failure cases I had forgotten about previously, some of which would require an unlikely chain of events to hit, which would have made finding in a test harder, but obviously not impossible. In particular, ParaMugsy is (although the current rewrite does not cover this yet) meant to run in a distributed environment, which increases the cost of errors. Both in debugging and reproducing. In the case of opass, writing the DB is important to get right. Missing handling a failure here can mean the users database of passwords can be lost, a tragic event.

Not Cumbersome

In the Introduction I showed that for a simple program, return-values are no more cumbersome than exceptions. In these larger projects the same holds. This shouldn't really be a surprise though, as the monadic operators actually simulate the exact flow of exception code. But the 'not cumbersome' is half of a lie, which is explained more below.

Refactoring Easier

Ocaml is a great language when it comes to refactoring. Simply make the change you want and iterate on compiler errors. This style has made it even easier for me. I can add new failures to my functions and work through the compiler errors to make sure the change is handled in every location.

Works No Matter The Concurrent Framework

The original implementation of ParaMugsy used Lwt. In the rewrite I decided to use Core's Async library. Both are monadic. And both handle exceptions quite differently. Porting functions over that did return-values was much easier because they didn't rely on the framework to handle and propagate failures. Exceptions are tricky in a concurrent framework and concurrency is purely library based in Ocaml rather than being part of the language, which means libraries can choose incompatible ways to handle them. Return-values give one less thing to worry about when porting code or trying to get code to work in multiple frameworks.

The Bad

Prototyping Easier With Exceptions

The whole idea is to make it hard to miss an error case. But that can be annoying when you just want to get something running. Often times we write software in such a way that the success path is the first thing we write and we handle the errors after that. I don't think there is necessarily a good reason for this other than it's much more satisfying to see the results of the hard work sooner rather than later. In this case, my solution is to relax the ban on exceptions temporarily. Any place that I will return an Error I instead write failwith "not yet implemented". That way there is an easily grepable string to ensure I have replaced all exceptions with Error's when I am done. This is an annoyance but thankfully with a fairly simple solution.

Cannot Express All Invariants In Type System

Sometimes there are sections of code where I know something is true, but it is not expressible in the type system. For example, perhaps I have a data structure that updates multiple pieces of information together. I know when I access one piece of information it will be in the other place. Or perhaps I have a pattern match that I need to handle due to exhaustiveness but I know that it cannot happen given some invariants I have established earlier. In the case where I am looking up data that I know will exist, I will use a lookup function that can throw an exception if it is easiest. In the case where I have a pattern match that I know will never happen, I use assert. But note, these are cases where I have metaphysical certitude that such events will not happen. Not cases where I'm just pretty sure they work.

Many Useful Libraries Throw Exceptions

Obviously a lot of libraries throw exceptions. Luckily the primary library I use is Jane St's Core Suite, where they share roughly the same aversion of exceptions. Some functions still do throw exceptions though, most notably In_channel.with_file and Out_channel.with_file. This can be solved by wrapping those functions in return-value ones. The problem comes in: what happens when the function being wrapped is poorly documented or at some point can throw more exceptional cases than when it was originally wrapped. One option is to always catch _ and turn it into a fairly generic variant type. Or maybe a function only has a few logical failure conditions so collapsing them to a few variant types makes sense. I'm not aware of any really good solution here.

A Few Examples

There are a few transformations that come up often when converting exception code to return-value code. Here are some in detail.

Building Things

It's common to want to do some work and then construct a value from it. In exception-land that is as simple, just something like Constructor (thing_that_may_throw_exception ()). This doesn't work with return-values. Instead we have to do what we did in the Introduction post. Here is an example:

let f () =
  let open Result.Monad_infix in
  thing_that_may_fail () >>= fun v ->
  Ok (Constructor v)

Looping

Some loops cannot be written in their most obvious style. Consider an implementation of map that expects the function passed to it to use Result.t to signal failures. The very naive implementation of map is:

let map f = function
  | []    -> []
  | x::xs -> (f x)::(map xs)

There are two ways to write this. The first requires two passes over the elements. The first pass applies the function and the second one checks which value each function returned or the first error that was hit.

let map f l =
  Result.all (List.map f l)

Result.all has the type ('a, 'b) Core.Std.Result.t list -> ('a list, 'b) Core.Std.Result.t

The above is simple but could be inefficient. The entire map is preformed regardless of failure and then walked again. If the function being applied is expensive this could be a problem. The other solution is a pretty standard pattern in Ocaml of using an accumulator and reversing it on output. The monadic operator could be replaced by a match in this example, I just prefer the operator.

let map f l =
  let rec map' f acc = function
    | []    -> Ok (List.rev acc)
    | x::xs -> begin
      let open Result.Monad_infix in
      f x >>= fun v ->
      map' f (v::acc) xs
    end
  in
  map' f [] l

I'm sure someone cleverer in Ocaml probably has a superior solution but this has worked well for me.

try/with

A lot of exception code looks like the following.

let () =
  try
    thing1 ();
    thing2 ();
    thing3 ()
  with
    | Error1 -> handle_error1 ()
    | Error2 -> handle_error2 ()
    | Error3 -> handle_error3 ()

The scheme I use would break this into two functions. The one inside the try and the one handling its result. This might sound heavy but the syntax to define a new function in Ocaml is very light. In my experience this hasn't been a problem.

let do_things () =
  let open Result.Monad_infix in
  thing1 () >>= fun () ->
  thing2 () >>= fun () ->
  thing3

let () =
  match do_things () with
    | Ok _ -> ()
    | Error Error1 -> handle_error1 ()
    | Error Error2 -> handle_error2 ()
    | Error Error3 -> handle_error3 ()

Conclusion

Using return-values instead of exceptions in my Ocaml projects has had nearly the exact output I anticipated. I have compile-time guarantees for handling failure cases and the cost to my code has been minimal. Any difficulties I've run into have had straight forward solutions. In some cases it's simply a matter of thinking about the problems from a new perspective and the solution is clear. I plan on continuing to develop code with these principles and creating larger projects. I believe that this style scales well in larger projects and actually becomes less cumbersome as the project increases since the guarantees can help make it easier to reason about the project.

6 comments:

  1. Regarding the last example (try/with), why do you introduce this extra functions? The natural translations seems to be pretty-direct:

    let () =
    match
    let open Result.Monad_infix in
    thing1 () >>= fun () ->
    thing2 () >>= fun () ->
    thing3
    with
    | Ok _ -> ()
    | Error Error1 -> handle_error1 ()
    | Error Error2 -> handle_error2 ()
    | Error Error3 -> handle_error3 ()

    ReplyDelete
    Replies
    1. I hope my statement didn't mean to imply my way was the only way, I just prefer the separation of the thing that one does with the error handling. Your example drives the parallel between this and exception handling home more though.

      Delete
  2. This comment has been removed by a blog administrator.

    ReplyDelete
  3. Hi Orbitz,

    Great article. Do you mind posting about your experiences in using Lwt vs Async. I'm very interested in giving async a try but I have a hard time finding an excuse to do it since Lwt has not failed me yet.

    ReplyDelete
    Replies
    1. Hey Rudi,
      I don't have anything really interesting to say about Lwt vs Async. I switched to Async mainly to reduce dependencies and as an experiment. So far I am happy with Async. The upsides are that it is consistent with Core in terms of the library design, and it has a lot less C in it than Lwt. The downside is that the community around it is smaller than Lwt. So far that hasn't been a problem for me.

      Delete
    2. I see. I don't use Core so it seems like there would be little benefits to switch over to Async. Thanks again for the blog posts, interesting stuff.

      Delete