Error Handling in Macros

Most macros, like our pattern matching example in the previous section, have very specific sets of input that they accept, and will throw strange-looking errors when those input expectations aren’t satisfied. For instance, if we didn’t realize that snore.match only handled vectors as input, we might try to match against a keyword, which would give us this error:

language_features/error_handling_1.clj
 
(match :foo
 
:oh ​"hi"
 
:foo ​"bar"
 
:else ​"else"​)
 
UnsupportedOperationException count not supported on this type: Keyword
 
clojure.lang.RT.countFrom (RT.java:556)
 
java.lang.UnsupportedOperationException: count not supported on this type: Keyword
 
RT.java:556 clojure.lang.RT.countFrom
 
RT.java:530 clojure.lang.RT.count
 
match.clj:13 snore.match/create-test-expression
 
match.clj:33 snore.match/match-clause
 
AFn.java:156 clojure.lang.AFn.applyToHelper
 
AFn.java:144 clojure.lang.AFn.applyTo
 
core.clj:626 clojure.core/apply
 
core.clj:2468 clojure.core/partial [fn]
 
RestFn.java:408 clojure.lang.RestFn.invoke
 
core.clj:2559 clojure.core/map [fn]
 
LazySeq.java:40 clojure.lang.LazySeq.sval
 
: :
 
: :
 
Compiler.java:6666 clojure.lang.Compiler.eval
 
core.clj:2927 clojure.core/eval
 
main.clj:239 clojure.main/repl [fn]
 
main.clj:239 clojure.main/repl [fn]
 
main.clj:257 clojure.main/repl [fn]
 
main.clj:257 clojure.main/repl

This stack trace gives us all the information we need to trace the code path from the call all the way to the place the exception was thrown, but it doesn’t tell the user of the macro that they’re just using the macro with unexpected values. And if you’re not used to reading compiler stack traces or remembering to try macroexpanding the expression, that long stack trace might be a bit overwhelming. There are a few ways to deal with this issue, some more common than others, and each with pros and cons:

Do nothing

Pro:

  • Takes no extra work for the macro author

Con:
  • Users need to read source code or ask others to help debug

Written documentation

Pro:

  • Full power of the English language

Con:

  • Goes out of date easily

  • Little context/slow feedback loop when something goes wrong

Make the macro smarter

Pro:

  • Friendlier to newcomers

Con:

  • Hard to know what kinds of mistakes users can make

  • Hard to figure out what users mean when they make a mistake

  • Can add significant code complexity

  • Not possible in full generality

Manually construct exception messages

Pro:

  • Easy to explain the problem (when we know what it is)

Con:

  • Requires manual decisions: easy to miss cases

  • Not always easy to figure out at what level a user went wrong

Have exception messages constructed automatically

Pro:

  • No manual work; covers all cases that a grammar can cover

  • Very good error messages (though potentially not as good as bespoke ones where the problem is known)

Con:

  • May require significant change in macro-writing practice

  • Quite rare in practice

The first two options, doing nothing and writing documentation, are quite easy to understand. Writing good documentation is difficult, but it’s something we’re used to seeing, and hopefully doing, as programmers. The other options, on the other hand, could use some discussion.

Making the macro smarter, to accept more kinds of input, seems like a great goal at first blush. It’s basically Postel’s Law[36] applied to macros. This can be a great hack for simple and obvious errors. But the problems space of “errors a user might make” is vast, approaching infinite, and with many errors, it’s hard to even reason through why they were made. Unfortunately, being able to detect and fix any user error is not a realistic goal. So even if we’re able to give the user some help and we don’t mind a bit of added code complexity, it’s not a complete solution.

If we wanted to improve the error messages for our mistaken pattern match call, we could start by adding an assertion that the match input is a vector, either with explicit assert calls or with Clojure preconditions:

language_features/error_handling_2.clj
 
;; Assertions, using custom error messages
 
