Specific reason for not embracing the use of exceptions for error propagation?

I don’t understand why one would not use exceptions to propagate errors in OCaml. The corresponding chapter in Real World OCaml has this quote:

The right trade-off depends on your application. If you’re writing a rough-and-ready program where getting it done quickly is key and failure is not that expensive, then using exceptions extensively may be the way to go. If, on the other hand, you’re writing production software whose failure is costly, then you should probably lean in the direction of using error-aware return types.

A preliminary question is:

What kind of costs do the terms “not that expensive” and “costly” refer to in the quoted paragraph?

Then the main question is:

In which specific scenario are those costs reduced by not letting exceptions propagate until the point where they’re logged and dropped?

2 Likes

The point of functional programming, is that it simplifies reasoning of the algorithm by dealing with all possible returned values at the point of return. Exceptions break that simplification and the compiler can no longer warn you about not having accounted for a possible result.

You would know that when programming an application. That is to say, it depends on the application. If you are writing a quick and dirty script for example and it dies on you with an uncaught exception, you will read it on the terminal, fix the script and run again. If you have a plane landing algorithm which dies with an uncaught exception…

Again it depends on the application. To give you a less extreme scenario, opening a socket can fail with an exception. If you want to show that in a graphical program, or present an intuitive message to the user, that exception must be caught and bound to the appropriate reaction. The cost here being usability and friendliness I guess.

5 Likes
  1. Exceptions are not tracked by the type system: the signature of a function does not reveal which exception might escape. Error-aware return types reveal all possible errors coming out of a function (or expression). The value of the type system grows with the size of a project as it can guide refactoring and limit how far errors can propagate. Exceptions are difficult to contain because they are dynamically scoped on top of not being tracked by the type system.

  2. Exceptions are more efficient than error-aware return types: return types need to be matched (explicitly or using a monadic bind operator) at every step in a call hierarchy. An exception unwinds a stack of calls (in OCaml) in one step and is only matched in the handler.

  3. Exceptions interfere with tail recursion – some care is required.

6 Likes

Note: I think the page on error handling on ocaml.org already discusses this.

Le Mon, 05 Mar 2018, Orbifx wrote:

The point of functional programming, is that it simplifies reasoning of the algorithm by dealing with all possible returned values at the point return. Exceptions break that simplification and the compiler can no longer warn you about not having accounted for a possible result.

The point of OCaml is that it’s not purely functional, so exceptions
still have their use :slight_smile:

You would know that when programming an application. That is to say, it depends on the application. If you are writing a quick and dirty script for example and it dies on you with an uncaught exception, you will read it on the terminal, fix the script and run again. If you have a plane landing algorithm which dies with an uncaught exception…

That’s true for some programs, as you said. Exception-based error
(non-)handling is still nice when you write something like a compiler,
where errors are not recoverable and can come from many places (and you
don’t want to write all the AST traversals in the result monad).

ocamlc itself is a good example of a program where exceptions make a lot
of sense!

So, YMMV…

The point of functional programming

Exceptions don’t qualify as functional programming indeed. It’s not something I consider immoral.

Exceptions break that simplification and the compiler can no longer warn you about not having accounted for a possible result.

This would be an argument for a form of checked exceptions, but I’m not aware of a mechanism which would allow this in OCaml today. We could discuss this, but I’d like to stick to the arguments for or against the use Or_error.t, which uses a single type Error.t.

If you have a plane landing algorithm which dies with an uncaught exception…

How would using another representation of the error prevent the plane from crashing?

If code is using something like the usage guidelines from rresult then the error cases would be explicitly listed. They would, of course, still need to be handled properly to avoid whatever bad things might happen when an error condition is mishandled. But you would, at the point where a function is called, know the potential resulting error cases rather than having to rely on some top-level exception catch-all case.

1 Like
  1. See my other reply referencing Error.t.
  2. I don’t know enough about the performance of OCaml/native exceptions. I used to believe that exception propagation was more efficient in OCaml than in other languages and that it was a good thing to use them for routine operations. Then I was told that Jane Street avoids them for performance reasons, which contradicts my former belief. I haven’t done any benchmarks myself.

I don’t have performance numbers but here is a discussion how exceptions in OCaml are implemented.

3 Likes

Not saying they don’t. @mjambon asked “why one would not use exceptions to propagate errors in OCaml”, so I’m explaining one reason (purity) which also gives rise to compiler assistance.

Might be so. I think something might be happening there with algebraic effects, but I might have misunderstood. There is no helpful answer to your questions if you start considering hypothetical compiler features.

