跳轉到內容

學習 Clojure/宏

來自華夏公益教科書,開放的書籍,開放的世界

宏是函式,它們有效地允許我們建立自己的語法便利。對於許多 Lisp 程式設計師來說,宏是使 Lisp 成為 Lisp 的基本特性。

簡單的宏

[編輯 | 編輯原始碼]

如果你理解了 reader 和 evaluator,實際上關於宏的操作和建立就不需要再理解更多了,因為宏僅僅是一個以特殊方式呼叫的普通函式。當作為宏呼叫時,函式接收其引數求值,並返回一個表示式,該表示式將替換對宏的呼叫。一個非常簡單(且無用)的宏將是一個簡單地返回其引數的宏

(def pointless (fn [n] n))

傳遞給此宏的任何內容——列表、符號,任何內容——都將被原樣返回,然後在呼叫後求值。實際上,呼叫此宏毫無意義

(pointless (+ 3 5))   ; pointless returns the list (+ 3 5), which is then evaluated in its place
(+ 3 5)               ; may as well just write this instead

但正如我們上面定義的無用,它只是一個普通函式,而不是宏。為了使其成為宏,我們需要將鍵值對:macro true作為元資料附加到由def對映到無用的 Var。有許多方法可以做到這一點,但最常見的做法是使用提供的宏clojure/defmacro簡單地定義函式作為宏

(defmacro pointless [n] n)   ; define a macro pointless that takes one parameter and simply returns it

執行某些操作的宏

[編輯 | 編輯原始碼]

一個真正有用的宏通常會返回一個語法引用的列表表示式。例如,考慮當 DEBUG 標誌開啟時執行某些操作,而標誌關閉時不執行任何操作的情況。首先定義標誌

(def DEBUG true)

現在,我們希望在它為真時執行某些操作,而在它們不為真時不執行。如果我們定義一個函式

(defn on-debug-fn [& args]
  (when DEBUG
    (eval `(do ~@args))))    ; Done this way to expand the list of args.

那麼 (on-debug-fn "Debug") 確實僅在 DEBUG 為真時返回 "Debug"。但是,(on-debug-fn (println "Debug")) 始終列印 "Debug"。這是因為,對於函式來說,引數總是被求值的,並且它帶有一個副作用:列印 "Debug"。

相反,我們需要的是一個宏。然後,就可以在不求值傳遞給它的形式的情況下檢查 DEBUG 標誌。

(defmacro on-debug [& body]
  `(when DEBUG
     (do ~@body)))

所以,讓我們看看它做了什麼

(macroexpand-1 '(on-debug (println "Debug"))) => (clojure.core/when user/DEBUG (do (println "Debug")))

(macroexpand-1 ...) 是一個函式,它顯示給定宏擴充套件成什麼。因此,它檢查 user/DEBUG 的值,並且僅在 debug 不為 false 時才求值主體。仔細觀察

(macroexpand
  '(on-debug
     (println "Debug"))) => (if* user/DEBUG
                               (do
                                 (do
                                   (println "Debug"))))

(macroexpand ...) 是一個函式,它擴充套件傳遞給它的形式中的所有宏。這表明 (when ...) 實際上是一個宏,它擴充套件成使用 (if* ...) 和 (do ...) 塊進行檢查,從而巧妙地使我們的 (do ...) 塊變得多餘。

所以宏的最終版本是

(defmacro on-debug [& body]
  `(when DEBUG
    ~@body))

現在,如果我們想要多個除錯級別,其中 1 是基本資訊,3 是痛苦的垃圾資訊呢?

(defmacro on-debug-level [level & body]
  `(when (and DEBUG
           (<= ~level DEBUG))
      ~@body))

level 字首有一個 unquote 符號,因此它不會被視為屬於名稱空間。DEBUG 沒有字首 unquote 符號,因為我們希望能夠更改除錯級別,而無需重新評估包含 (on-debug-level ...) 表單的每個函式。

相反,如果我們在宏中使用了 ~DEBUG,那麼當使用 (on-debug-level ...) 的宏被求值時,~DEBUG 將被替換為 user/DEBUG 的值。這意味著,如果除錯級別後來被更改,則所有這些宏也必須被重新評估,以便將新值放入條件中。

在 REPL 中測試,(on-debug-level 2 (println "Debug")) 確實在除錯級別為 2 時列印 "Debug",而在除錯級別為 1 時什麼也不做。當然,這可以在函式中使用,以便有一個單一的方法來列印除錯字串。

(defn debug-println [level st]
   (on-debug-level level
                   (println st)))

宏作為控制結構

[編輯 | 編輯原始碼]

此宏假設一個函式 (get-connection),它開啟並返回與資料庫的連線。它提供了一個典型的 Lisp with- 語法,其中它將 *conn* 繫結到連線,執行宏的主體,關閉連線並返回主體的返回值。

 (defonce *conn* nil)

這將繫結一個 var,*conn*,它將在宏的主體中使用以引用資料庫連線。

(defmacro with-connection [& body]
  `(binding [*conn* (get-connection)]
     (let [ret# (do ~@body)]
       (.close *conn*)
       ret#)))

逐步執行此操作

(defmacro with-connection [& body] ...)

這使用可變引數語法來獲取引數作為列表。

 `(binding ...

反引號運算子表示此表單中的所有內容都應該被引用,而不是求值(除非以 unquote 運算子 (~) 為字首)。因此,binding 變成 clojure.core/binding,並且與之相關的所有內容都以列表的形式構建,而不是作為一系列函式呼叫。

 `(binding [*conn* (get-connection)]

這將 var *conn* 繫結起來,以便在此表單中,它具有 (get-connection) 返回的值。換句話說,在 (with-connection ...) 表單內的操作可以將 *conn* 視為資料庫連線。

請注意,函式 (get-connection) 在宏實際使用之前不會被求值。這是因為反引號在 (binding ...) 之前。另一方面,如果我們希望在宏被定義時求值函式,則將使用 unquote (~) 運算子

`(binding [*conn* ~(get-connection)]

這將呼叫 (get-connection) 一次,在宏被定義時,所有對該宏的未來使用將使用 (get-connection) 最初返回的值。

    (let [ret# (do ~@body)]

此語句有點複雜。ret# 建立一個 gensymmed 名稱;一個唯一的名稱,確保如果符號 ret 在 (with-connection ...) 表單的主體中使用,它不會與宏定義中使用的符號衝突。

語句的後半部分解包主體並將其傳遞給 do。傳遞給宏的可變引數(如上面的 [& body])作為列表給出。~@(unquote-splicing)運算子用列表中包含的值替換列表。

如果沒有 ~@ 運算子,語句將類似於以下內容

`(do (list 1 2 3)) => (do (clojure.core/list 1 2 3))

使用 ~@ 運算子,列表將被列表中的值替換,給出

`(do ~@(list 1 2 3)) => (do 1 2 3)

因此語句

    (let [ret# (do ~@body)]

將名稱 ret# 繫結到透過求值主體返回的值。這樣做是為了返回主體表單的值,而不是關閉資料庫返回的值。

最後

      (.close *conn*)
      ret#)))

呼叫連線的 close 方法並返回主體的返回值。總而言之,這意味著

(with-connection
 (str *conn*))

開啟資料庫連線,將其繫結到 *conn*,捕獲主體返回的值(在本例中,只是連線的字串表示形式),關閉資料庫連線並返回主體的返回值。

Previous page
構建 Jar 包
學習 Clojure Next page
併發程式設計
華夏公益教科書