(​defmacro​ match [input & more]
 
(​assert​ (​vector?​ input) ​"Match input must be a vector"​)
 
(​let​ [clauses (​partition​ 2 more)]
 
`(​cond​ ~@(​mapcat​ (​partial​ match-clause input)
 
clauses))))
 
 
 
 
;; Clojure preconditions
 
(​defmacro​ match [input & more]
 
{:pre [(​vector?​ input)]}
 
(​let​ [clauses (​partition​ 2 more)]
 
`(​cond​ ~@(​mapcat​ (​partial​ match-clause input)
 
clauses))))

But how do we know what assertions to add? We might have happened upon that annoying error message because a coworker was blocked for half an hour trying to figure it out, but what happens next time? Have we provided good error messages for all the things someone could do wrong? What if someone assumes that our match is like case instead of cond, in that its last clause doesn’t need an :else prefix? As things stand, they might be in for a surprise:

language_features/error_handling_3.clj
 
(match [1 2 3]
 
[0 y z] :yx-plane
 
[x 0 z] :xz-plane
 
[x y 0] :xy-plane
 
:other)
 
 
;=> nil

So to provide a good error message for this mistake, we need an additional precondition that checks that there are an even number of expressions after the match input:

language_features/error_handling_4.clj
 
(​defmacro​ match [input & more]
 
{:pre [(​vector?​ input)
 
(​even?​ (​count​ more))]}
 
(​let​ [clauses (​partition​ 2 more)]
 
`(​cond​ ~@(​mapcat​ (​partial​ match-clause input)
 
clauses))))

Are we finished now? Perhaps so. match (or at least our version of it) is a pretty simple macro—we haven’t provided many places for behavior to diverge. But the question of completion is fuzzier for macros with more complex input constraints, like this one that acts like defn but provides a default docstring where none is given:

language_features/default_docstring.clj
 
;; lein try [org.clojure/tools.macro "0.1.5"]
 
(​require​ '[clojure.tools.macro :as m])
 
 
(​defn-​ default-docstring [​name​]
 
(​str​ ​"The author carelessly forgot to provide a docstring for `"
 
name
 
"`. Sorry!"​))
 
 
(​defmacro​ my-defn [​name​ & body]
 
(​let​ [[​name​ args] (m/name-with-attributes ​name​ body)
 
name​ (​if​ (:doc (​meta​ ​name​))
 
name
 
(​vary-meta​ ​name​ ​assoc​ :doc (default-docstring ​name​)))]
 
`(​defn​ ~​name​ ~@args)))
 
 
(my-defn foo [])
 
;=> #'user/foo
 
(​doc​ foo)
 
; -------------------------
 
; user/foo
 
; ([])
 
; The author carelessly forgot to provide a docstring for `foo`. Sorry!
 
;=> nil

Enumerating all of the possible things that can go wrong for a user here would be time-consuming. There’s a combinatorial number of things the user could do wrong, with the function name, docstring, function arguments, pre- and post-conditions, and metadata map. You may remember making some confusing mistakes around what the defn or ns macros allowed when you were first learning Clojure. In situations like these, the idea of illuminated macros is particularly compelling, and we’ll look at that next.

Illuminated Macros

The idea of illuminated macros, as far as I can tell, was introduced in a talk of the same name at Clojure/Conj 2013[37] by Jonathan Claggett and Chris Houser, where they discussed the pain of learning to use complex and under-documented macros, and a potential solution, based in part on research in Scheme by Culpepper & Felleison.[38] They introduced a library called seqex[39] that aims to help automatically create both good documentation and good error messages for macros that are written using its tooling.

Do you remember when you were still learning the syntax for defn, and where the docstring goes in relation to the argument list and any preconditions? The built-in defn macro does a pretty good job of providing helpful error messages, but it’s had many hours of thought put into it, and it’s not perfect:

language_features/defn_errors.clj
 
(​defn​ ​"Squares a number"​ square [x] (​*​ x x))
 
; IllegalArgumentException First argument to defn must be a symbol
 
; clojure.core/defn (core.clj:277)
 
; java.lang.IllegalArgumentException: First argument to defn must be a symbol
 
; core.clj:277 clojure.core/defn
 
 
;; Not bad, huh? Let's try to remember how destructuring works:
 
 
(​defn​ square-pair [(x y)]
 
(​list​ (​*​ x x) (​*​ y y)))
 
; CompilerException java.lang.Exception: Unsupported binding form: (x y),
 
; compiling: [...]
 
 
;; Still pretty darn good, but doesn't tell us what binding forms *are* valid.

A seqex-based implementation of defn goes even a bit further and provides both additional context around errors and generated grammar documentation:

language_features/seqex_defn_usage.clj
 
;; lein try org.clojars.trptcolin/seqex "2.0.1.1"
 
 
(​require​ '[n01se.syntax.repl :as syntax])
 
 
(syntax/defn ​"Squares a number"​ square [x] (​*​ x x))
 
; Bad value: "Squares a number"
 
; Expected var-name
 
;=> nil
 
 
(syntax/syndoc syntax/defn)
 
; defn => (defn var-name doc-string? attr-map? (sig-body | (sig-body)+))
 
; sig-body => binding-vec prepost-map? form*
 
; binding-vec => [binding-form* (& symbol)? (:as symbol)?]
 
; binding-form => symbol | binding-vec | binding-map
 
; binding-map => {((binding-form form) | (:as symbol) | keys | defaults)*}
 
; keys => (:keys | :strs | :syms) [symbol*]
 
; defaults => :or {(symbol form)*}
 
;=> nil
 
 
;; The above is even better in the terminal, with its ANSI color output.

The syndoc syntax documentation for this macro looks just like a BNF[40] grammar, and because it’s automatically generated from the same code that provides the error messages, it’s bound to stay in sync even if the allowable macro inputs change!

This is a pretty big win for anyone who’s struggled with poor macro usability. It’s not without cost, though: syntax/defn takes work to implement, even though it happens to be built on top of clojure.core/defn. seqex, as you may have guessed based on its name or problem domain, uses tools that the authors have dubbed “sequence expressions,” which are for sequences what regular expressions are for strings. Ignoring the most complex parts of syntax/defn, the destructuring logic, there’s still a lot of thought that’s been put into structuring the sequence expressions:

language_features/seqex_defn_implementation.clj
 
(​ns​ n01se.syntax.repl
 
(:require [n01se.seqex :refer [cap recap]]
 
[n01se.syntax :refer [defterminal defrule defsyntax
 
cat opt alt rep* rep+
 
vec-form, map-form, map-pair, list-form
 
rule sym form string]])
 
(:refer-clojure :exclude [​let​ ​defn​]))
 
(​alias​ 'clj 'clojure.core)
 
 
(defterminal prepost-map ​map?​)
 
(defterminal attr-map ​map?​)
 
(defterminal doc-string ​string?​)
 
 
(​declare​ binding-form)
 
 
(defrule binding-vec
 
(vec-form (cat (rep* (​delay​ binding-form))
 
(opt (cat '& sym))
 
(opt (cat :as sym)))))
 
 
(defrule binding-map
 
;; [complex logic mostly because of destructuring]
 
)
 
 
(defrule binding-form
 
(alt sym binding-vec binding-map))
 
 
(defrule sig-body
 
(cat binding-vec (opt prepost-map) (rep* form)))
 
 
(defterminal var-name ​symbol?​)
 
 
(defsyntax ​defn
 
(cap (cat var-name
 
(opt doc-string)
 
(opt attr-map)
 
(alt sig-body
 
(rep+ (list-form sig-body))))
 
(​fn​ [forms] `(clj/defn ~@forms))))

This style of macro-writing hasn’t yet taken the Clojure community by storm, but that may change because of the documentation and error-generating benefits. Quite recently, another entrant has appeared on the sequence expression scene: Christophe Grand’s elegant and powerful seqexp.[41] At version 0.2.1, it’s more of a single-purpose tool, currently only handling the sequence expression part of the problem, and not the generation of error messages or documentation. I wouldn’t be surprised to see those kinds of features built on top of seqexp in the future, though. Because most macros tend to be fairly focused and because the cost of switching over to an illuminated approach is high, we’re likely to keep writing macros in the normal way for the foreseeable future. But as a user of complex macros, I’d sure like to have the benefits conferred by the illuminated macro approach.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.116.21.152