由淺入深學習 Lisp 宏之實戰篇

YoungFrasie 7年前發布 | 48K 次閱讀 Lisp Lisp開發

在上一篇文章中,介紹了宏(macro)的本質: 在編譯時期運行的函數 。宏相對于普通函數,還有如下兩條特點:

  1. 宏的參數不會求值(eval),是 symbol 字面量
  2. 宏的返回值是 code(在運行期執行),不是一般的數據。

這兩條特點也決定了是需要用普通函數還是宏來解決問題,這里面也蘊含著 code as data 的思想,也被稱為同像性(homoiconicity,來自希臘語單詞 homo,意為與符號含義表示相同)。同像性使得在 Lisp 中去操作語法樹(AST)顯得十分自然,而這在非 Lisp 語言只能由編譯器(Compiler)去操作。

這篇文章側重于實戰,用具體示例介紹寫宏的技巧與注意事項,希望讀者能把本文的 Clojure 代碼自己手動敲到 REPL 里面去運行、調試,直到完全理解。

Code as data

首先看一個簡單的程序片段

(defn hello-world []
  (println "hello world"))

上面的代碼首先是一個大的 list,里面依次包含了2 個 symbol,1 個 vector,1 個 list,這個嵌套的 list 又包含了 1 個 symbol,1 個 string。可以看到,這些都是 Clojure 里面的基本數據類型,這就給我們提供了一個很好的寫宏基礎。Clojure 里面很多控制結構都是用宏來實現,比如 when :

(defmacro when [test & body]
  (list 'if test (cons 'do body)))

' 代表 quote,作用是阻止后面的表達式求值,如果不使用 ' 的話,在進行 (list 'if test ...) 求值時會報錯,因為沒發對 special form 單獨進行求值,這里需要的僅僅是 if 字面量,list 函數執行后的結果(是一個 list)作為 code 插入到調用 when 的地方去執行。

(when (even? (rand-int 100))
  (println "good luck!")
  (println "lisp rocks!"))

;; when 展開后的形式

(if (even? (rand-int 100))
  (do (println "good luck!") (println "lisp rocks!")))

syntax-quote & unquote

對于一些簡單的宏,可以采用像 when 那樣的方式,使用 list 函數來形成要返回的 code,但對于復雜的宏,使用 list 函數來表示,會顯得十分麻煩,看下 when-let 的實現:

(defmacro when-let [bindings & body]
  (let [form (bindings 0) tst (bindings 1)]
    `(let [temp# ~tst]
       (when temp#
         (let [~form temp#]
           ~@body)))))

這里返回的 list 使用 ` (backtick)進行了修飾,這是 syntax-quote,它與 quote ' 類似,只不過在阻止表達式求值的同時,支持以下兩個功能:

  1. 表達式里的所有 symbol 會在當前 namespace 中進行 resolve,返回 fully-qualified symbol
  2. 允許通過 ~ (unquote) 或 ~@ (slicing-unquote) 阻止部分表達式的 quote,以達到對它們求值的效果

可以通過下面一個例子來了解它們之間的區別:

(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]

這里尤其要注意理解嵌套 syntax-quote 的情況,為了得到正確的值,需要 unquote 相應的次數(上例中的第四個println),這在 macro-writing macro 中十分有用,后面會介紹的。

最后需要注意一點,在整個 Clojure 程序生命周期中, (syntax-)quote , (slicing-)unquote 是 Reader 來解析的,詳見編譯器工作流程。可以通過 read-string 來驗證:

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

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,由淺入深介紹寫宏的技巧與注意事項。

(do-primes [n start end]
  body)

它會遍歷 [start, end) 范圍內的素數,對于具體素數 n,執行 body 里面的內容。

do-primes

(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))))))

上面的實現比較直接,首先定義了兩個輔助函數,然后通過返回由 loop 構成的 code 來達到遍歷的效果。簡單測試下:

(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

達到預期。但是這么實現會有些問題,比如傳入的start end 不是固定的數字,而是一個函數,我們無法確定這個函數有無副作用,這就會導致重復執行多次 end,這顯然不是我們想要的效果,需要進行改造。

也許你會說,這個解決也很簡單,在進行 loop 之前,用一個 let 先把 end 的值算出來,這個確實能解決多次執行的問題,但是又引入另一個隱患: end 先于 start 執行 。這會不會產生不良后果,我們同樣無法預知,我們能做到的就是 盡量不用暴露宏的實現細節 ,盡量保證參數的求值順序。

(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)))))))

上面使用 gensym 機制來保證生產 symbol 的唯一性,保證宏的“衛生”( hygiene )。

(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))))))

only-once

通過上面的例子,我們也很容易的知道,gensym 是一種常用的技巧,所以我們完全有可能再進行一次抽象,構造 only-once 宏,來保證傳入的參數按照順序只執行一次。

(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))

;; 展開為

only-once 的核心思想是用 gensym 來替換掉傳入的 symbol(即 names),為了達到這種效果,它首先定義出一組與參數數目相同的 gensyms(分別記為#s1 #s2),然后在第二層 let 為這些 gensyms 做 binding,value 也是用 gensym 生成的(分別記為#s3 #s4),這一層的 let 的返回值將內嵌到 do-primes3 內:

(let [#s1 #s3 #s2 #s4]
  '(let [#s3 start #s3 end]
    (let [start #s1 end #s2]
      ~@body))

第三層 let 的結果作為 code 內嵌到調用 do-primes3 處,即最終的展開式:

(let [#s3 2 #s4 (+ 10 (rand-int 30))]
  (loop [n #s3]
    (when (< n #s4)
      (when (prime? n) (println n))
      (recur (next-prime (inc n))))))

根據上述分析過程,可以看到第四層嵌套的 let 先于第三層嵌套的 let 執行,第四層 let 做 binding 時,是把 #s1 對應的 #s3 賦值給 start,#s2 對應的 #s4 賦值給 end,這樣就成功的實現了 symbol 的替換。

only-once 屬于 macro-writing macro 的范疇,就是說它使用的對象本身還是個宏,所以有一定的難度,主要是分清不同表達式的求值環境,這一點對于理解指一類宏非常核心。不過這一類宏大家應該很少能見到,更多的時候是使用輔助函數來分解復雜宏。比如我們這里就使用了兩個輔助函數 prime? next-prime 來簡化宏的寫法。

def-watched

作為實戰的最后一個例子,著重介紹 code 與 data 的聯系與區別。

def-watched 它可以定義一個受監控的 var,在 root binding 改變時打印前后的值

(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

為了簡化 def-watched,可能會想把里面的函數提取出來:

(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)))

這時的效果和上面是一樣的,請注意這里是把 gen-watch-fn 實現為了函數,如果用宏的話,會有什么效果呢?

;; 將 gen-watch-fn 改為 defmacro,其他均不變 
;; (def-watched2 bar 1) 展開后變成了
(do
  (def bar 1)
  (add-watch
    #'bar
    :re-bind
    #function[user/gen-watch-fn/fn--17288]))

這直接會報 No matching ctor found for class #function[user/gen-watch-fn/fn–17288],由于 gen-watch-fn 是宏,它返回的是 code,而不是一般的 data,這也就是問題發生的緣由。

總結

本文一開始就明確指出 Lisp 中 code as data 的特性,這一點表面看似比較好理解,但是放到具體環境中時,就十分容易搞錯。

實戰部分給出了一些宏的管用技巧,介紹了相比來說難以理解的 macro-writing marco,理解它有一定難度,但也不是無法入手,理清 quote unquote 的作用機制,并且在 REPL 中不斷調試,肯定能有所收獲。

雖說不推薦使用宏解決問題,但是在有些時候,一個宏能省掉好幾十行代碼,而且能使邏輯更清晰,這時候也就不用“吝嗇”了。

最后,希望經過這兩篇文章的介紹,大家能對宏有更深的理解。Happy Lisp!

 

來自:http://liujiacai.net/blog/2017/10/01/macro-in-action/

 

 本文由用戶 YoungFrasie 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!