由浅入深学习 Lisp 宏之实战篇

YoungFrasie 6年前
   <p>在上一篇文章中,介绍了宏(macro)的本质: 在编译时期运行的函数 。宏相对于普通函数,还有如下两条特点:</p>    <ol>     <li>宏的参数不会求值(eval),是 symbol 字面量</li>     <li>宏的返回值是 code(在运行期执行),不是一般的数据。</li>    </ol>    <p>这两条特点也决定了是需要用普通函数还是宏来解决问题,这里面也蕴含着 <a href="/misc/goto?guid=4959755047634006692" rel="nofollow,noindex">code as data</a> 的思想,也被称为同像性(homoiconicity,来自希腊语单词 homo,意为与符号含义表示相同)。同像性使得在 Lisp 中去操作语法树(AST)显得十分自然,而这在非 Lisp 语言只能由编译器(Compiler)去操作。</p>    <p>这篇文章侧重于实战,用具体示例介绍写宏的技巧与注意事项,希望读者能把本文的 Clojure 代码自己手动敲到 REPL 里面去运行、调试,直到完全理解。</p>    <h2>Code as data</h2>    <p>首先看一个简单的程序片段</p>    <pre>  <code class="language-lisp">(defn hello-world []    (println "hello world"))  </code></pre>    <p>上面的代码首先是一个大的 list,里面依次包含了2 个 symbol,1 个 vector,1 个 list,这个嵌套的 list 又包含了 1 个 symbol,1 个 string。可以看到,这些都是 Clojure 里面的基本数据类型,这就给我们提供了一个很好的写宏基础。Clojure 里面很多控制结构都是用宏来实现,比如 when :</p>    <pre>  <code class="language-lisp">(defmacro when [test & body]    (list 'if test (cons 'do body)))  </code></pre>    <p>' 代表 quote,作用是阻止后面的表达式求值,如果不使用 ' 的话,在进行 (list 'if test ...) 求值时会报错,因为没发对 special form 单独进行求值,这里需要的仅仅是 if 字面量,list 函数执行后的结果(是一个 list)作为 code 插入到调用 when 的地方去执行。</p>    <pre>  <code class="language-lisp">(when (even? (rand-int 100))    (println "good luck!")    (println "lisp rocks!"))    ;; when 展开后的形式    (if (even? (rand-int 100))    (do (println "good luck!") (println "lisp rocks!")))  </code></pre>    <h3>syntax-quote & unquote</h3>    <p>对于一些简单的宏,可以采用像 when 那样的方式,使用 list 函数来形成要返回的 code,但对于复杂的宏,使用 list 函数来表示,会显得十分麻烦,看下 when-let 的实现:</p>    <pre>  <code class="language-lisp">(defmacro when-let [bindings & body]    (let [form (bindings 0) tst (bindings 1)]      `(let [temp# ~tst]         (when temp#           (let [~form temp#]             ~@body)))))  </code></pre>    <p>这里返回的 list 使用 <em>`</em> (backtick)进行了修饰,这是 syntax-quote,它与 quote ' 类似,只不过在阻止表达式求值的同时,支持以下两个功能:</p>    <ol>     <li>表达式里的所有 symbol 会在当前 namespace 中进行 resolve,返回 fully-qualified symbol</li>     <li>允许通过 ~ (unquote) 或 ~@ (slicing-unquote) 阻止部分表达式的 quote,以达到对它们求值的效果</li>    </ol>    <p>可以通过下面一个例子来了解它们之间的区别:</p>    <pre>  <code class="language-lisp">(let [x '(* 2 3) y x]    (println `y)    (println ``y)    (println ``~y)    (println ``~~y)    (println (eval ``~~y))    (println `[~@y]))    ;; 依次输出    user/y  (quote user/y)  user/y  (* 2 3)  6  [* 2 3]  </code></pre>    <p>这里尤其要注意理解嵌套 syntax-quote 的情况,为了得到正确的值,需要 unquote 相应的次数(上例中的第四个println),这在 macro-writing macro 中十分有用,后面会介绍的。</p>    <p>最后需要注意一点,在整个 Clojure 程序生命周期中, (syntax-)quote , (slicing-)unquote 是 <a href="/misc/goto?guid=4959736054480327010" rel="nofollow,noindex">Reader</a> 来解析的,详见编译器工作流程。可以通过 read-string 来验证:</p>    <pre>  <code class="language-lisp">user> (read-string "`y")  (quote user/y)  user> (read-string "``y")  (clojure.core/seq (clojure.core/concat (clojure.core/list (quote quote))                                          (clojure.core/list (quote user/y))))  user> (read-string "``~y")  (quote user/y)  user> (read-string "``~~y")  y  </code></pre>    <h2>Macro Rules of Thumb</h2>    <p>在正式实战前,这里摘抄 JoyOfClojure 一书中关于写宏的一般准则:</p>    <ol>     <li>如果函数能完成相应功能,不要写宏。在需要构造语法抽象(比如 when )或新的binding 时再去用宏</li>     <li>写一个宏使用的 demo,并手动展开</li>     <li>使用 macroexpand , macroexpand-1 与 clojure.walk/macroexpand-all 去验证宏是如何工作的</li>     <li>在 REPL 中测试</li>     <li>如果一个宏比较复杂,尽可能拆分成多个函数</li>    </ol>    <h2>In Action</h2>    <p>宏的一大应用场景是流程控制,比如上面介绍的 when、when-let,以及各种 do 的衍生品 dotimes、doseq,我们的实战也从这里入手,构造一系列 do-primes,由浅入深介绍写宏的技巧与注意事项。</p>    <pre>  <code class="language-lisp">(do-primes [n start end]    body)  </code></pre>    <p>它会遍历 [start, end) 范围内的素数,对于具体素数 n,执行 body 里面的内容。</p>    <h3>do-primes</h3>    <pre>  <code class="language-lisp">(defn prime? [n]    (let [guard (int (Math/ceil (Math/sqrt n)))]      (loop [i 2]        (if (zero? (mod n i))          false          (if (= i guard)            true            (recur (inc i)))))))    (defn next-prime [n]    (if (prime? n)      n      (recur (inc n))))    (defmacro do-primes [[variable start end] & body]    `(loop [~variable ~start]       (when (< ~variable ~end)         (when (prime? ~variable)           ~@body)         (recur (next-prime (inc ~variable))))))  </code></pre>    <p>上面的实现比较直接,首先定义了两个辅助函数,然后通过返回由 loop 构成的 code 来达到遍历的效果。简单测试下:</p>    <pre>  <code class="language-lisp">(do-primes [n 2 13]    (println n))    ;; 展开为    (loop [n 2]    (when (< n 13)      (when (prime? n) (println n))      (recur (next-prime (inc n)))))    ;; 最终输出 3 5 7 11  </code></pre>    <p>达到预期。但是这么实现会有些问题,比如传入的start end 不是固定的数字,而是一个函数,我们无法确定这个函数有无副作用,这就会导致重复执行多次 end,这显然不是我们想要的效果,需要进行改造。</p>    <p>也许你会说,这个解决也很简单,在进行 loop 之前,用一个 let 先把 end 的值算出来,这个确实能解决多次执行的问题,但是又引入另一个隐患: <strong>end 先于 start 执行</strong> 。这会不会产生不良后果,我们同样无法预知,我们能做到的就是 <strong>尽量不用暴露宏的实现细节</strong> ,尽量保证参数的求值顺序。</p>    <pre>  <code class="language-lisp">(defmacro do-primes2 [[variable start end] & body]    `(let [start# ~start           end# ~end]       (loop [~variable start#]         (when (< ~variable end#)           (when (prime? ~variable)             ~@body)           (recur (next-prime (inc ~variable)))))))  </code></pre>    <p>上面使用 gensym 机制来保证生产 symbol 的唯一性,保证宏的“卫生”( <a href="/misc/goto?guid=4959755047755687973" rel="nofollow,noindex">hygiene</a> )。</p>    <pre>  <code class="language-lisp">(do-primes2 [n 2 (+ 10 (rand-int 30))]    (println n))  ;; 展开为  (let [start__17380__auto__ 2 end__17381__auto__ (+ 10 (rand-int 30))]    (loop [n start__17380__auto__]      (when (< n end__17381__auto__)        (when (prime? n) (println n))        (recur (next-prime (inc n))))))  </code></pre>    <h3>only-once</h3>    <p>通过上面的例子,我们也很容易的知道,gensym 是一种常用的技巧,所以我们完全有可能再进行一次抽象,构造 only-once 宏,来保证传入的参数按照顺序只执行一次。</p>    <pre>  <code class="language-lisp">(defmacro only-once [names & body]    (let [gensyms (repeatedly (count names) gensym)]      `(let [~@(interleave gensyms (repeat '(gensym)))]         `(let [~~@(mapcat #(list %1 %2) gensyms names)]            ~(let [~@(mapcat #(list %1 %2) names gensyms)]               ~@body)))))    (defmacro do-primes3 [[variable start end] & body]    (only-once [start end]               `(loop [~variable ~start]                  (when (< ~variable ~end)                    (when (prime? ~variable)                      ~@body)                    (recur (next-prime (inc ~variable)))))))    (do-primes3 [n 2 (+ 10 (rand-int 30))]    (println n))    ;; 展开为  </code></pre>    <p>only-once 的核心思想是用 gensym 来替换掉传入的 symbol(即 names),为了达到这种效果,它首先定义出一组与参数数目相同的 gensyms(分别记为#s1 #s2),然后在第二层 let 为这些 gensyms 做 binding,value 也是用 gensym 生成的(分别记为#s3 #s4),这一层的 let 的返回值将内嵌到 do-primes3 内:</p>    <pre>  <code class="language-lisp">(let [#s1 #s3 #s2 #s4]    '(let [#s3 start #s3 end]      (let [start #s1 end #s2]        ~@body))  </code></pre>    <p>第三层 let 的结果作为 code 内嵌到调用 do-primes3 处,即最终的展开式:</p>    <pre>  <code class="language-lisp">(let [#s3 2 #s4 (+ 10 (rand-int 30))]    (loop [n #s3]      (when (< n #s4)        (when (prime? n) (println n))        (recur (next-prime (inc n))))))  </code></pre>    <p>根据上述分析过程,可以看到第四层嵌套的 let 先于第三层嵌套的 let 执行,第四层 let 做 binding 时,是把 #s1 对应的 #s3 赋值给 start,#s2 对应的 #s4 赋值给 end,这样就成功的实现了 symbol 的替换。</p>    <p>only-once 属于 macro-writing macro 的范畴,就是说它使用的对象本身还是个宏,所以有一定的难度,主要是分清不同表达式的求值环境,这一点对于理解指一类宏非常核心。不过这一类宏大家应该很少能见到,更多的时候是使用辅助函数来分解复杂宏。比如我们这里就使用了两个辅助函数 prime? next-prime 来简化宏的写法。</p>    <h3>def-watched</h3>    <p>作为实战的最后一个例子,着重介绍 code 与 data 的联系与区别。</p>    <p>def-watched 它可以定义一个受监控的 var,在 root binding 改变时打印前后的值</p>    <pre>  <code class="language-lisp">(defmacro def-watched [name & value]    `(do       (def ~name ~@value)       (add-watch (var ~name)                  :re-bind                  (fn [~'key ~'r old# new#]                    (println '~name old# " -> " new#)))))    (def-watched foo 1)                    (def foo 2)  ;; 这时打印 foo 1 -> 2  </code></pre>    <p>为了简化 def-watched,可能会想把里面的函数提取出来:</p>    <pre>  <code class="language-lisp">(defn gen-watch-fn [name]    (fn [k r o n]      (println name ":" o " -> " n)))    (defmacro def-watched2 [name & value]    `(do       (def ~name ~@value)       (add-watch (var ~name)                  :re-bind (gen-watch-fn '~name))))    (def-watched2 bar 1)                    ;; 展开为  (do (def bar 1) (add-watch #'bar :re-bind (gen-watch-fn 'bar)))  </code></pre>    <p>这时的效果和上面是一样的,请注意这里是把 gen-watch-fn 实现为了函数,如果用宏的话,会有什么效果呢?</p>    <pre>  <code class="language-lisp">;; 将 gen-watch-fn 改为 defmacro,其他均不变   ;; (def-watched2 bar 1) 展开后变成了  (do    (def bar 1)    (add-watch      #'bar      :re-bind      #function[user/gen-watch-fn/fn--17288]))  </code></pre>    <p>这直接会报 No matching ctor found for class #function[user/gen-watch-fn/fn–17288],由于 gen-watch-fn 是宏,它返回的是 code,而不是一般的 data,这也就是问题发生的缘由。</p>    <h2>总结</h2>    <p>本文一开始就明确指出 Lisp 中 code as data 的特性,这一点表面看似比较好理解,但是放到具体环境中时,就十分容易搞错。</p>    <p>实战部分给出了一些宏的管用技巧,介绍了相比来说难以理解的 macro-writing marco,理解它有一定难度,但也不是无法入手,理清 quote unquote 的作用机制,并且在 REPL 中不断调试,肯定能有所收获。</p>    <p>虽说不推荐使用宏解决问题,但是在有些时候,一个宏能省掉好几十行代码,而且能使逻辑更清晰,这时候也就不用“吝啬”了。</p>    <p>最后,希望经过这两篇文章的介绍,大家能对宏有更深的理解。Happy Lisp!</p>    <p> </p>    <p>来自:http://liujiacai.net/blog/2017/10/01/macro-in-action/</p>    <p> </p>