专栏名称: SegmentFault思否
SegmentFault (www.sf.gg)开发者社区,是中国年轻开发者喜爱的极客社区,我们为开发者提供最纯粹的技术交流和分享平台。
目录
相关文章推荐
程序员的那些事  ·  北京大学出的第二份 DeepSeek ... ·  23 小时前  
码农翻身  ·  中国的大模型怎么突然间就领先了? ·  昨天  
OSC开源社区  ·  RAG市场的2024:随需而变,从狂热到理性 ·  昨天  
程序猿  ·  41岁DeepMind天才科学家去世:长期受 ... ·  2 天前  
程序员的那些事  ·  清华大学:DeepSeek + ... ·  3 天前  
51好读  ›  专栏  ›  SegmentFault思否

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

SegmentFault思否  · 公众号  · 程序员  · 2017-10-18 08:00

正文

文章作者: http://liujiacai.net/ 原文链接: http://liujiacai.net/blog/2017/10/01/macro-in-action/

上一篇文章 中,介绍了宏(macro)的本质:在编译时期运行的函数。宏相对于普通函数,还有如下两条特点:

  • 宏的参数不会求值(eval),是 symbol 字面量

  • 宏的返回值是 code(在运行期执行),不是一般的数据。

这两条特性蕴含着一非常重要的思想: code as data ,也被称为同像性(homoiconicity,来自希腊语单词 homo,意为与符号含义表示相同)。同像性使得在 Lisp 中去操作语法树(AST)显得十分自然,而这在非 Lisp 语言只能由编译器(Compiler)去操作。

本文是宏系列的第二篇文章,侧重于实战,对于新手建议先阅读宏系列的理论篇,之后再来看本文。当然如果你有一定基础,也可以直接阅读本文。 其次,希望读者能把本文的 Clojure 代码手动敲到 REPL 里面去运行、调试,直到完全理解。

Code as data

首先看一简单的程序片段

  1. (defn hello-world []

  2.  (println "hello world"))

上面的代码首先是一个大的 list,里面依次包含了2 个 symbol,1 个 vector,1 个 list,这个嵌套的 list 又包含了 1 个 symbol,1 个 string。可以看到,这些都是 Clojure 里面的基本数据类型,这就给我们提供了一个很好的写宏基础:可以像操作数据一样操作 code ,达到自动生成新 code 的目的。

宏的一主要使用场景是流程控制,比如 Clojure 里面的 when

  1. (defmacro when [test & body]

  2.  (list 'if test (cons 'do body)))