I don’t know anything about Or_error. I haven’t used Janestreet’s core. Maybe someone can help with that part. But I hope the answers on exceptions helped. @hcarty’s answer explains why typed errors can help from crashing: harder for programmer to miss out a possible exception; it’s easy to neglect an exception, without ever even having noticed.

Exceptions have their place, but they should be the exception rather than the norm. :stuck_out_tongue:

Janestreet does soft real-time, critical applications.
This is not the use case of everybody.
They are just super afraid that some code might throw
an exception which is never caught.
By having type signatures which make exceptional behavior
explicit, there are less chances that a programmer would
use a crashable function without handling the crash case.

They are just super afraid that some code might throw an exception which is never caught.

But what is the programmer supposed to do once an exception is caught?

If I call f (g (h x))* where h raises an exception, say Failure "network error", and f handles them (logs them and drops them), I don’t want g to be involved because g can do nothing about it. I don’t want the source code of g to be more complicated than it needs to be. Now if there’s not one but ten functions g1, g2, …, g10 functions in the call stack between f and h, and they represent much of the application’s source code, their type is polluted by error propagation that’s irrelevant to what they do. Exception propagation makes this transparent and it’s great.

*edit: I mean f calls g, which calls h.

1 Like

I’m not sure if this question is meant to be rethorical, but IMO this is a good question to actually have an answer to, especially when errors are costly (e.g. your company could lose money from it). The transparent trait that exceptions have makes it easy to forget handling it. It’s in a way like static and dynamic type systems; you’re going to have type errors anyway, so why don’t you face it upfront?

I think this is definitely a trade-off, but one I would personally take. If I can make the error types uniform, I can utilize combinators, e.g.

let res = h x >>= g >>= f in
match res with
| Ok x -> ...
| Error e -> ...

and g would only be run if the result of h x is not an Error.

4 Likes

I also share your lack of disdain for exceptions in OCaml and saddened by what seems to be a cargo cult movement against them. Result types have just as many disadvantages, it’s simply a different set of trade-offs. With that being said, I still have reservations about exceptions in some cases.

  • If a function is pure, then I’d much rather keep it total and signal errors with a result type. I make an exception (no pun intended) for programming errors and still throw Invalid_argument though.

  • I don’t like exceptions because it’s far too easy in OCaml to catch exceptions you don’t want. For example, I almost never want to catch Invalid_argument, but I still see myself using and seeing a lot of try ... with _ -> ... You don’t have this problem with Or_error.t since all errors are opaque.

  • Exceptions don’t work so nicely with all the user level threading libraries out there. It’s actually not so bad once you get the hang of it, but I’ve been on teams where I had to get beginners up to speed and it’s not at all intuitive to most people. Result types don’t have these problems of course.

5 Likes

As discussed above, it also depends on your application domain. Writing compilers, and having tried both the result monad and exceptions, I ultimately chose the latter because:

  1. an error will anyway result in the program termination so I may as well fail fast and not bother tainting my types with result
  2. using the result monad means your coworkers (who may not be fluent in monadic programming) will also have to undergo this choice (and curse you); besides OCaml doesn’t feature a “standard” monad library and sometimes you may need to write things for which functions such as liftM or mapM may be needed (worse: you may have to compose with other monads).

Now, I am still not very happy with relying on untracked exception…

Besides my main problem is that I think we lack a discussion on design principles here. A discussion that does not reduce to choosing between exceptions and the result monad. There are several other aspects that, IMO, should be discussed too (and that could be implemented with any technology). E.g.:

  • defensive programming vs fail fast
  • error recovery (see what Eiffel proposed for instance)
  • more concretely: when should you use invalid_arg vs assert vs …
2 Likes

The primary value of avoiding exceptions is that it makes error behavior explicit in the type of the function. If you’re in an environment where everything might fail, being explicit about it is probably a negative. But if most of your function calls are total, then knowing which ones might fail highlights places where you should consider what the correct behavior is in the case of that failure. Remember that the failure of an individual step in your program doesn’t generally mean the overall failure of your code.

It’s a little bit like null-handling in languages without options. If everything might be null, well, option types probably don’t help you. But if most of the values you encounter in your program are guaranteed to be there, then tracking which ones might be null be tagging them as options is enormously helpful, since it draws your attention to the cases where it might be there, and so you get an opportunity to think about what the difference really is.

A very prosaic example. In the stdlib, Map.find throws an exception. In Base and Core, Map.find returns an Or_error.t. To me, the latter is clearly preferable, since it encourages the user to think about what they should do if Map.find fails.

