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


#10

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.


#11

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.


#12

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.


#13

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.


#14

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 …

#15

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

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.


#17

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.


#18

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.


#19

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.


#20

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?


#21

For starters it would help not to see it as “contamination”. You have a computation whose result maybe not only be a valid value, but also an error. That creates a complex type.

Converting an Error.t or Result.t to an exception, seems to get the worst of both approaches, unless you really have to do such a conversion due to some API or whatever.

Generally you need to either propagate the error to another area of the code base that knows how to deal with such error (e.g. division by zero could request the user to enter it again). Monads and functors help you do that (e.g. >>=).

Another thing you could do is have a mandatory or optional function parameter which knows how to deal with various errors (e.g. division ~error_handler a b). I said this in the beginning of the thread… this really depends on the specifics of each problem and algorithm.

If you get a chance try both approaches and see what happens in the long run.


#22

Exactly. That’s what exception propagation does. Without monads or functors.

Been there, done that. The most usable pattern was Http_exn.not_found "no such user account".


#23

Yes, and at the cost of sacrificing guarantees from the type system.


#24

(If that’s seems like the right trade off-for your use case, then by all means use exceptions.)


#25

I think we need to distinguish the case when a function cannot be evaluated at a point because the arguments are outside the domain of the function and the case when evaluation of the function at a possibly valid point of it’s domains cannot be carried due to external factors. Examples of external factors would be external services not being available or misbehaving and resource exhaustion. I would only consider the latter case of errors true runtime errors, and a prime use case for result, since such errors are outside the control of the code, and generally should be handled.

What about domain errors then? In general I would tend to classify these as bugs, but there may be cases where it’s safer or more convenient to use result or option rather than raising exceptions. Some examples where I would prefer exceptions:

  • Division by zero. The code which passed the zero is most likely broken. If the argument is a float, this is easily seen by adding a noise to the divisor. If the program relies on catching division by zero, it will instead receive huge numbers which may invalidate the result.
  • More generally all floating and integer point functions which correspond to mathematical functions. E.g. there is a good practise in analysis to carefully specify domains, and if the code is correct it stays within those domains.
  • I think the practise of making sure to stay within the domain is preferable for pure functions as far as feasible (see below). A symptom of a result which should have been an exception is that the users can clearly see something is wrong and terminates the Error case with an assert false.

But what about the danger that the code fails with an uncaught exception? Well, isn’t there an even greater risk that the code just makes a wrong calculation on some parts of its domain? Note in contrast, that true runtime errors can occur no matter how correct the code is, so it makes a lot more sense to force the caller to deal with it.

Still, one reason to use result for domain errors may be that the structure of the part of the domain on which the function is defined is too complex for the caller to be expected to deal with. I typical example would be conversions and parsers. If inputs come from external sources, it should be checked, but the easiest way to check the input may be to actually try to parse it.

What about find then? This can be seen as another case of a complex domain, since there is a non-trivial dependency between the container and the search key (though of course one can use mem to do the check). But another resolution of this case, which I think is more elegant, is to not consider a negative lookup to be an error at all, but to rather expose the map as an explicitly partial function. That is, to let find return an option instead of a result. The justification for this view would be if uncaught Not_found exceptions tend to indicate that the code it fact needs a representation of a partial function.


#26

Which guarantees are you referring to?

In the following code (from earlier message), the type system does not tell you which function body might contain a bug because they all return Or_error.t instead of just the sensitive function divide:

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 ()

#27

Maybe we need a slightly bigger example than division. A while ago I have explored a seemingly simple exercise in error handling:

Create a directory with a given set of permissions and ownership. And if it exists already, fix permissions and ownership when necessary or fail otherwise while giving a good reason.

The functions in the Unix module return exceptions but I found it easier to implement this using an error monad. In particular, I wanted to have sensible error messages. Maybe this can be also done with exceptions and I would be interested to read about it.

My writeup is here: Making a Directory in OCaml.


#28

Assume

  • you have a function f : a -> b
  • the code of f is “pure” (which I roughly take to mean it does not use exceptions, and all functions and operators it uses are also pure)

then the type system guarantees that f will either (i) terminate with a value of type b, or (ii) diverge.
If on the other hand f is “impure” (i.e., it uses exceptions) then there is a third possibility: (iii) f may throw an arbitrary exception.

You could imagine an even stronger notion of “purity” where the type system rules out case (ii) as well (and in fact such type systems are used in proof assistants like Coq and Isabelle). The stronger your notion of “purity”, the stronger the guarantees your type system can provide. Another popular notion of “purity” (for example in Haskell) is no state & no IO. Then the type system can rule out nondeterminism.


#29

Sorry, I see code bloat where you see purity.