-
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 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)
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