Выбрать главу

(combine-results

(foo)

(bar)

(baz))

and have it mean something like this:

(let ((result t))

(unless (foo) (setf result nil))

(unless (bar) (setf result nil))

(unless (baz) (setf result nil))

result)

The only tricky bit to writing this macro is that you need to introduce a variable—result in the previous code—in the expansion. As you saw in the previous chapter, using a literal name for variables in macro expansions can introduce a leak in your macro abstraction, so you'll need to create a unique name. This is a job for with-gensyms. You can define combine-results like this:

(defmacro combine-results (&body forms)

(with-gensyms (result)

`(let ((,result t))

,@(loop for f in forms collect `(unless ,f (setf ,result nil)))

,result)))

Now you can fix check by simply changing the expansion to use combine-results instead of PROGN.

(defmacro check (&body forms)

`(combine-results

,@(loop for f in forms collect `(report-result ,f ',f))))

With that version of check, test-+ should emit the results of its three test expressions and then return T to indicate that everything passed.[103]

CL-USER> (test-+)

pass ... (= (+ 1 2) 3)

pass ... (= (+ 1 2 3) 6)

pass ... (= (+ -1 -3) -4)

T

And if you change one of the test cases so it fails,[104] the final return value changes to NIL.

CL-USER> (test-+)

pass ... (= (+ 1 2) 3)

pass ... (= (+ 1 2 3) 6)

FAIL ... (= (+ -1 -3) -5)

NIL

Better Result Reporting

As long as you have only one test function, the current result reporting is pretty clear. If a particular test case fails, all you have to do is find the test case in the check form and figure out why it's failing. But if you write a lot of tests, you'll probably want to organize them somehow, rather than shoving them all into one function. For instance, suppose you wanted to add some test cases for the * function. You might write a new test function.

(defun test-* ()

(check

(= (* 2 2) 4)

(= (* 3 5) 15)))

Now that you have two test functions, you'll probably want another function that runs all the tests. That's easy enough.

(defun test-arithmetic ()

(combine-results

(test-+)

(test-*)))

In this function you use combine-results instead of check since both test-+ and test-* will take care of reporting their own results. When you run test-arithmetic, you'll get the following results:

CL-USER> (test-arithmetic)

pass ... (= (+ 1 2) 3)

pass ... (= (+ 1 2 3) 6)

pass ... (= (+ -1 -3) -4)

pass ... (= (* 2 2) 4)

pass ... (= (* 3 5) 15)

T

Now imagine that one of the test cases failed and you need to track down the problem. With only five test cases and two test functions, it won't be too hard to find the code of the failing test case. But suppose you had 500 test cases spread across 20 functions. It might be nice if the results told you what function each test case came from.

Since the code that prints the results is centralized in report-result, you need a way to pass information about what test function you're in to report-result. You could add a parameter to report-result to pass this information, but check, which generates the calls to report-result, doesn't know what function it's being called from, which means you'd also have to change the way you call check, passing it an argument that it simply passes onto report-result.

This is exactly the kind of problem dynamic variables were designed to solve. If you create a dynamic variable that each test function binds to the name of the function before calling check, then report-result can use it without check having to know anything about it.

Step one is to declare the variable at the top level.

(defvar *test-name* nil)

Now you need to make another tiny change to report-result to include *test-name* in the FORMAT output.

(format t "~:[FAIL~;pass~] ... ~a: ~a~%" result *test-name* form)

With those changes, the test functions will still work but will produce the following output because *test-name* is never rebound:

CL-USER> (test-arithmetic)

pass ... NIL: (= (+ 1 2) 3)

pass ... NIL: (= (+ 1 2 3) 6)

pass ... NIL: (= (+ -1 -3) -4)

pass ... NIL: (= (* 2 2) 4)

pass ... NIL: (= (* 3 5) 15)

T

For the name to be reported properly, you need to change the two test functions.

(defun test-+ ()

(let ((*test-name* 'test-+))

(check

(= (+ 1 2) 3)

(= (+ 1 2 3) 6)

(= (+ -1 -3) -4))))

(defun test-* ()

(let ((*test-name* 'test-*))

(check

(= (* 2 2) 4)

(= (* 3 5) 15))))

Now the results are properly labeled.

CL-USER> (test-arithmetic)

pass ... TEST-+: (= (+ 1 2) 3)

pass ... TEST-+: (= (+ 1 2 3) 6)

pass ... TEST-+: (= (+ -1 -3) -4)

pass ... TEST-*: (= (* 2 2) 4)

pass ... TEST-*: (= (* 3 5) 15)

T

An Abstraction Emerges

In fixing the test functions, you've introduced several new bits of duplication. Not only does each function have to include the name of the function twice—once as the name in the DEFUN and once in the binding of *test-name*—but the same three-line code pattern is duplicated between the two functions. You could remove the duplication simply on the grounds that duplication is bad. But if you look more closely at the root cause of the duplication, you can learn an important lesson about how to use macros.

The reason both these functions start the same way is because they're both test functions. The duplication arises because, at the moment, test function is only half an abstraction. The abstraction exists in your mind, but in the code there's no way to express "this is a test function" other than to write code that follows a particular pattern.

Unfortunately, partial abstractions are a crummy tool for building software. Because a half abstraction is expressed in code by a manifestation of the pattern, you're guaranteed to have massive code duplication with all the normal bad consequences that implies for maintainability. More subtly, because the abstraction exists only in the minds of programmers, there's no mechanism to make sure different programmers (or even the same programmer working at different times) actually understand the abstraction the same way. To make a complete abstraction, you need a way to express "this is a test function" and have all the code required by the pattern be generated for you. In other words, you need a macro.

вернуться

103

If test-+ has been compiled—which may happen implicitly in certain Lisp implementations—you may need to reevaluate the definition of test-+ to get the changed definition of check to affect the behavior of test-+. Interpreted code, on the other hand, typically expands macros anew each time the code is interpreted, allowing the effects of macro redefinitions to be seen immediately.

вернуться

104

You have to change the test to make it fail since you can't change the behavior of +.