iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 28
0
Software Development

擁抱 Clojure系列 第 28

[第 28 天] 擁抱 Clojure:巨集

巨集

授人以 Fortran 得 Fortran,
授人以 Lisp 得所喜之語言。

— 蓋伊·史提爾二世《The Seasoned Schemer》

LISP 程式語言家族的編寫方式與編譯器內部使用的語法樹相似,這種特色被稱爲同像性 (Homoiconicity)。依據這項特色,產生了有別於其他語言的魔法,可以編寫程式來改變程式,不僅可以創造自己的語言,還可以擴充程式語言,彌補程式語言的不足。

這個神奇的魔法就是巨集 (Macro),透過這篇文章你將學會如何撰寫巨集。

什麼是巨集

基礎認識

Clojure 的資料結構之一:列表,與 Clojure 程式非常像。我們可以透過 list 創建列表

(list '+ 1 2)
;; => (+ 1 2)
(class (list '+ 1 2))
;; => clojure.lang.PersistentList

範例中的運算式產生了一個列表,內容爲符號 + 以及 1 與 2。與運算式 (+ 1 2) 看起來一模一樣,差別是使用 list 函式產生的資料。

前面章節介紹過的讀取器 (Reader) 就是將文字轉換成列表後,再做進一步的處理。我們可以使用 read-string 模擬讀取器,將文字轉換成列表:

(read-string "(+ 1 2)")
;; => (+ 1 2)
(class (read-string "(+ 1 2)"))
;; => clojure.lang.PersistentList

read-string 函式讀取文字,轉換成列表。透過 eval 函式,可以把列表當作程式執行,求得執行後的結果:

(eval (list + 1 2))
;; => 3

也可以使用單引號 (') 於列表之前,Clojure 會將列表原封不動返回:

'(+ 1 2)
;; => (+ 1 2)
(eval '(+ 1 2))
;; => 3

引用 (Quote)

單引號 (') 被稱爲引用 (Quote),它之後的列表會被 Clojure 忽略而照實返回,如果將它交給 eval 函式則會被求值。列表若是沒有在前面加上單引號,Clojure 便會將它視爲函式呼叫,呼叫第一個符號代表的函式,如果找不到函式便會報錯:

(a 1 2)
;; => CompilerException java.lang.RuntimeException: Unable to resolve symbol: a in this context
'(a 1 2)
;; => (a 1 2)
(eval '(a 1 2))
;; => CompilerException java.lang.RuntimeException: Unable to resolve symbol: a in this context

使用引用 (Quote) 讓 Clojure 不對列表求值,將程式變成資料,再丟給 eval 函式把資料當作可執行的函式,執行之後取得返回值。

於是藉由列表與引用,可以產生程式的範本,其中包含了之後將被執行的程式碼,這些範本只是一般的資料,可以任意地拼接或修改成任意列表,最終再丟給 eval 或編譯器編譯後執行:

(cons '+ '(1 2))
;; => (+ 1 2)
(eval (cons '+ '(1 2 3)))
;; => 6

現在你應該更了解在 Clojure 中,程式可以是資料,資料也可以是程式的意思了。前面範例使用 eval 則讓我們了解 Clojure 如何將文字變成列表,再將列表變成程式執行的概念模型。

產生程式範本

現在可以依據需要,產生列表給 eval 執行後取得結果,現在讓我們把產生列表的功能寫成函式,以便重複使用。

(defn report [form]
  (eval (cons 'println (list "report form:" form))))

(report (= 2 (+ 2 3)))
;; => report form: false

原本我們預期的結果應該是:report form: (= 2 (+ 2 3)),結果卻不如預期,這是爲什麼呢?

原因在於 Clojure 中 (大部分程式語言也是一樣),在呼叫函式之前,程式語言會先把交給函式的參數計算求值完畢,函式再根據參數計算。

因此在我們的範例中 (= 2 (+ 2 3)) 便會先求值 (結果爲 false),結果便與我們想要的不一樣。因此如果要達到如此的效果,就要使用巨集。

另外,eval 函式雖然可以幫忙我們將列表轉成程式後求值,但是它無法處理當呼叫 eval 函式時的詞法語境 (Lexical scope),亦即當前可以使用的環境 (繫結或符號等等)。因此使用範圍非常侷限。

從以上範例我們使用 eval 模擬了編譯器將程式求值的行爲,我們也使用了 read-string 模擬了將文字轉換成列表的行爲,也了解到修改列表就可以改變程式的行爲。

而巨集便是可以修改程式、改變行爲的工具。

構建巨集

defmacro

建立函式使用 defn 或是 fn,而建立巨集則使用 defmacrodefmacro 的參數不會預先求值,會返回列表給呼叫巨集者,編譯器會轉成實際程式執行之:

(defmacro infix-add [form]
  (list (second form) (first form) (last form)))

(infix-add (2 + 3))
;; => 5

以上範例利用巨集來讀取以中置表示法寫成的形式,並求值之。我們可以使用 macroexpand 觀察巨集如何展開:

(macroexpand '(infix-add (2 + 3)))
;; => (+ 2 3)

語法引用

反引號 (`) 被稱爲語法引用 (Syntax quote),功能與單引號 (') 類似,差別在於反引號之後的符號會被改成加上命名空間後的全名 (Qualified name),避免衝突。

(def foo "foo")
;; => #'user/foo
'foo
;; => foo
`foo
;; => user/foo

以上範例分別使用引用以及語法引用,引用只返回符號,語法引用則會將符號加上命名空間。建議編寫巨集時使用語法引用,避免衝突問題,而且語法引用也可以與之後介紹的解引用搭配使用。

解引用

另一個語法引用 (~) 與引用 (') 的差別,在於語法引用中可以使用波浪號 (~),波浪號稱爲解引用 (Unquote)。將波浪號加在語法引用中的其中一個列表或符號之前,將會使這個符號或列表跳出引用環境,讓編譯器對符號或列表求值,再將它放回引用環境中,產出新的列表。

以下範例示範加上解引用之前與之後的差別:

`(+ 1 (* 2 3))
;; => (clojure.core/+ 1 (clojure.core/* 2 3))
`(+ 1 ~(* 2 3))
;; => (clojure.core/+ 1 6)

第一個範例中只在列表前加上語法引用,於是列表中的符號加上命名空間後,便返回了。而第二個範例中將解引用加在 (* 2 3) 之前,此運算式就會先被求值,再放回語法引用建立的列表之中。

現在我們把之前提過的 report 範例用巨集與語法引用改寫:

(defmacro report [form]
  `(println "report form:" '~form))
(report (= 2 (+ 2 3)))
;; => report form: (= 2 (+ 2 3))

看起來一切正常,但是出現了先前沒有出現的符號,究竟是什麼意思呢?先讓我們利用 REPL 看看它到底做了什麼事:

(def a 4)
`(1 2 3 '~a)
;; => (1 2 3 (quote 4))

它先對 a 求值,結果爲 4,再把它套用到 quote 之中。因此在我們的 report 範例中,'~ 符號會對 form 求值,form 的值爲 (= 2 (+ 2 3)),再使用 quote 抑制函式呼叫後放回語法引用產生的範本中。

再擴充 report 巨集,讓它除了可以顯示原始的形式之外,還可以對這個形式求值。因此會再次使用到解引用,以求得形式的值:

(defmacro report [form]
  `(println "report form:" '~form ", result:" ~form))

(report (= 3 (+ 2 1)))
;; => report form: (= 3 (+ 2 1)) , result: true

解引用拼接

除了解引用之外,還有一種特殊的解引用稱爲解引用拼接 (Unquote-splicing),使用 ~@ 符號來達到此功能。

解引用拼接 (Unquote-splicing) 會將之後的列表解開,再放入其他的列表之中:

(def a '(5 6 7 8))
`(1 2 3 4 ~a 9 10)
;; => (1 2 3 4 (5 6 7 8) 9 10)
`(1 2 3 4 ~@a 9 10)
;; => (1 2 3 4 5 6 7 8 9 10)

第一個範例中使用解引用,結果是列表中又有列表,而第二個範例用了解引用拼接之後,解消了原先的列表,將列表中的每個元素放入新的列表之中。

解引用拼接通常用在巨集接受不定個數的運算式,以下範例示範其使用方法:

(defmacro foo [& body]
  `(do-something ~@body))
(macroexpand-1 '(foo (do (println "Hello foo") 42)))
;; => (user/do-something (do (println "Hello foo") 42))

由於巨集中已經使用了列表,所以需要先將參數套用解引用拼接,再放入列表中。範例中使用的 macroexpand-1 功能與先前提及的 macroexpand 一樣,都是具有展開巨集的功能,而 macroexpand 展開的巨集之中若還有使用到其他巨集,則會繼續展開。

gensym

由於巨集具有範本化程式的功能,在巨集之中若有資料繫結,一不注意就會發生錯誤:

(defmacro bad-macro [& body]
  `(let [x :value]
     ~@body))
(bad-macro (println "bad macro"))
;; => CompilerException java.lang.RuntimeException: Can't let qualified name: user/x
(macroexpand-1 '(bad-macro (println "bad macro")))
;; => (clojure.core/let [user/x :value] (println "bad macro"))

以上巨集範例中使用到資料繫結,被編譯器發現錯誤,因爲沒有使用暫時的名字,而使用全名。若是使用該巨集的環境中已經有相同名稱的資料繫結,就有發生錯誤的可能。

Clojure 提供了 gensym 函式產生編造過的符號名稱,避免與其他符號名稱衝突的可能,以下是使用 gensym 修改過的版本:

(defmacro good-macro [& body]
  (let [x (gensym)]
    `(let [~x :value]
       ~@body)))

(good-macro (println "good macro"))
;; => good macro
(macroexpand-1 '(good-macro (println "good macro")))
;; => (clojure.core/let [G__10556 :value] (println "good macro"))

你也可以在語法引用之中,於符號之後加上井字號,功能與使用 gensym 相同:

(defmacro hygienic-macro [& body]
  `(let [x# :value]
     ~@body))

(hygienic-macro "hygienic macro")
;; => "hygienic macro"
(macroexpand-1 '(hygienic-macro "hygienic macro"))
;; => (clojure.core/let [x__10558__auto__ :value] "hygienic macro")

巨集可以做什麼

現在你手上已經有了建構巨集的工具,然而究竟巨集可以做到哪些事,以下提出幾項範例,希望給讀者一些靈感。

定義控制流程

Ruby 或 Perl 程式語言提供了與 if 相反的控制流程:unless,只有 unless 中的判斷式爲否,才會執行第一個分支,否則不執行:

(unless (= 1 2) "Math rules !")
;; => "Math rules !"

unless 可以利用 if 達成相同的行爲:

(if (not conditional) then)

因此 unless 巨集如下:

(defmacro unless [conditional & body]
  `(if (not ~conditional)
     (do ~@body)))

以上巨集使用語法引用建立範本,使用解引用對條件式求值再放回列表,再使用解引用拼接處理巨集剩餘的參數。

資源管理

你是否曾經在程式中開啓檔案,卻在結束時忘記將檔案關閉,導致記憶體資源浪費?建立一個在運算式最後自動關閉資源的巨集,是非常方便的:

(with-open [r (clojure.java.io/input-stream "tmpfile.txt")]
  (println "Do things with opened resource"))

範例如下:

(defmacro with-my-open [bindings & body]
  `(let ~(subvec bindings 0 2)
     (try
       ~@body
       (finally
         (. ~(bindings 0) close)))))

以上範例爲簡易版,Clojure 內建有 with-open 巨集,考慮更周全,請使用內建版本。

給讀者的忠告

由於巨集可以修改語法、改變列表結構,所以巨集的能力只受限於使用者的想像力。但是越是強大的工具,越要謹慎使用。以下有幾點建議:

  1. 可以用函式不要用巨集

    巨集除錯困難,可以用函式就不需要巨集,除非需要使用到延遲求值功能或建立自己的語法。

  2. 使用 macroexpandmacroexpand-1 以及 clojure.walk/macroexpand-all 除錯

    一旦巨集不如預期運作,使用 macroexpand 相關函式將巨集展開,觀察展開過的列表究竟何處發生問題。

  3. 參考別人的巨集

    要寫好程式除了了解語言的特性與語法之外,研讀別人的程式碼也是進步的方式之一。Clojure 內建許多巨集,可以在 REPL 中使用 source 函式列出巨集的原始碼,學習其中的思考方式。

回顧

透過本篇文章,你知道了 Clojure 中的運算式都是由列表構成,還知道了在編譯之前修改列表,就可以改變編譯的結果。了解到藉由定義巨集可以達成自定語法,也了解了建構巨集的相關工具,還知道了巨集可以如何運用,最後你知道了撰寫巨集應該注意的地方。

還不賴吧?今天就先到這裡,下一篇文章再見囉!

(本篇文章同步刊登於 GitHub,歡迎在文章下方留言或發送 PR 給予建議與指教)


上一篇
[第 27 天] 擁抱 Clojure:並行與併發(四)
下一篇
[第 29 天] 擁抱 Clojure:測試
系列文
擁抱 Clojure30

尚未有邦友留言

立即登入留言