' 代表 quote,作用是阻止后面的表达式求值,如果不使用'的话,在进行 (list'if test ...) 求值时会报错,因为没发对 special form 单独进行求值,这里需要的仅仅是 if 字面量,list 函数执行后的结果(是一个 list)作为 code 插入到调用 when 的地方去执行。

  1. (when (even? (rand-int 100))

  2.  (println "good luck!")

  3.  (println "lisp rocks!"))

  4. ;; when 展开后的形式

  5. (if (even? (rand-int 100))

  6.  (do (println "good luck!") (println "lisp rocks!")))

syntax-quote & unquote

对于一些简单的宏,可以采用像 when 那样的方式,使用 list 函数来形成要返回的 code,但对于复杂的宏,使用 list 函数来表示,会显得十分麻烦,看下 when-let 的实现:

  1. (defmacro when-let [bindings & body]

  2.  (let [form (bindings 0) tst (bindings 1)]

  3.    `(let [temp# ~tst]

  4.       (when temp#

  5.         (let [~form temp#]

  6.           ~@body)))))

这里返回的 list 使用 (backtick) 进行了修饰,这称为 syntax-quote,它与 quote ' 类似,只不过在阻止表达式求值的同时,支持以下两个额外功能:

  1. 表达式里的所有 symbol 会在当前 namespace 中进行 resolve,返回 fully-qualified symbol

  2. 允许通过 ~(unquote) 或 ~@(slicing-unquote) 阻止部分表达式的 quote,以达到对它们求值的效果

可以通过下面一个例子来了解它们之间的区别:

  1. (let [x '(* 2 3) y x]

  2.  (println `y)

  3.  (println ``y)

  4.  (println ``~y)

  5.  (println ``~~y)

  6.  (println (eval ``~~y))

  7.  (println `[~@y]))

  8. ;; 依次输出

  9. user/y

  10. (quote user/y)

  11. user/y

  12. (* 2 3)

  13. 6

  14. [* 2 3]

这里尤其要注意理解嵌套 syntax-quote 的情况,为了得到正确的值,需要 unquote 相应的次数(上例中的第四个println),这在 macro-writing macro 中十分有用,后面会介绍的。

最后需要注意一点,在整个 Clojure 程序生命周期中,(syntax-)quote, (slicing-)unquote 是 Reader 来解析的,详见 编译器工作流程。可以通过read-string来验证:

  1. user> (read- string "`y")

  2. (quote user/y)

  3. user> (read-string "``y")

  4. (clojure.core/seq (clojure.core/concat (clojure.core/list (quote quote))

  5.                                       (clojure.core/list (quote user/y))))

  6. user> (read-string "``~y")

  7. (quote user/y)

  8. user> (read-string "``~~y")

  9. y

Macro Rules of Thumb

在正式实战前,这里摘抄 JoyOfClojure 一书中关于写宏的一般准则:

  1. 如果函数能完成相应功能,不要写宏。在需要构造语法抽象(比如when)或新的binding 时再去用宏

  2. 写一个宏使用的 demo,并手动展开

  3. 使用macroexpand, macroexpand-1 与 clojure.walk/macroexpand-all 去验证宏是如何工作的

  4. 在 REPL 中测试

  5. 如果一个宏比较复杂,尽可能拆分成多个函数

希望读者在写/读宏遇到困难时,思考是否对应了上述准则。

In Action

前面介绍过,宏的一大应用场景是流程控制,比如上面介绍的 when、when-let,以及各种 do 的衍生品 dotimes、doseq,所以实战也从这里入手,构造一系列 do-primes,通过对它不断的完善修改,介绍写宏的技巧与注意事项。

  1. (do-primes [n start end]

  2.  body)

上面是 do-primes 的使用方式,它会遍历 [start, end) 范围内的素数,对于具体素数 n,执行 body 里面的内容。

do-primes

  1. (defn prime? [n]

  2.  (let [guard (int (Math/ceil (Math/sqrt n)))]

  3.    (loop [i 2]

  4.      (if (zero? (mod n i))

  5.        false

  6.        ( if (= i guard)

  7.          true

  8.          (recur (inc i)))))))

  9. (defn next-prime [n]

  10.  (if (prime? n)

  11.    n

  12.    (recur (inc n))))

  13. (defmacro do-primes [[variable start end] & body]

  14.  `(loop [~variable ~start]

  15.     (when (< ~variable ~end)

  16.       (when (prime? ~variable)

  17.         ~@body)

  18.       (recur (next-prime (inc ~variable))))))

上面的实现比较直接,首先定义了两个辅助函数,然后通过返回由 loop 构成的 code 来达到遍历的效果。简单测试下:

  1. (do-primes [n 2 13]

  2.  (println n))

  3. ;; 展开为

  4. (loop [n 2]

  5.  (when (< n 13)

  6.    (when (prime? n) (println n))

  7.    (recur (next-prime (inc n)))))

  8. ;; 最终输出 3 5 7 11

达到预期。但上述实现有些问题:end 在循环中进行比较时多次进行了求值,如果传入的 end 不是固定的数字,而是一个函数,而我们又无法确定这个函数有无副作用,这就可能产生问题。 也许你会说,这个解决也很简单,在进行 loop 之前,用一个 let 先把 end 的值先算出来就可以了。这个确实能解决多次执行的问题,但是又引入另一个隐患:end 先于 start 执行。这会不会产生不良后果,我们同样无法预知,我们能做到的就是尽量不用暴露宏的实现细节,具体表现就是保证宏参数的求值顺序。所以有了下面的修改:

  1. (defmacro do-primes2 [[variable start end] & body]

  2.  `(let [start# ~start

  3.         end# ~end]

  4.     (loop [~variable start#]

  5.       (when (< ~variable end#)

  6.         (when (prime? ~variable)

  7.           ~@body)

  8.         (recur (next-prime (inc ~variable)))))))

上面使用 gensym 机制来生成全局位置的 symbol,保证宏的“卫生”(hygiene)。如果这里不使用 gensym,而是随便选取变量名,那么很有可能会产生冲突。

  1. (do-primes2 [n 2 (+ 10 (rand-int 30))]

  2.  (println n))

  3. ;; 展开为

  4. (let [start__17380__auto__ 2 end__17381__auto__ (+ 10 (rand-int 30))]

  5.  (loop [n start__17380__auto__]

  6.    (when (< n end__17381__auto__)

  7.      (when (prime? n) (println n))

  8.      (recur (next-prime (inc n))))))

only-once

通过上面的例子,我们知道,gensym 是一种非常实用的技巧,所以我们完全有可能再进行一次抽象,构造 only-once 宏,来保证传入的参数按照顺序只求值一次:

  1. (defmacro only-once [names & body]

  2.  (let [gensyms (repeatedly (count names) gensym)]

  3.    `(let [~@(interleave gensyms (repeat '(gensym)))]

  4.       `(let [~~@(mapcat #(list %1 %2) gensyms names)]

  5.          ~(let [~@(mapcat #(list %1 %2) names gensyms)]

  6.             ~@body)))))

  7. (defmacro do-primes3 [[variable start end] & body]

  8.  (only-once [start end]

  9.             `(loop [~variable ~start]

  10.                (when (< ~variable ~end)

  11.                  (when (prime? ~variable)

  12.                    ~@body)

  13.                  (recur (next-prime (inc ~variable)))))))

  14. (do-primes3 [n 2 (+ 10 (rand-int 30))]

  15.  (println n))

  16. ;; 展开为

only-once 的核心思想是用 gensym 来替换掉传入的 symbol(即 names),为了达到这种效果,它首先定义出一组与参数数目相同的 gensyms(分别记为#s1 #s2),然后在第二层 let 为这些 gensyms 做 binding,value 也是用 gensym 生成的(分别记为#s3 #s4),这一层的 let 的返回值将内嵌到 do-primes3 内:

  1. (let [#s1 #s3 #s2 #s4]

  2.  '(let [#s3 start #s3 end]

  3.    (let [start #s1 end #s2]

  4.      ~@body))

第三层 let 的结果作为 code 内嵌到调用 do-primes3 处,即最终的展开式:

  1. (







请到「今天看啥」查看全文