學習 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*,捕獲主體返回的值(在本例中,只是連線的字串表示形式),關閉資料庫連線並返回主體的返回值。