-
Notifications
You must be signed in to change notification settings - Fork 4
Error Handling with Maybe
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 declare 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 is made up of a data type and a collection of supporting macros and functions. A Maybe
value represents
just
makes a Maybe
value using the supplied value. 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 cases. It can take 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
It can also take 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. Returns the raw value
field. This function hides the internal field value but is not meant to be a common way to get it; see may
.
(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 binding form that helps use a maybe's boxed value conditionally, similarly as 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
s.
(def lst [1 2 3 4 5])
(def col [(just (lst 3)) (just (lst 8)) (just (lst 0)) (just (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
s.
(defn at [idx] (maybe (lst idx)))
(map-maybe at [0 2 4 6 8])
;; (1 3 5)
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:
(may [planes expr] (str "The planes are " planes) "no planes")
;; "The planes are [: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
(may [planes expr] (str "The planes are " planes) "no planes")
;; "no planes"
As another example, consider an interpreter that evaluates an expression such as arr[i]
, which involve 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