彼の一存

The place where the past meets the present to contemplate the future

堵住 lisp 宏的漏洞

本文的内容来自于 Practical Common Lisp

先来一个有漏洞的宏作为示例

;;判断一个数是否为质数
(defun primep (number)
  (when (> number 1)
    (loop for fac from 2 to (isqrt number) never (zerop (mod number fac)))))
;;返回一个质数的下一个质数(若当前这个数是质数则返回该数)
(defun next-prime (number)
  (loop for n from number when (primep n) return n))
;;这是一个只对变量在 start 至 end 区域内(包括边界)的质数执行 body 的宏
(defmacro do-primes ((var start end) &body body)
  `(do ((,var (next-prime ,start) (next-prime (1+ ,var))))
       ((> ,var ,end))
     ,@body))

这里的 &body&rest 是等价的意思,但是在调用 do-primes 时,两者会有不同的缩进。

do-primes 的漏洞连锁

执行以下代码,来看看 do-primes 里的第一个漏洞:

(do-primes (p 0 (random 100))
  (format t "~d " p))

什么漏洞?看起来好像很正常。

用宏展开的命令一探以上代码的究竟:

(macroexpand-1 '(do-primes (p 0 (random 100)) (format t "~d " p)))
(DO ((P (NEXT-PRIME 0) (NEXT-PRIME (1+ P)))) ((> P (RANDOM 100))) (FORMAT T "~d " P))
T

可以看到,在每次判定结束的时候,都重新生成了一个 100 以内的随机数。在宏 do-primes 里, (RANDOM 100) 并未在传进去的时候就计算,而是 按原样传进去了1,然后在 do-primes 宏展开后再考虑计算问题!

那么怎么修复这个漏洞呢?很简单,想办法在宏内让 (random 100) 只执行一次,好,按这个思路修改 do-primes 宏的定义代码:

(defmacro do-primes ((var start end) &body body)
  `(do ((ending-value ,end)
        (,var (next-prime ,start) (next-prime (1+ ,var))))
       ((> ,var ending-value))
     ,@body))

等会儿,这个修改引入了一个 不算太严重的 问题: end 比 start 先求值了 ,这在这段代码里可能不算什么问题,但是考虑到通用性,还是应该这样写:

(defmacro do-primes ((var start end) &body body)
  `(do ((,var (next-prime ,start) (next-prime (1+ ,var)))
        (ending-value ,end))
       ((> ,var ending-value))
     ,@body))

嗯,修复这个漏洞很简单,交换一下就行了,不过目前这个 do-primes 形式有一个更隐秘且更严重的新漏洞,尝试以下代码:

(do-primes (ending-value 0 10)
  (print ending-value))

宏展开看看:

(macroexpand-1 '(do-primes (ending-value 0 10) (print ending-value)))
(DO ((ENDING-VALUE (NEXT-PRIME 0) (NEXT-PRIME (1+ ENDING-VALUE))) (ENDING-VALUE 10)) ((> ENDING-VALUE 10)) (PRINT ENDING-VALUE))
T

见鬼, ending-value 和宏定义里的 ending-value 重合了! ending-value 会一直停留在 10 这个值上,导致死循环!

为了修复这个漏洞,我们需要在宏定义里使用永远不会在宏定义外面被用到的符号。但是考虑到外界的无限可能性,这种符号在哪里找呢?

函数 GENSYM 提供了答案。 GENSYM 每次被调用都会返回唯一的一个符号——不曾在之前的 lisp 代码中出现过——以后也不会。基于这个函数,重写 do-primes !:

(defmacro do-primes ((var start end) &body body)
  (let ((ending-value-name (gensym)))
    `(do ((,var (next-prime ,start) (next-prime (1+ ,var)))
          (,ending-value-name ,end))
         ((> ,var ,ending-value-name))
       ,@body)))

由于 let 的部分是在 do-primes 定义的时候就执行的 ,所以 ending-value-name 变量只在本地有效,外部不会有重名。这里仅为给新人的提醒。

之后,对 var start 做和 end 一样的处理,这些漏洞就补上了。

填补 lisp 漏洞的总结

  1. 所有宏参数只应该被求值一次;看这里
  2. 宏定义式内参数的展开执行应该与参数的传入顺序一致;看这里
  3. 使用 GENSYM 创建宏定义式中的临时变量名。看这里

使用上述三方法,就能消除一些大部分的 lisp 宏漏洞,减少宏抽象的细节泄露。

事实上, 任何一种抽象手段,都有泄露抽象细节的问题 ,即使是被视为 史上最强抽象手段(包括未来史) 的 lisp 宏也无法避免。关于这点可以参看 The Law of Leaky Abstractions

Prev link

脚注:

1

实际上对所有宏而言都是如此。

Comments

使用Disqus评论
comments powered by Disqus