OSR » Exceptionless Error Management » Recommendation Candidate 2

About this recommendation

Essentially, this is a variation on recommendation candidate #1, without polymorphic variants and with the addition of an obscure type for error-causing functions.

In a nutshell, a module conforming with this recommendation will

type ('a, 'b) may_fail  (*obscure type, hiding the error-carrying mechanism*)

and

type ('a, 'b) status =
     Success of 'a
   | Error   of 'b 

The recommendation does not specify how these details should be presented.

Examples

Finding in a list

According to this recommendation, function find of module List will could have the following signatures:

val find_exn: ('a -> bool) -> 'a list -> 'a
val find: ('a -> bool) -> 'a list -> 'a option

Both versions could coexist in the library.

Note that, when invoked,

find some_predicate some_list

may produce exceptions if some_predicate raises an exception.

Parsing an integer

Similarly, we may introduce

type parse_error_reasons =
   Overflow
 | SyntaxError

type parse_error =
{
  source : string;
  offset : int;
  reason : parse_error_reasons;
}

val parse_int : string -> (int, parse_error) may_fail

To whom does this recommendation apply ?

This recommendation applies to module developers working on modules meant to be used in functional or mostly-functional settings. This recommendation specifies nothing about the internal implementation of these modules, only about how results and errors are presented to clients.

For instance, Map, List, Array, Hashtbl, Event would be subject to this recommendation: despite the fact that some of the most important functions of Array, Hashtbl or Event produce side-effects, these modules are often used in otherwise mostly-functional programs.

By opposition, functions of modules such as Unix or LablGl's GLDraw, are meant to be used mostly as a sequence of imperative operations. Consequently, these modules are not covered by this set of conventions.

Rationale

By experience, checking the value of results at each step of mostly-imperative code is tedious and makes the code harder to read.

On the other hand, in mostly-functional settings, matching the result of an operation against patterns is quite common. If necessary, trivial functions may hide this error-checking either by using monadic-style or by raising exceptions.

Data structures involved in the recommendation

This recommendation requires the adoption of a few standard types and functions. Tentatively, we will group them in a module Exceptionless and give them the following signature:

(**
  The type of an operation which may either succeed and produce a result of
  type 'a or fail and produce a result of type 'b.
*)
type ('a, 'b) may_fail 

(**
  The status of an operation.
*)
type ('a, 'b) status =
   Success of 'a
 | Error   of 'b

(**
 Produce a success
*)
val return : 'a -> ('a, 'b) may_fail
(**
 Produce an error
*)
val throw  : 'b -> ('a, 'b) may_fail

(**
 Evaluate an expression and decode its status.
*)
val result : ('a, 'b) may_fail -> ('a, 'b) status

Additional functions may be added to this module to provide the ability to handle errors in a fully monadic manner or re-raise them. However, fully monadic error management is not the purpose of this recommendation.

Rationale

The use of type ('a, 'b) may_fail gives the ability to read the type of errors which the evaluation of an expression call may cause. This type is obscure and de-coupled from ('a, 'b) status, so as to

We prefer a standardized type ('a, 'b) status to a set of polymorphic variants as using a simple variant type lets the compiler check that all errors are, indeed, handled.

Note

Several prototypes for this module are already implemented and will be published online soon.

Detail of the recommendation

In a module conforming to this recommendation, every value of the module's signature guarantees the following points:

  1. Invalid_argument, in case of an error made by the client
  2. Assert_failure, in case of an internal inconsistency error

This recommendation does not specify how details on the failure should be represented.

Why let functions use assert ?

As mentioned above, any function may raise Assert_failure.

The rationale for this decision is twofold. Firstly, Assert_failures are raised as a consequence of failed assert, which in turn is used to warn of a programmer error. Assert_failures are not meant to be caught, except perhaps by a toplevel emergency exit routine. Consequently, it cannot be reasonably expected for the rest of the program to deal with these errors.

Secondly, these exceptions are raised because the function reached a state which should be impossible by design. One may not expect a programmer to be able to predict his own errors and both deal with them cleanly (by applying exceptionless error management) and not fix them (by letting the assertion fail).

A consequence of this is that assert is not necessarily the right mechanism.

Why let function arguments raise exceptions ?

As mentioned above, whenever a function f accepts a function p as argument and p raises an exception, the flow of this exception should not be altered. In other words, in this case, f p may raise an exception, even though the name of f does not end with _exn.

The rationale for this decision is the following:

Additional suggestions

Exceptionless vs. exceptionful

We are well aware that exceptions are not always avoidable and that they may be useful in some circumstances. This is the reason why the recommendation does not wish to force module authors to completely remove exception mechanisms. However, for modules covered by this recommendation, the default error-management mechanism should be exceptionless error management.

Most functions should therefore not raise exceptions other than Assert_failure. If it is necessary to write a function which may raise exceptions, an exceptionless counterpart should be provided.

Representation of failures

Usual common-sense rules wrt data structures should apply:

Examples

Finding in a list

Not finding anything in a list is not considered an error. Therefore, we will tend to write


# let find p l = 
   try    Some (List.find p l)
   with   Not_found -> None
val find: ('a -> bool) -> 'a list -> 'a option

We may also decide to wrap the old find function with the new naming conventions:

# let find_exn = List.find
val find_exn: ('a -> bool) -> 'a list -> 'a

Division

Dividing by zero is a client error and may therefore raise an exception of constructor Invalid_arg.

# let divide_exn x y =
   if y = 0 then raise ( Invalid_arg "division by zero" )
  else          x / y
val divide_exn: int -> int -> int

An alternative approach would be to consider DivisionByZero as an Error:

# type division_by_zero = [ DivisionByZero ]
# let divide x y     =
   if y = 0 then throw DivisionByZero
   else          return ( x / y )
val divide: int -> int -> (int, division_by_zero) may_fail

Here, we had to replace raise with throw and to specify return before the result.

Parsing integers

A simple implementation of integers, ignoring sign, parsing may be written as follows:

open Exceptionless

type parse_error_reasons =
   Overflow
 | SyntaxError

type parse_error =
{
  source : string;
  offset : int;
  reason : parse_error_reasons;
}

let parse_int s =
 let rec aux i power acc =
  if i < 0 then return acc
  else          match s.[i] with
    | '0' .. '9' as d -> 
       let new_acc = ( acc + power * ( int_of_char d - int_of_char '0' ) ) in
       if  new_acc >= 0 then  (*Check if sign has suddenly changed*)
         aux ( i - 1 ) ( power * 10 ) new_acc
       else                   
         throw { source = s ; offset = i ; reason = Overflow }
    | _ -> throw { source = s ; offset = i ; reason = SyntaxError }
 in
  aux (String.length s - 1) 1 0

Note that, despite the Java-like naming of functions, we returned the result of return acc throw { source = s ; offset = i ; reason = Overflow }. The implementation of module Error may decide to implement these functions using exceptions or the regular flow of informations.

Also note that the length of the function is similar to what we would have written had we used exceptions: we have added a return and reformulated two raise in terms of throw.

Once we have defined this function, we may use it as follows:

let attempt_parsing s =
 match result (parse_int s) with
   | Success i                           -> 
      Printf.printf "Successfully parsed %i\n" i
   | Error ({reason = Overflow} as e)    -> 
      Printf.printf "Overflow error when parsing \"%s\"\n" e.source
   | Error ({reason = SyntaxError} as e) -> 
     Printf.printf "Syntax error at offset %d when parsing \"%s\"\n"
      e.offset
      e.source

let _ = attempt_parsing "123456"
let _ = attempt_parsing "12345678989123423148716259823452938652"
let _ = attempt_parsing "-12345"

Questions ?

Express yourselves here

I mostly agree. However I think:

type ('a,'b) result = Ret of 'a | Err of 'b
let ret x = Ret x
let err x = Err x
 let apply f x = try Ret(f x) with e -> Err e

-- Berke