Skip to content

Error Handling with Maybe

blancas edited this page Feb 6, 2013 · 25 revisions

Maybe is a facility for dealing with operations that can fail. It is a simple alternative to writing code that returns (and tests for) nil values, or to the use of exception handling with try and catch forms. Another use case relates to functions that return a primitive type where any value could be a valid one. The advantages of using Maybe are the following:

  • Works with boxed values and avoids null pointers.
  • Simplifies code for checking and reacting to error conditions.
  • Propagates error to short-circuit sequenced computations.

The types and functions in this guide are declared in the blancas.morph.core and blancas.morph.monads namespaces.

(use 'blancas.morph.core
     'blancas.morph.monads)

Morph's implementation of Maybe follows after Haskell's Maybe Monad. It consists of a data type and a collection of supporting macros and functions. A Maybe instance represents the existence or the absence of a value. If the value exists (i.e., it is not nil) we call it a Just value; otherwise we call it Nothing. We refer to a Maybe instance informally as a maybe value.

just makes a maybe value using the supplied argument. Use this function when you know that you have a non-nil value, or if you have a variable that may or may not be nil. This code illustrates how to create maybe instances with just and how they print.

(just "foo")
;; Just foo
(just {:foo 1 :bar 2})
;; Just {:foo 1, :bar 2}
(just (System/getProperty "my-defaults"))
;; Nothing

nothing is a constant that represents the absence of a value. Use it in favor of (just nil) when you know that there's nothing to provide.

nothing
;; Nothing
(= nothing (just nil))
;; true

maybe is a macro that can simplify the creation of maybe values for two use cases. In the first case, it takes an expression to evaluate; if the expression throws an exception, it results in a Nothing. Note that a plain call to just won't handle exceptions.

(just ([0] 1))
;; IndexOutOfBoundsException   clojure.lang.PersistentVector.arrayFor (PersistentVector.java:106)
(maybe ([0] 1))
;; Nothing

In the second case, it also takes a predicate to be applied to the supplied value or expression. If the predicate succeeds, it results in a Just value; if it fails, it results in Nothing.

(maybe neg? (min -50 (Math/pow -4 3)))
;; Just -64.0
(maybe pos? (min -50 (Math/pow -4 3)))
;; Nothing

run-just is an accessor for a maybe value; it returns the internal raw field. This function is meant to encapsulate this internal field and be used only in low-level code. To access the Just boxed value use may (see below).

(run-just (just :foo))
;; :foo
(run-just nothing)
;; nil

just? is a predicate to test for a Just value.

(just? (just "yeap"))
;; true
(just? nothing)
;; false

nothing? is a predicate to test for Nothing.

(nothing? (just "nope"))
;; false
(nothing? nothing)
;; true

may is a macro that binds a maybe's boxed value conditionally, similar to the if-let form. The binding vector contains a fresh variable and an expression that evaluates to a maybe. If it is a Just, the then block is evaluated with the value bound to the fresh variable; otherwise the else block is evaluated. Thus the fresh variable is only visible in the if form.

(may [fresh-var maybe-expression]
  (if-form)
  (else-form))

The following code illustrates the use of the may macro. The code in the if form can access the desired value with the assurance that the computation succeeded.

(def plane (just 737))

(may [p plane] (println "the plane is a" p))
;; the plane is a  737

(println "will take a" (may [p plane] p "bus"))
;; will take a 737
(println "will take a" (may [p nothing] p "bus"))
;; will take a bus

justs collects Just values from a collection, ignoring any Nothing values.

(def lst [1 2 3 4 5])
(def col [(maybe (lst 3)) (maybe (lst 8)) (maybe (lst 0)) (maybe (lst 100))])
(justs col)
;; (4 1)

map-maybe takes a function that returns a maybe and a collection. It maps the function over the collection and returns only the Just values, ignoring any Nothing values.

(defn at [idx] (maybe (lst idx)))

(map-maybe at [0 2 4 6 8])
;; (1 3 5)

Sequencing Maybe Operations

A common pattern in programming is to make a series of calls, where calls depend on previous results. If these calls can fail, plenty of boilerplate code is necessary for the safety of the whole computation. The Maybe type implements the Monad protocol and therefore works as the Haskell Maybe monad. As a practical matter, this makes it possible to call maybe computations in sequence with no boilerplate code at all. The whole expression produces a Just value or Nothing.

The following sample illustrates the mechanics of this technique. Similar to the let form, the monad macro uses a vector to create bindings. The second element of each pair must be an expression that evaluates to a Maybe instance. If all such instances are non-nil (i.e., they're Just values), the values are bound to their corresponding variables. The body of the form must return a Maybe instance.

(def expr (monad [x (just :777)
                  y (just :737)
                  z (just :A330)]
            (just [x y z])))

expr
;; Just [:777 :737 :A330]

In the above code each variable is bound to a value; then the body makes a maybe that boxes a vector with the three results. Now we can use expr like so:

(str "The fleet has " (may [planes expr] planes "no planes"))
;; "The fleet has [:777 :737 :A330]"

When any computation fails, the whole monad expression is Nothing.

(def expr (monad [x (just :777)   ;; good
                  y nothing       ;; this is not good
                  z (just :A330)] ;; this could've been good but it won't evaluate
            (just [x y z])))      ;; this won't happen either

expr
;; Nothing
(str "The fleet has " (may [planes expr] planes "no planes"))
;; "The fleet has no planes"

As another example, consider an interpreter that evaluates an expression such as arr[i], which involves looking up the variable in a symbol table, getting a pointer to the value record, and finally getting the value at the indicated index. These steps may fail with an undeclared identifier, a value not initialized, or an index out of range. Instead of working with nil values, this code defines functions for getting each result as a maybe type.

(deftype SymRec [name type value])  ;; symbol table record
(deftype ValRec [indexed top val])  ;; value record

;; populate the symbol table with "foo" and "bar"
(def valrec1 (ValRec. true 0 [1 2 3 4 5 6]))
(def symrec1 (SymRec. "foo" "int" valrec1))
(def valrec2 (ValRec. false 0 999))
(def symrec2 (SymRec. "bar" "int" valrec2))
(def sym-tbl {"foo" symrec1 "bar" symrec2})

(defn sym-rec 
  "find a var in the table"
  [str tbl] (just (get tbl str)))

(defn val-rec
  "get the pointer to the value record"
  [entry] (maybe (.value entry)))

(defn lookup 
  "lookup the nth value in the vector v"
  [v n]
  (monad [srec (sym-rec v sym-tbl)
          vrec (val-rec srec)]
    (maybe ((.val vrec) n))))

Function lookup lines up calls for finding a variable in the symbol table, then using the returned reference for getting the value record. If both these operations succeed, it returns a maybe with the actual vector lookup, which is the third computation in the sequence. Note that there is no visible boilerplate code for detecting or working around nil or non-existing values.

Finally, the following code defines a test function to simulate the work of the interpreter as it works out some vector-access expressions.

(defn test
  "simulates an expression var[idx]"
  [var idx]
  (may [v (lookup var idx)]
    (println var "[" idx "] =" v)
    (println "not found")))

(test "foo" 2)
;; foo [ 2 ] = 3
(test "foo" 9)
;; not found
(test "xyz" 0)
;; not found