Why and when try/catch sucks

Exceptions were invented to handle unexpected situations. And here is where they are good. When your app:

then it’s fine, app should panic and you should fix it.

But problem is that people use exceptions to manage control flow. And it looks like fucking goto. Really, see the following example (stolen from niwi.nz):

def read_report_file(name:str, owner:User) -> Report:
    try:
        path = os.path.join(REPORTS_DIR, name)
        file = io.open(path, "rt")
        return process_file_and_get_report(file, owner)
    except PermissionDeniend as e:
        send_notification_about_unauthorized_request(e, owner)
        raise e
    except FileNotFound as e:
        raise ReportNotFound("Report file does not exists") from e
    finally:
        file.close()

Let’s explain, what’s so bad there.

Go developers in their documentation explain this problem too:

A number of designs for exceptions have been proposed but each adds significant
complexity to the language and run-time. By their very nature, exceptions span
functions and perhaps even goroutines; they have wide-ranging implications. 
There is also concern about the effect they would have on the libraries. They
are, by definition, exceptional yet experience with other languages that
support them show they have profound effect on library and interface
specification. It would be nice to find a design that allows them to be truly 
exceptional without encouraging common errors to turn into special control 
flow that requires every programmer to compensate.

I always knew they are smart guys. They are telling the same that I wanted to from the beginning. Exceptions are good in truly exceptional situations, and in other cases we need a better solution.

A better solution

In go they forced the following syntax: each function should return a tuple of two values where the first one is the result and the second one is error. When error is nil then it’s fine, moving on. When it’s not then we have to process it somehow.

def get_soundcard_usb_version(computer):
  sound_card, err = get_sound_card(computer)
  if err:
    return None, err
  usb, err = get_usb(sound_card)
  if err:
    return None, err
  version, err = get_version(usb)
  if err:
    return None, err
  return version, None

The idea to return error is value is good, but the code looks a little awkward. In clojure we can add a couple of macros to reduce boilerplate (see adambard) but we still have to return a vector in each function.

What if we had a way to distinguish errors from results? Wait, we have it:

(defrecord Failure [message])

(defn fail [message] (Failure. message))

(defprotocol ComputationFailed
  "A protocol that determines if a computation has resulted in a failure.
   This allows the definition of what constitutes a failure to be extended
   to new types by the consumer."
  (failed? [self]))

(extend-protocol ComputationFailed
  Object    (failed? [self] false)
  Failure   (failed? [self] true)
  Exception (failed? [self] true))

Nice! Thank’s Rich for that. Now we can check if our function call failed or not:

(if (failed? (somefun 42)) "oh that's bad" "oh that's great!")

Great. I think you already got that we’re not stopping here and not going to do the checking in every single function we use. Let’s automate that:

(defn failed-arg [args]
  (first (filter failed? args)))

(defn maybe [f & args]
  (if-let [x (failed-arg args)] x (apply f args)))

What have we just did? We’ve created a simple way to pass errors through functions. If any argument of the function is Failure then nothing will be done and the argument will be returned. Else we will just call the function. Les’t demonstrate:

(maybe + 2 3) ;= 5
(maybe + 2 (fail "oops!")) ;= #Failure{:message "oops!"}

Yahoo! Aren’t we cool already? Definitely we are. Let’s go further:

(defmacro maybe->> [val & fns]
  (let [fns (for [f fns] `(maybe ~f))]
    `(->> ~val ~@fns)))

Now we chain functions that can potentially fail just as simple as any others:

(maybe->> 3 #(fail (str "arg: " %)) #(/ 5 %)) ;= #Failure{:message "arg: 3"}
(maybe->> 3 #(+ 2 %) #(/ 5 %)) ;= 1

Last thing is to implement the bingings syntax. And we will use m…khm-khm…onads. Scared already? Relax, I’ll show how it all works.

(require '[clojure.algo.monads :refer [defmonad domonad]])

(defmonad error-m 
  [m-result identity
   m-bind   (fn [x f] (maybe f x))])

(defmacro attempt-all 
  ([bindings return] `(domonad error-m ~bindings ~return))
  ([bindings return else]
     `(let [result# (attempt-all ~bindings ~return)]
        (if (failed? result#) ~else result#))))

Yes, looks scary. Moreover, I stole it from brehaut. But here is how it works:

(attempt-all [a 1
              b (inc a)] b)) ;= 2

(attempt-all [a (fail "oops")
              b (inc a)] b)) ;= #Failure{:message "oops"}

(attempt-all [a (fail "oops")
              b (inc a)] b ":(")) ;= ":("

Summary

In my opinion, Failure type provides sane way to handle errors that can be predicted. You can isolate error handling from domain logic and write more composable and more functional code.

Learn More