Poor man's static exception analysis with alerts

As part of the ReScript project, @cristianoc has shipped an interesting tool to statically analyze OCaml exceptions and warn if they are unhandled: reanalyze/EXCEPTION.md at master · rescript-association/reanalyze · GitHub

In recent versions of OCaml it looks like we can actually get a ‘poor man’s’ version of this functionality by just using alerts. E.g. say we have a function f which can throw an exception Failure, we tag it with an alert:

module M : sig
  val f : int -> int
  [@@alert exn "Failure"]
end = struct
  let f _ = failwith "!"
end

Now all we need to do is turn exn alerts into compile errors, and we are statically catching all exceptions. To indicate that an exception has been handled, we just turn off the alert at the callsite:

try (M.f[@alert "-exn"]) 1 with Failure _ -> 0

EDIT: adjusted alert name.

10 Likes

Interesting, but you need to annotate by hand all source code…

True, but consider that it’s a best practice to document functions which can raise exceptions using ocamldoc comments. Using an alert is I think an equivalent amount of effort:

val f : int -> int
(** Does xyz.
    @raises Failure *)

val f : int -> int
[@@alert exn "Failure"]
(** Does xyz. *)

After that, enabling alerts as errors is a one-time action. And disabling alerts at callsites is a compiler-driven, low-effort action. I think it’s feasible to do this at scale in a production codebase.

2 Likes

Some suffix function names by _exn to warn potential users.
That’s also not a bad idea.

The _exn name suffix for functions was, conceptually, a way to ‘alert’ people that they are calling something that might throw. Now that OCaml has shipped a general alert feature directly in the compiler, we can ‘formalize’ it a bit more by using that.

Is this basically the OCaml equivalent of Java’s checked exceptions?

This makes me think exceptions were never a great idea to begin with in OCaml- I still prefer either the result type or using polymorphic variants to compose the errors.

In the long run I’m hoping the not yet implemented typed effect system will give us a better idiom for this.

1 Like

Basically, yes. People seem to hate checked exceptions, then reproduce most of its functionality (compile-time error checking) manually using result types. So, :man_shrugging:

At least alerts can be controlled precisely by upgrading or downgrading or even silencing them as required.

2 Likes

I’m not sure people hate checked exceptions, I think they hate the way it was implemented in Java which is exceptionally (yes) annoying. See the start of section 2. of this paper for an explanation.

3 Likes

Thanks for the pointer. True, the Java model is pretty limited. I’m not sure OCaml alerts suffer from those limitations. E.g. let’s port over the example on §2 ‘Motivation’ to OCaml:

# module M : sig
  val f : int -> int
  [@@alert exn "Failure on 0"]
end = struct
  let f = function
    | 0 -> invalid_arg "0"
    | n -> n + 1
end;;
module M : sig val f : int -> int end
# List.map M.f [1;2;3;4];;
Line 1, characters 9-12:
Alert exn: M.f
Failure on 0
- : int list = [2; 3; 4; 5]
# List.map M.f [0;1;2;3;4];;
Line 1, characters 9-12:
Alert exn: M.f
Failure on 0
Exception: Invalid_argument "0".

(Removed the duplicated alerts, too lazy to look into that right now).

The usage of the M.f function specifically triggers the alert. The argument people make is that HOFs should be polymorphic, e.g. the Scala hypothetical example:

def map[B, E](f: A => B throws E): List[B] throws E

What they want here is that the map function should express the capability of throwing any exception E that can be thrown by the function f. If f doesn’t throw any exception, then neither can map.

But, the OCaml alert system already accomplishes this. E.g. if you do List.map succ [1;2;3] you get no alert because neither of List.map nor succ have an alert. But if you do List.map M.f [1;2;3] you do get an alert because M.f does.

So it looks like the two biggest painpoints are already solved by OCaml alerts.

A thought experiment: if this alert was added to the Stdlib.{raise,raise_notrace} functions, it would ripple out throughout the OCaml ecosystem, forcing a much more exception-aware approach in all codebases.

1 Like

