Can I write this macro without using eval?

2019-07-02 10:18发布

问题:

I'm trying to write a macro which will catch a compile time error in Clojure. Specifically, I would like to catch exceptions thrown when a protocol method, which has not been implemented for that datatype, is called and clojure.lang.Compiler$CompilerException is thrown.

So far I have:

(defmacro catch-compiler-error [body] (try (eval body) (catch Exception e e)))

But of course, I've been told that eval is evil and that you don't typically need to use it. Is there a way to implement this without using eval?

I'm inclined to believe that eval is appropriate here since I specifically want the code to be evaluated at runtime and not at compile time.

回答1:

Macros are expanded at compile time. They don't need to eval code; rather, they assemble the code that will be later be evaluated at runtime. In other words, if you want to make sure that the code passed to a macro is evaluated at runtime and not at compile time, that tells you that you absolutely should not eval it in the macro definition.

The name catch-compiler-error is a bit of a misnomer with that in mind; if the code that calls your macro has a compiler error (a missing parenthesis, perhaps), there's not really anything your macro can do to catch it. You could write a catch-runtime-error macro like this:

(defmacro catch-runtime-error
  [& body]
  `(try
     ~@body
     (catch Exception e#
       e#)))

Here's how this macro works:

  1. Take in an arbitrary number of arguments and store them in a sequence called body.
  2. Create a list with these elements:
    1. The symbol try
    2. All the expressions passed in as arguments
    3. Another list with these elements:
      1. The symbol catch
      2. The symbol java.lang.Exception (the qualified version of Exception)
      3. A unique new symbol, which we can refer to later as e#
      4. That same symbol that we created earlier

This is a bit much to swallow all at once. Let's take a look at what it does with some actual code:

(macroexpand
 '(catch-runtime-error
    (/ 4 2)
    (/ 1 0)))

As you can see, I'm not simply evaluating a form with your macro as its first element; that would both expand the macro and evaluate the result. I just want to do the expansion step, so I'm using macroexpand, which gives me this:

(try
  (/ 4 2)
  (/ 1 0)
  (catch java.lang.Exception e__19785__auto__
    e__19785__auto__))

This is indeed what we expected: a list containing the symbol try, our body expressions, and another list with the symbols catch and java.lang.Exception followed by two copies of a unique symbol.

You can check that this macro does what you want it to do by directly evaluating it:

(catch-runtime-error (/ 4 2) (/ 1 0))
;=> #error {
;    :cause "Divide by zero"
;    :via
;    [{:type java.lang.ArithmeticException
;      :message "Divide by zero"
;      :at [clojure.lang.Numbers divide "Numbers.java" 158]}]
;    :trace
;    [[clojure.lang.Numbers divide "Numbers.java" 158]
;     [clojure.lang.Numbers divide "Numbers.java" 3808]
;     ,,,]}

Excellent. Let's try it with some protocols:

(defprotocol Foo
  (foo [this]))

(defprotocol Bar
  (bar [this]))

(defrecord Baz []
  Foo
  (foo [_] :qux))

(catch-runtime-error (foo (->Baz)))
;=> :qux

(catch-runtime-error (bar (->Baz)))
;=> #error {,,,}

However, as noted above, you simply can't catch a compiler error using a macro like this. You could write a macro that returns a chunk of code that will call eval on the rest of the code passed in, thus pushing compile time back to runtime:

(defmacro catch-error
  [& body]
  `(try
     (eval '(do ~@body))
     (catch Exception e#
       e#)))

Let's test the macroexpansion to make sure this works properly:

(macroexpand
 '(catch-error
    (foo (->Baz))
    (foo (->Baz) nil)))

This expands to:

(try
  (clojure.core/eval
   '(do
      (foo (->Baz))
      (foo (->Baz) nil)))
  (catch java.lang.Exception e__20408__auto__
    e__20408__auto__))

Now we can catch even more errors, like IllegalArgumentExceptions caused by trying to pass an incorrect number of arguments:

(catch-error (bar (->Baz)))
;=> #error {,,,}

(catch-error (foo (->Baz) nil))
;=> #error {,,,}

However (and I want to make this very clear), don't do this. If you find yourself pushing compile time back to runtime just to try to catch these sorts of errors, you're almost certainly doing something wrong. You'd be much better off restructuring your project so that you don't have to do this.

I'm guessing you've already seen this question, which explains some of the pitfalls of eval pretty well. In Clojure specifically, you definitely shouldn't use it unless you completely understand the issues it raises regarding scope and context, in addition to the other problems discussed in that question.