第十章:宏

Lisp 程式碼是用 Lisp 物件的列表來表示。2.3 節宣稱這讓 Lisp 可以寫出可自己寫程式的程式。本章將示範如何跨越表達式與程式碼的界線。

10.1 求值 (Eval)

如何產生表達式是很直觀的:呼叫 list 即可。我們沒有考慮到的是,如何使 Lisp 將列表視爲程式碼。這之間缺少的一環是函數 eval ,它接受一個表達式,將其求值,然後返回它的值:

> (eval '(+ 1 2 3))
6
> (eval '(format t "Hello"))
Hello
NIL

如果這看起很熟悉的話,這是應該的。這就是我們一直交談的那個 eval 。下面這個函數實現了與頂層非常相似的東西:

(defun our-toplevel ()
  (do ()
      (nil)
    (format t "~%> ")
    (print (eval (read)))))

也是因爲這個原因,頂層也稱爲讀取─求值─打印迴圈 (read-eval-print loop, REPL)。

呼叫 eval 是跨越程式碼與列表界限的一種方法。但它不是一個好方法:

  1. 它的效率低下: eval 處理的是原始列表 (raw list),要不當下編譯它,或是用直譯器求值。兩種方法都比執行編譯過的程式來得慢許多。
  2. 表達式在沒有詞法語境 (lexical context)的情況下被求值。舉例來說,如果你在一個 let 裡呼叫 eval ,傳給 eval 的表達式將無法參照由 let 所設置的變數。

有許多更好的方法 (下一節敘述)來利用產生程式碼的這個可能性。當然 eval 也是有用的,唯一合法的用途像是在頂層迴圈使用它。

對於程式設計師來說, eval 的主要價值大概是作爲 Lisp 的概念模型。我們可以想像 Lisp 是由一個長的 cond 表達式定義而成:

(defun eval (expr env)
  (cond ...
        ((eql (car expr) 'quote) (cdr expr))
        ...
        (t (apply (symbol-function (car expr))
                  (mapcar #'(lambda (x)
                              (eval x env))
                          (cdr expr))))))

許多表達式由預設子句 (default clause)來處理,預設子句獲得 car 所參照的函數,將 cdr 所有的參數求值,並返回將前者應用至後者的結果。 [1]

但是像 (quote x) 那樣的句子就不能用這樣的方式來處理,因爲 quote 就是爲了防止它的參數被求值而存在的。所以我們需要給 quote 寫一個特別的子句。這也是爲什麼本質上將其稱爲特殊運算子 (special operator): 一個需要被實現爲 eval 的一個特殊情況的運算子。

函數 coercecompile 提供了一個類似的橋樑,讓你把列表轉成程式碼。你可以 coerce 一個 lambda 表達式,使其成爲函數,

> (coerce '(lambda (x) x) 'function)
#<Interpreted-Function BF9D96>

而如果你將 nil 作爲第一個參數傳給 compile ,它會編譯作爲第二個參數傳入的 lambda 表達式。

> (compile nil '(lambda (x) (+ x 2)))
#<Compiled-Function BF55BE>
NIL
NIL

由於 coercecompile 可接受列表作爲參數,一個程式可以在動態執行時構造新函數。但與呼叫 eval 比起來,這不是一個從根本解決的辦法,並且需抱有同樣的疑慮來檢視這兩個函數。

函數 eval , coercecompile 的麻煩不是它們跨越了程式碼與列表之間的界限,而是它們在執行期做這件事。跨越界線的代價昂貴。大多數情況下,在編譯期做這件事是沒問題的,當你的程式執行時,幾乎不用成本。下一節會示範如何辦到這件事。

10.2 宏 (Macros)

寫出能寫程式的程式的最普遍方法是通過定義宏。是通過轉換 (transformation)而實現的運算子。你通過說明你一個呼叫應該要翻譯成什麼,來定義一個宏。這個翻譯稱爲宏展開(macro-expansion),宏展開由編譯器自動完成。所以宏所產生的程式碼,會變成程式的一個部分,就像你自己輸入的程式一樣。

宏通常透過呼叫 defmacro 來定義。一個 defmacro 看起來很像 defun 。但是與其定義一個函數呼叫應該產生的值,它定義了該怎麼翻譯出一個函數呼叫。舉例來說,一個將其參數設爲 nil 的宏可以定義成如下:

(defmacro nil! (x)
  (list 'setf x nil))

這定義了一個新的運算子,稱爲 nil! ,它接受一個參數。一個這樣形式 (nil! a) 的呼叫,會在求值或編譯前,被翻譯成 (setf a nil) 。所以如果我們輸入 (nil! x) 至頂層,

> (nil! x)
NIL
> x
NIL

完全等同於輸入表達式 (setf x nil)

要測試一個函數,我們呼叫它,但要測試一個宏,我們看它的展開式 (expansion)。

函數 macroexpand-1 接受一個宏呼叫,並產生它的展開式:

> (macroexpand-1 '(nil! x))
(SETF X NIL)
T

一個宏呼叫可以展開成另一個宏呼叫。當編譯器(或頂層)遇到一個宏呼叫時,它持續展開它,直到不可展開爲止。

理解宏的祕密是理解它們是如何被實現的。在檯面底下,它們只是轉換成表達式的函數。舉例來說,如果你傳入這個形式 (nil! a) 的表達式給這個函數

(lambda (expr)
  (apply #'(lambda (x) (list 'setf x nil))
         (cdr expr)))

它會返回 (setf a nil) 。當你使用 defmacro ,你定義一個類似這樣的函數。 macroexpand-1 全部所做的事情是,當它看到一個表達式的 car 是宏時,將表達式傳給對應的函數。

10.3 反引號 (Backquote)

反引號讀取宏 (read-macro)使得從模版 (templates)建構列表變得有可能。反引號廣泛使用在宏定義中。一個平常的引用是鍵盤上的右引號 (apostrophe),然而一個反引號是一個左引號。(譯註: open quote 左引號,closed quote 右引號)。它稱作“反引號”是因爲它看起來像是反過來的引號 (titled backwards)。

(譯註: 反引號是鍵盤左上方數字 1 左邊那個: ` ,而引號是 enter 左邊那個 ')

一個反引號單獨使用時,等於普通的引號:

> `(a b c)
(A B C)

和普通引號一樣,單一個反引號保護其參數被求值。

反引號的優點是,在一個反引號表達式裡,你可以使用 , (逗號)與 ,@ (comma-at)來重啓求值。如果你在反引號表達式裡,在某個東西前面加逗號,則它會被求值。所以我們可以使用反引號與逗號來建構列表模版:

> (setf a 1 b 2)
2
> `(a is ,a and b is ,b)
(A IS 1 AND B IS 2)

通過使用反引號取代呼叫 list ,我們可以寫出會產生出展開式的宏。舉例來說 nil! 可以定義爲:

(defmacro nil! (x)
  `(setf ,x nil))

,@ 與逗號相似,但將(本來應該是列表的)參數扒開。將列表的元素插入模版來取代列表。

> (setf lst '(a b c))
(A B C)
> `(lst is ,lst)
(LST IS (A B C))
> `(its elements are ,@lst)
(ITS ELEMENTS ARE A B C)

,@ 在宏裡很有用,舉例來說,在用剩餘參數表示程式碼主體的宏。假設我們想要一個 while 宏,只要初始測試表達式爲真,對其主體求值:

> (let ((x 0))
    (while (< x 10)
       (princ x)
       (incf x)))
0123456789
NIL

我們可以通過使用一個剩餘參數,蒐集主體的表達式列表,來定義一個這樣的宏,接著使用 comma-at 來扒開這個列表放至展開式裡:

(defmacro while (test &rest body)
  `(do ()
       ((not ,test))
     ,@body))

10.4 範例:快速排序法(Example: Quicksort)

圖 10.1 包含了重度依賴宏的一個範例函數 ── 一個使用快速排序演算法 λ 來排序向量的函數。這個函數的工作方式如下:

(defun quicksort (vec l r)
  (let ((i l)
        (j r)
        (p (svref vec (round (+ l r) 2))))    ; 1
    (while (<= i j)                           ; 2
      (while (< (svref vec i) p) (incf i))
      (while (> (svref vec j) p) (decf j))
      (when (<= i j)
        (rotatef (svref vec i) (svref vec j))
        (incf i)
        (decf j)))
    (if (>= (- j l) 1) (quicksort vec l j))    ; 3
    (if (>= (- r i) 1) (quicksort vec i r)))
  vec)

圖 10.1 快速排序。

  1. 開始你通過選擇某個元素作爲主鍵 ( pivot )。許多實現選擇要被排序的序列中間元素。
  2. 接著你分割(partition)向量,持續交換元素,直到所有主鍵左邊的元素小於主鍵,右邊的元素大於主鍵。
  3. 最後,如果左右分割之一有兩個或更多元素時,你遞迴地應用這個算法至向量的那些分割上。

每一次遞迴時,分割越變越小,直到向量完整排序爲止。

在圖 10.1 的實現裡,接受一個向量以及標記欲排序範圍的兩個整數。這個範圍當下的中間元素被選爲主鍵 ( p )。接著從左右兩端開始產生分割,並將左邊太大或右邊太小的元素交換過來。(將兩個參數傳給 rotatef 函數,交換它們的值。)最後,如果一個分割含有多個元素時,用同樣的流程來排序它們。

除了我們前一節定義的 while 宏之外,圖 10.1 也用了內建的 when , incf , decf 以及 rotatef 宏。使用這些宏使程式看起來更加簡潔與清晰。

10.5 設計宏 (Macro Design)

撰寫宏是一種獨特的程式設計,它有著獨一無二的目標與問題。能夠改變編譯器所看到的東西,就像是能夠重寫它一樣。所以當你開始撰寫宏時,你需要像語言設計者一樣思考。

本節快速給出宏所牽涉問題的概要,以及解決它們的技巧。作爲一個例子,我們會定義一個稱爲 ntimes 的宏,它接受一個數字 n 並對其主體求值 n 次。

> (ntimes 10
    (princ "."))
..........
NIL

下面是一個不正確的 ntimes 定義,說明了宏設計中的某些議題:

(defmacro ntimes (n &rest body)
  `(do ((x 0 (+ x 1)))
       ((>= x ,n))
     ,@body))

這個定義第一眼看起來可能沒問題。在上面這個情況,它會如預期的工作。但實際上它在兩個方面壞掉了。

一個宏設計者需要考慮的問題之一是,無意的變數捕捉 (variable capture)。這發生在當一個在宏展開式裡用到的變數,恰巧與展開式即將插入的語境裡,有使用同樣名字作爲變數的情況。不正確的 ntimes 定義創造了一個變數 x 。所以如果這個宏在已經有 x 作爲名字的地方被呼叫時,它可能無法做到我們所預期的:

> (let ((x 10))
    (ntimes 5
       (setf x (+ x 1)))
    x)
10

如果 ntimes 如我們預期般的執行,這個表達式應該會對 x 遞增五次,最後返回 15 。但因爲宏展開剛好使用 x 作爲迭代變數, setf 表達式遞增那個 x ,而不是我們要遞增的那個。一旦宏呼叫被展開,前述的展開式變成:

> (let ((x 10))
    (do ((x 0 (+ x 1)))
        ((>= x 5))
      (setf x (+ x 1)))
    x)

最普遍的解法是不要使用任何可能會被捕捉的一般符號。取而代之的我們使用 gensym (8.4 小節)。因爲 read 函數 intern 每個它見到的符號,所以在一個程式裡,沒有可能會有任何符號會 eql gensym。如果我們使用 gensym 而不是 x 來重寫 ntimes 的定義,至少對於變數捕捉來說,它是安全的:

(defmacro ntimes (n &rest body)
  (let ((g (gensym)))
    `(do ((,g 0 (+ ,g 1)))
         ((>= ,g ,n))
       ,@body)))

但這個宏在另一問題上仍有疑慮: 多重求值 (multiple evaluation)。因爲第一個參數被直接插入 do 表達式,它會在每次迭代時被求值。當第一個參數是有副作用的表達式,這個錯誤非常清楚地表現出來:

> (let ((v 10))
    (ntimes (setf v (- v 1))
      (princ ".")))
.....
NIL

由於 v 一開始是 10 ,而 setf 返回其第二個參數的值,應該印出九個句點。實際上它只印出五個。

如果我們看看宏呼叫所展開的表達式,就可以知道爲什麼:

> (let ((v 10))
    (do ((#:g1 0 (+ #:g1 1)))
        ((>= #:g1 (setf v (- v 1))))
      (princ ".")))

每次迭代我們不是把迭代變數 (gensym 通常印出前面有 #: 的符號)與 9 比較,而是與每次求值時會遞減的表達式比較。這如同每次我們查看地平線時,地平線都越來越近。

避免非預期的多重求值的方法是設置一個變數,在任何迭代前將其設爲有疑惑的那個表達式。這通常牽扯到另一個 gensym:

(defmacro ntimes (n &rest body)
  (let ((g (gensym))
        (h (gensym)))
    `(let ((,h ,n))
       (do ((,g 0 (+ ,g 1)))
           ((>= ,g ,h))
         ,@body))))

終於,這是一個 ntimes 的正確定義。

非預期的變數捕捉與多重求值是折磨宏的主要問題,但不只有這些問題而已。有經驗後,要避免這樣的錯誤與避免更熟悉的錯誤一樣簡單,比如除以零的錯誤。

你的 Common Lisp 實現是一個學習更多有關宏的好地方。藉由呼叫展開至內建宏,你可以理解它們是怎麼寫的。下面是大多數實現對於一個 cond 表達式會產生的展開式:

> (pprint (macroexpand-1 '(cond (a b)
                                (c d e)
                                (t f))))
(IF A
    B
    (IF C
        (PROGN D E)
        F))

函數 pprint 印出像程式碼一樣縮排的表達式,這在檢視宏展開式時特別有用。

10.6 通用化參照 (Generalized Reference)

由於一個宏呼叫可以直接在它出現的地方展開成程式碼,任何展開爲 setf 表達式的宏呼叫都可以作爲 setf 表達式的第一個參數。 舉例來說,如果我們定義一個 car 的同義詞,

(defmacro cah (lst) `(car ,lst))

然後因爲一個 car 呼叫可以是 setf 的第一個參數,而 cah 一樣可以:

> (let ((x (list 'a 'b 'c)))
    (setf (cah x) 44)
    x)
(44 B C)

撰寫一個展開成一個 setf 表達式的宏是另一個問題,是一個比原先看起來更爲困難的問題。看起來也許你可以這樣實現 incf ,只要

(defmacro incf (x &optional (y 1)) ; wrong
  `(setf ,x (+ ,x ,y)))

但這是行不通的。這兩個表達式不相等:

(setf (car (push 1 lst)) (1+ (car (push 1 lst))))

(incf (car (push 1 lst)))

如果 lstnil 的話,第二個表達式會設成 (2) ,但第一個表達式會設成 (1 2)

Common Lisp 提供了 define-modify-macro 作爲寫出對於 setf 限制類別的宏的一種方法 它接受三個參數: 宏的名字,額外的參數 (隱含第一個參數 place),以及產生出 place 新數值的函數名。所以我們可以將 incf 定義爲

(define-modify-macro our-incf (&optional (y 1)) +)

另一版將元素推至列表尾端的 push 可寫成:

(define-modify-macro append1f (val)
  (lambda (lst val) (append lst (list val))))

後者會如下工作:

> (let ((lst '(a b c)))
    (append1f lst 'd)
    lst)
(A B C D)

順道一提, pushpop 都不能定義爲 modify-macros,前者因爲 place 不是其第一個參數,而後者因爲其返回值不是更改後的物件。

10.7 範例:實用的宏函數 (Example: Macro Utilities)

6.4 節介紹了實用函數 (utility)的概念,一種像是構造 Lisp 的通用運算子。我們可以使用宏來定義不能寫作函數的實用函數。我們已經見過幾個例子: nil! , ntimes 以及 while ,全部都需要寫成宏,因爲它們全都需要某種控制參數求值的方法。本節給出更多你可以使用宏寫出的多種實用函數。圖 10.2 挑選了幾個實踐中證實值得寫的實用函數。

(defmacro for (var start stop &body body)
  (let ((gstop (gensym)))
    `(do ((,var ,start (1+ ,var))
          (,gstop ,stop))
         ((> ,var ,gstop))
       ,@body)))

(defmacro in (obj &rest choices)
  (let ((insym (gensym)))
    `(let ((,insym ,obj))
       (or ,@(mapcar #'(lambda (c) `(eql ,insym ,c))
                     choices)))))

(defmacro random-choice (&rest exprs)
  `(case (random ,(length exprs))
     ,@(let ((key -1))
         (mapcar #'(lambda (expr)
                     `(,(incf key) ,expr))
                 exprs))))

(defmacro avg (&rest args)
  `(/ (+ ,@args) ,(length args)))

(defmacro with-gensyms (syms &body body)
  `(let ,(mapcar #'(lambda (s)
                     `(,s (gensym)))
                 syms)
     ,@body))

(defmacro aif (test then &optional else)
  `(let ((it ,test))
     (if it ,then ,else)))

圖 10.2: 實用宏函數

第一個 for ,設計上與 while 相似 (164 頁,譯註: 10.3 節)。它是給需要使用一個綁定至一個值的範圍的新變數來對主體求值的迴圈:

> (for x 1 8
          (princ x))
12345678
NIL

這比寫出等效的 do 來得省事,

(do ((x 1 (+ x 1)))
    ((> x 8))
  (princ x))

這非常接近實際的展開式:

(do ((x 1 (1+ x))
     (#:g1 8))
    ((> x #:g1))
  (princ x))

宏需要引入一個額外的變數來持有標記範圍 (range)結束的值。 上面在例子裡的 8 也可是個函數呼叫,這樣我們就不需要求值好幾次。額外的變數需要是一個 gensym ,爲了避免非預期的變數捕捉。

圖 10.2 的第二個宏 in ,若其第一個參數 eql 任何自己其他的參數時,返回真。表達式我們可以寫成:

(in (car expr) '+ '- '*)

我們可以改寫成:

(let ((op (car expr)))
  (or (eql op '+)
      (eql op '-)
      (eql op '*)))

確實,第一個表達式展開後像是第二個,除了變數 op 被一個 gensym 取代了。

下一個例子 random-choice ,隨機選取一個參數求值。在 74 頁 (譯註: 第 4 章的圖 4.6)我們需要隨機在兩者之間選擇。 random-choice 宏實現了通用的解法。一個像是這樣的呼叫:

(random-choice (turn-left) (turn-right))

會被展開爲:

(case (random 2)
  (0 (turn-left))
  (1 (turn-right)))

下一個宏 with-gensyms 主要預期用在宏主體裡。它不尋常,特別是在特定應用中的宏,需要 gensym 幾個變數。有了這個宏,與其

(let ((x (gensym)) (y (gensym)) (z (gensym)))
        ...)

我們可以寫成

(with-gensyms (x y z)
        ...)

到目前爲止,圖 10.2 定義的宏,沒有一個可以定義成函數。作爲一個規則,寫成宏是因爲你不能將它寫成函數。但這個規則有幾個例外。有時候你或許想要定義一個運算子來作爲宏,好讓它在編譯期完成它的工作。宏 avg 返回其參數的平均值,

> (avg 2 4 8)
14/3

是一個這種例子的宏。我們可以將 avg 寫成函數,

(defun avg (&rest args)
  (/ (apply #'+ args) (length args)))

但它會需要在執行期找出參數的數量。只要我們願意放棄應用 avg ,爲什麼不在編譯期呼叫 length 呢?

圖 10.2 的最後一個宏是 aif ,它在此作爲一個故意變數捕捉的例子。它讓我們可以使用變數 it 來參照到一個條件式裡的測試參數所返回的值。也就是說,與其寫成

(let ((val (calculate-something)))
  (if val
      (1+ val)
      0))

我們可以寫成

(aif (calculate-something)
     (1+ it)
     0)

小心使用 ( Use judiciously),預期的變數捕捉可以是一個無價的技巧。Common Lisp 本身在多處使用它: 舉例來說 next-method-pcall-next-method 皆依賴於變數捕捉。

像這些宏明確示範了爲何要撰寫替你寫程式的程式。一旦你定義了 for ,你就不需要寫整個 do 表達式。值得寫一個宏只爲了節省打字嗎?非常值得。節省打字是程式設計的全部;一個編譯器的目的便是替你省下使用機械語言輸入程式的時間。而宏允許你將同樣的優點帶到特定的應用裡,就像高階語言帶給程式語言一般。通過審慎的使用宏,你也許可以使你的程式比起原來大幅度地精簡,並使程式更顯著地容易閱讀、撰寫及維護。

如果仍對此懷疑,考慮看看如果你沒有使用任何內建宏時,程式看起來會是怎麼樣。所有宏產生的展開式,你會需要用手產生。你也可以將這個問題用在另一方面。當你在撰寫一個程式時,捫心自問,我需要撰寫宏展開式嗎?如果是的話,宏所產生的展開式就是你需要寫的東西。

10.8 源自 Lisp (On Lisp)

現在宏已經介紹過了,我們看過更多的 Lisp 是由超乎我們想像的 Lisp 寫成。許多不是函數的 Common Lisp 運算子是宏,而他們全部用 Lisp 寫成的。只有二十五個 Common Lisp 內建的運算子是特殊運算子。

John Foderaro 將 Lisp 稱爲“可程式的程式語言。” λ 通過撰寫你自己的函數與宏,你將 Lisp 變成任何你想要的語言。 (我們會在 17 章看到這個可能性的圖形化示範)無論你的程式適合何種形式,你確信你可以將 Lisp 塑造成適合它的語言。

宏是這個靈活性的主要成分之一。它們允許你將 Lisp 變得完全認不出來,但仍然用一種有原則且高效的方法來實作。在 Lisp 社區裡,宏是個越來越感興趣的主題。可以使用宏辦到驚人之事是很清楚的,但更確信的是宏背後還有更多需要被探索。如果你想的話,可以通過你來發現。Lisp 永遠將進化放在程式設計師手裡。這是它爲什麼存活的原因。

Chapter 10 總結 (Summary)

  1. 呼叫 eval 是讓 Lisp 將列表視爲程式碼的一種方法,但這是不必要而且效率低落的。
  2. 你通過敘說一個呼叫會展開成什麼來定義一個宏。檯面底下,宏只是返回表達式的函數。
  3. 一個使用反引號定義的主體看起來像它會產生出的展開式 (expansion)。
  4. 宏設計者需要注意變數捕捉及多重求值。宏可以通過漂亮印出 (pretty-printing)來測試它們的展開式。
  5. 多重求值是大多數展開成 setf 表達式的問題。
  6. 宏比函數來得靈活,可以用來定義許多實用函數。你甚至可以使用變數捕捉來獲得好處。
  7. Lisp 存活的原因是它將進化交給程式設計師的雙手。宏是使其可能的部分原因之一。

Chapter 10 練習 (Exercises)

  1. 如果 xayb 以及 z(c d) ,寫出反引用表達式僅包含產生下列結果之一的變數:
(a) ((C D) A Z)

(b) (X B C D)

(c) ((C D A) Z)
  1. 使用 cond 來定義 if
  2. 定義一個宏,接受一個數字 n ,伴隨著一個或多個表達式,並返回第 n 個表達式的值:
> (let ((n 2))
    (nth-expr n (/ 1 0) (+ 1 2) (/ 1 0)))
3
  1. 定義 ntimes (167 頁,譯註: 10.5 節)使其展開成一個 (區域)遞迴函數,而不是一個 do 表達式。
  2. 定義一個宏 n-of ,接受一個數字 n 與一個表達式,返回一個 n 個漸進值:
> (let ((i 0) (n 4))
    (n-of n (incf i)))
(1 2 3 4)
  1. 定義一個宏,接受一變數列表以及一個程式碼主體,並確保變數在程式碼主體被求值後恢復 (revert)到原本的數值。
  2. 下面這個 push 的定義哪裡錯誤?
(defmacro push (obj lst)
  `(setf ,lst (cons ,obj ,lst)))

舉出一個不會與實際 push 做一樣事情的函數呼叫例子。
  1. 定義一個將其參數翻倍的宏:
> (let ((x 1))
    (double x)
    x)
2

腳註

[1]要真的複製一個 Lisp 的話, eval 會需要接受第二個參數 (這裡的 env) 來表示詞法環境 (lexical enviroment)。這個模型的 eval 是不正確的,因爲它在對參數求值前就取出函數,然而 Common Lisp 故意沒有特別指出這兩個操作的順序。

讨论

comments powered by Disqus