I looked at the section of the paper you referenced, and I think the authors are trying too hard to justify their design choices. In truth (at least, in my experience) Java programmers hate checked exceptions b/c while in theory they want to write 'em all down and have 'em checked, in practice it’s too bloody tedious. OTOH, Rust code tends to be lower-level code, and there is a well-established discipline for low-level code (which is reproduced in C++ in the Google C++ style guide) of never throw an exception and always return return-codes; always check your return-codes. I would argue that Rust is just doing the Rust-y version of that. A corollary of that statement is that as Rust gets used for “business logic”, we’re going to see more and more chafing about these Result<,> types everywhere.

Your idea of using alerts is interesting, and what I like about it most is that it’s optional. B/c yeah, I also hate checked exceptions!

Once you can easily abstract over error handling strategies, the story likely becomes different.

Which is called hard-coding the error monad in the language.

2 Likes

5 posts were split to a new topic: Implementing rust’s ? operator

As an alternative to annotations, I’m bias to the idea that exceptions shouldn’t be globally accessible, but rather the ability to fail should be passed as an explicit argument ~exn to the function:

module M : sig
  val f : exn:exn -> int -> int
end = struct
  let f ~exn x = if x >= 0 then x else raise exn
end

with the convention that exceptions should only be introduced locally at the same time as their handler:

let safe () =
  let exception Oops in
  try ...
      M.f (-1) ~exn:Oops
      ...
  with Oops -> (* handled! *)

I think this style has some nice benefits:

  • It shows up in the documentation and interface of the function
  • It shows up in the code, so you can easily track who uses the exception
  • If you already follow the naming convention foo_exn then the syntaxic overhead is pretty light as you can write foo~exn
  • Your exception can’t accidentally be catched by another handler (modulo a wildcard try ... with _ ->, so, well, don’t do that!)
  • It’s checked… assuming the exception can’t escape its scope (which could be verifed with jst’s local annotation :slight_smile: )
  • The caller can be very precise, compared to a global exception like Not_found that can be raised from multiple unrelated spots:
let exception A_not_found in
let exception B_not_found in
try
  let a = Mymap.find "a" t ~exn:A_not_found in
  let b = Mymap.find "b" t ~exn:B_not_found in
  ...
with
  | A_not_found -> ...
  | B_not_found -> ...
11 Likes

This is a nice idea, it sounds a bit like EIO’s capabilities. However, I disagree that the overhead is light if you follow the foo_exn convention. While its true that foo~exn is the same length, usually the existence of foo_exn implies the existence of foo that does not use exceptions but returns a result or an option.

1 Like

Do you know of any codebases that use this style, or is it mainly a thought experiment as of now? It seems like a pretty neat idea, but it would be cool to see how it goes in practice, too.

I did a quick check on sherlocode and found a couple of functions with exn:exn in the signature, but it didn’t look like anything too pervasive among those projects.

Ha, I wish, but no sorry I don’t know anyone doing this in the wild. To be clear, I think result is just fine and this style is too unfamiliar atm to really push for it, but it’s tempting :}

1 Like

I will just add a note that when the alerts feature was originally added, exceptions were one of the motivating examples:

…I suggested to generalize this even further to allow other kind of alerts. For instance, one might want to mark components that perform I/O, rely on generic marshaling, or can
possibly raise exceptions.

Another interesting thought: if we add an alert to Effect.perform about its possibly raising the ‘unhandled effect’ exception, we get static analysis of effect handling basically for free.

2 Likes

Sadly I don’t think this is enough as alerts can’t cover which effects are handled after you continue k :confused: (see Patrick’s great blog post on “Escaping Effects” or the conclusion of my old ramblings for why this might not be so intuitive at all)

3 Likes

Thanks, that’s an interesting read. I should point out that my expectation is not that the alert checker will be able to discern which exceptions have been handled and thus don’t need to trigger the alert. That part will need to be managed by a human. The only thing the alert system can do is trigger the alert on the usage of the item that it is attached to. For Patrick’s example, adapting it to the alert system would look something like this:

(* getName.ml *)

open Effect.Deep
type _ Effect.t += GetName : string Effect.t

let get_name () = (Effect.perform[@alert "-exn"]) GetName

(* getName.mli *)

val get_name : unit -> string
[@@alert exn "Unhandled GetName"]

It would now be up to the caller to install a handler and then mark the get_name function as handled there. All the alert provides is really just a warning that we need to proceed carefully here.

2 Likes