宏约定和最佳实践的 Lisp 单元测试

Lisp unit tests for macros conventions and best practices

我发现很难对宏扩展进行推理,并且想知道测试它们的最佳实践是什么。

所以如果我有一个宏,我可以通过macroexpand-1执行一级宏扩展。

(defmacro incf-twice (n)
  `(progn
     (incf ,n)
     (incf ,n)))

例如

(macroexpand-1 '(incf-twice n))

评估为

(PROGN (INCF N) (INCF N))

把它变成宏测试似乎很简单。

(equalp (macroexpand-1 '(incf-twice n))
  '(progn (incf n) (incf n)))

是否有组织宏测试的既定惯例?另外,是否有一个库可以总结 s 表达式之间的差异?

通常测试宏不是 Lisp 和 Common Lisp 的强项之一。 Common Lisp(和一般的 Lisp 方言)使用过程宏。宏可以依赖于运行时上下文、compile-time 上下文、实现等等。它们也可能有副作用(比如在 compile-time 环境中注册东西,在开发环境中注册东西等等)。

所以有人可能想测试一下:

  • 生成正确的代码
  • 生成的代码确实做了正确的事情
  • 生成的代码实际上在代码上下文中工作
  • 在复杂宏的情况下,宏参数实际上被正确解析。想想 loopdefstruct、……宏。
  • 宏检测到格式错误的参数代码。同样,想想像 loopdefstruct.
  • 这样的宏
  • 副作用

从上面的列表可以推断出在开发宏时最好将所有这些问题区域最小化。但是:那里确实有非常复杂的宏。真的很可怕。尤其是那些习惯于实现新领域特定语言的人。

使用 equalp 之类的方法来比较代码仅适用于相对简单的宏。宏通常会引入新的、未保留的和独特的符号。因此 equalp 将无法使用这些。

例子:(rotatef a b)看似简单,展开其实很复杂:

CL-USER 28 > (pprint (macroexpand-1 '(rotatef a b)))

(PROGN
  (LET* ()
    (LET ((#:|Store-Var-1234| A))
      (LET* ()
        (LET ((#:|Store-Var-1233| B))
          (PROGN
            (SETQ A #:|Store-Var-1233|)
            (SETQ B #:|Store-Var-1234|))))))
  NIL)

#:|Store-Var-1233|是一个符号,它是uninterned的,是宏新创建的。

另一种具有复杂扩展的简单宏形式是 (defstruct s b)

因此需要一个 s-expression 模式匹配器来比较扩展。有一些可用的,它们在这里很有用。需要在测试模式中确保生成的符号在需要时是相同的。

还有 s-expression 差异工具。例如 diff-sexp.

我同意;一般来说,这是一项非常难解决的任务,因为宏可以做很多事情。但是,我要指出的是,在许多情况下,对宏进行单元测试的最简单方法是让宏做的事情尽可能少。在许多情况下,最简单的宏实现只是简单函数周围的语法糖。例如,在 Common Lisp 中有一个典型的 with-... 宏模式(例如 with-open-file),其中宏只是封装了一些样板代码:

(defun make-frob (frob-args)
  ;; do something and return the resulting frob
  (list 'frob frob-args))

(defun cleanup-frob (frob)
  (declare (ignore frob))
  ;; release the resources associated with the frob
  )

(defun call-with-frob (frob-args function)
  (let ((frob (apply 'make-frob frob-args)))
    (unwind-protect (funcall function frob)
      (cleanup-frob frob))))

(defmacro with-frob ((var &rest frob-args) &body body)
  `(call-with-frob
    (list ,@frob-args)
    (lambda (,var)
      ,@body)))

这里的前两个函数,make-frobcleanup-frob对单元测试来说相对简单。 call-with-frob 有点难。这个想法是它应该处理创建 frob 的样板代码并确保发生清理调用。这有点难以检查,但如果样板文件仅依赖于一些定义明确的接口,那么您可能能够创建一个 frob 模型来检测它是否被正确清理。最后,with-frob 宏非常简单,您可以按照您一直在考虑的方式对其进行测试,即检查其扩展。或者你可能会说它很简单,你不需要测试它。

另一方面,如果您正在研究更复杂的宏,例如 loop,它本身就是一种编译器,您我们几乎肯定已经在一些单独的功能中拥有扩展逻辑。例如,您可能

(defmacro loop (&body body)
  (compile-loop body))

这种情况你真的不需要测试loop,你需要测试compile-loop,然后您回到了通常的单元测试领域。

我通常只测试功能,而不是扩展的形状。

是的,有各种各样的上下文和环境可能会影响发生的事情,但如果你依赖这些东西,为你的测试设置相同的东西应该没问题。

一些常见的情况:

  • 绑定宏:测试变量是否按预期在内部绑定,并且任何隐藏的外部变量不受影响
  • unwind-protect 包装器:从内部引发非本地退出并检查清理工作是否正常
  • definition/registration: 测试你可以define/register你想要的然后使用它