Also: recovering from exceptions is tricky. An exception that arrives in the middle of some imperative part of your program may trash your state by interrupting an update that wasn’t supposed to be interrupted. If your program is a compiler that is just going to shut down in that case, then that’s probably OK. But if your program is a long-lived system that wants to handle and recover from an exception, then it’s less reasonable to just keep soldiering on without understanding where precisely that exception occurred, particularly if the correctness of this program really matters.

I should say: I don’t mean to say that one should avoid exceptions entirely; there are definitely contexts where I think they’re the right way to go. But the value in having types capture error behavior seems very clear to me.

y

16 Likes

as @bobbypriambodo explained, you don’t need to make g aware of the result type of h, by externalizing the processing of errors. That’s also a strong argument against the exception system because it leaks information between the functions and it’s against composability and modularity. Each time a new g needs to be written, the writer must remember that there are exceptions passing by in this particular use of the functions and that could make the code of g more complicated that needs to be because exception processing must be taken into account: now g must assume that the flow of execution will be interrupted and protect against that risk (close opened files,…).

The good point for error type is that the assumptions on the processing of errors by the stages of calls to functions are made clear and formal by the type system. The seperation of concerns for the processing in g can really be enforced.

3 Likes

I have a specific reason that only occasionally applies - laziness can obscure the times at which is is possible for an exception to be raised, making it annoyingly easy to get handling them wrong.

This is not a problem for fail-fast things like division by zero, index out of bounds, etc, but for exceptions as deliberate control flow it can be error prone.

2 Likes

To me there’s a big difference between having a single type to represent errors such as exn, Error.t or string, and a new error type for each function that adds or removes errors. It’s what I expect when I hear about the benefits of having errors in the function signature. A possible interface is the following:

type f_error = [ `F_invalid_arg ]
type g_error = [ `G_invalid_arg of float | `G_timeout ]
type h_error = [ `F_invalid_arg | `G_invalid_arg of float | `G_timeout ]
type i_error = [ `F_invalid_arg | `G_invalid_arg of float ]

val f : unit -> (int, f_error) result
val g : unit -> (float, g_error) result
val h : unit -> (string, h_error) result
val i : unit -> (string, i_error) result

A corresponding implementation would be:

type f_error = [ `F_invalid_arg ]
type g_error = [ `G_invalid_arg of float | `G_timeout ]
type h_error = [ f_error | g_error ]
type i_error = [ `F_invalid_arg | `G_invalid_arg of float ]

let f () =
  Ok 2

let g () =
  Error (`G_invalid_arg (-1.))

(* Merge sets of possible errors. *)
let h () =
  match f (), g () with
  | Ok a, Ok b -> Ok (Printf.sprintf "%i %g" a b)
  | Error e, _ -> Error (e :> h_error)
  | _, Error e -> Error (e :> h_error)

(* Recover from some errors. *)
let rec i () =
  match h () with
  | Ok s -> Ok s
  | Error `G_timeout -> i ()
  | Error (#i_error as e) -> Error e

I don’t know how practical this is.

My experience with using polymorphic variants for tracking more specific errors has not been good — it’s generally led to weird and confusing type error messages, and the precision hasn’t been all that useful.

I think the most prosaic examples illustrate the point best: Map.find doesn’t have a terribly unique error message, but the fact that you know that it can fail is enough of a clue to let you look deeper, and consider why it might fail. Error monads are occasionally useful, but I think most of the time, the key value of returning errors in the type is that you can see which functions don’t return errors.

6 Likes

I’m on board with giving the programmer a hint that a function might fail, but how do you avoid contaminating the type of other functions? An immediate failwith?

In the following code, f1 and f2 return an Or_error.t even though their caller can do nothing to prevent it. Now our codebase has 3 functions whose return types tell the programmer to be careful but there’s nothing to be careful about when calling the last two.

let divide a b =
  if b = 0 then
    Error (Error.of_string "Division by zero")
  else
    Ok (a / b)

let f1 () =
  divide 0 0

let f2 () =
  f1 ()

So, the way I’d write my code is:

let divide a b =
  if b = 0 then
    Error (Error.of_string "Division by zero")
  else
    Ok (a / b)

let f1 () =
  match divide 0 0 with
  | Ok x -> x
  | Error e -> Error.raise e

let f2 () =
  f1 ()

Now both f1 and f2 return a plain int and the programmer’s attention is drawn to divide. Is this recommended usage, as opposed to monadic propagation?