第 7 章 宏

2018-02-24 15:54 更新

第 7 章 宏

Lisp 中,宏的特性讓你能用變換的方式定義操作符。宏定義在本質上,是能生成 Lisp 代碼的函數(shù) -- 一個能寫程序的程序。這一小小開端引發(fā)了巨大的可能性,同時也伴隨著難以預料的風險。

第 7-10 章將帶你走入宏的世界。本章會解釋宏如何工作,介紹編寫和調試它們的技術,然后分析一些宏風格中存在的問題。

7.1 宏是如何工作的

由于我們可以調用宏并得到它的返回值,因此宏往往被人們和函數(shù)聯(lián)系在一起。宏定義有時和函數(shù)定義相似,而且不嚴謹?shù)卣f,被人們稱為 "內置函數(shù)" 的?do?其實就是一個宏。但如果把兩者過于混為一談,就會造成很多困惑。宏和常規(guī)函數(shù)的工作方式截然不同,并且只有知道宏為何不同,以及怎樣不同, 才是用好它們的關鍵。一個函數(shù)只產(chǎn)生結果,而宏卻產(chǎn)生表達式。當它被求值時,才會產(chǎn)生結果。

要入門,最好的辦法就是直接看個例子。假設我們想要寫一個宏?nil!,它把實參設置為?nil。讓(nil! x)?和?(setq x nil)?的效果一樣。我們完成這個功能的方法是:把?nil!?定義成宏,讓它來把前一種形式的實例變成后一種形式的實例:

> (defmacro nil! (var)
  (list 'setq var nil))
NIL!

用漢語轉述的話,這個定義相當于告訴 Lisp: "無論何時,只要看到形如?(nil!)?的表達式,請在求值之前先把它轉化成?(setq nil)?的形式。"

宏產(chǎn)生的表達式將在調用宏的位置求值。宏調用是一個列表,列表的第一個元素是宏的名稱。當我們把宏調用?(nil! x)?輸入到?toplevel?的時候發(fā)生了什么? Lisp 首先會發(fā)覺?nil!?是個宏的名字,然后:

  1. 按照上述定義的要求構造表達式,接著,

  2. 在調用宏的地方求值該表達式。

構造新表達式的那一步被稱為宏展開(macro expansion)。Lisp 查找?nil!?的定義,其定義展示了如何為宏調用構建一個即將取代它的表達式。和函數(shù)一樣,nil!?的定義也應用到了宏調用傳給它的表達式參數(shù)上。

它返回一個三元素列表,這三個元素分別是:?setq、作為參數(shù)傳遞給宏的那個表達式,以及?nil。在本例中,nil!?的參數(shù)是?x?,宏展開式是?(setq x nil)。

宏展開之后是第二步:求值(evaluation)。Lisp 求值宏展開式?(setq x nil)?時就好像是你原本就寫在那兒的一樣。求值并不總是立即發(fā)生在展開之后,不過在?toplevel?下的確是這樣的。一個發(fā)生在函數(shù)定義里的宏調用將在函數(shù)編譯時展開,但展開式 或者說它產(chǎn)生的對象代碼, 要等到函數(shù)被調用時才會求值。

如果把宏的展開和求值分清楚,你遇到的和宏有關的困難,或許有很多就能避免。當編寫宏的時候,要清楚哪些操作是在宏展開期進行的,而哪些操作是在求值期進行的,通常,這兩步操作的對象截然不同。宏展開步驟處理的是表達式,而求值步驟處理的則是它們的值。

有些宏的展開過程比?nil!?的情況更復雜。nil!?的展開式只是調用了一下內置的?special form,但往往一個宏的展開式可能會是另一個宏調用,就好像是一層套一層的俄羅斯套娃。在這種情況下,宏展開就會繼續(xù)抽絲剝繭直到獲得一個沒有宏的表達式。這一步驟中可以經(jīng)過任意多次的展開操作,一直到最終停下來。

盡管有許多語言也提供了某種形式的宏,但 Lisp 宏卻格外強大。在編譯 Lisp 文件時,解析器先讀取源代碼,然后將其輸出送給編譯器。這里有個天才的手筆:解析器的輸出由 Lisp 對象的列表組成。通過使用宏,我們可以操作這種處于解析器和編譯器之間的中間狀態(tài)的程序。如果必要的話,這些操作可以無所不包。一個生成展開式的宏擁有 Lisp 的全部威力,可任其驅馳。事實上,宏是貨真價實的 Lisp 函數(shù) 那種能返回表達式的函數(shù)。雖然?nil!?的定義中只是調用了一下?list?,但其他宏里可能會驅動整個子程序來生成其展開式。

有能力改變編譯器所看到的東西,差不多等同于能夠對代碼進行重寫。所以我們就可以為語言增加任何構造,只要用變換的方法把它定義成已有的構造。

7.2 反引用(backquote)

反引用(backquote)是引用(quote)的特別版本,它可以用來創(chuàng)建 Lisp 表達式的模板。反引用最常見的用途之一是用在宏定義里。

反引用字符 得名的原因是:它和通常的引號?'`?相似,只不過方向相反。當單獨把反引用作為表達式前綴的時候,它的行為和引號一樣:

`(a b c) 等價于 '(a b c)

只有在反引用和逗號?,?以及 comma-at?,@?一同出現(xiàn)時才變得有用。如果說反引用創(chuàng)建了一個模板,那么逗號就在反引用中創(chuàng)建了一個槽(slot) 。一個反引用列表等價于將其元素引用起來,調用一次?list。也就是:

`(a b c)  等價于 (list 'a 'b 'c).

在反引用的作用域里,逗號要求 Lisp: "把引用關掉" 。當逗號出現(xiàn)在列表元素前面時,它的效果就相當于取消引用,讓 Lisp 把那個元素按原樣放在那里。所以:

`(a ,b c ,d)  等價于 (list 'a b 'c d)

插入到結果列表里的不再是符號 b ,取而代之的是它的值。無論逗號在嵌套列表里的層次有多深,它都仍然有效:

> (setq a 1 b 2 c 3)
3
> `(a ,b c)
(A 2 C)
> `(a (,b c))
(A (2 C))

而且它們也可以出現(xiàn)在引用的列表里,或者引用的子列表里:

> `(a b ,c (',(+ a b c)) (+ a b) 'c '((,a 'b)))
(A B 3 ('6) (+ A B) 'C '((1 'B)))

一個逗號能抵消一個反引用的效果,所以逗號在數(shù)量上必須和反引用匹配。如果某個操作符出現(xiàn)在逗號的外層,或者出現(xiàn)在包含逗號的那個表達式的外層,那么我們說該操作符包圍了這個逗號。例如在`(,a ,(b ',c))?中,最后一個逗號就被前一個逗號和兩個反引號所包圍。通行的規(guī)則是:一個被n?個逗號包圍的逗號必須被至少?n + 1?個反引號所包圍。很明顯,由此可知:逗號不能出現(xiàn)在反引用的表達式的外面。只要遵守上述規(guī)則,就可以嵌套使用反引用和逗號。下面的任何一個表達式如果輸入到 toplevel 下都將造成錯誤:

,x `(a ,,b c) `(a ,(b ,c) d) `(,,'a)

嵌套的反引用只有在宏定義的宏里才可能會用到。第 16 章將討論這兩個主題。

反引用通常被用來創(chuàng)建列表【注 1】。任何用反引用生成的列表也都可以用?list?和普通的引用來實現(xiàn)。使用反引用的好處只是在于它改進了表達式的可讀性,因為反引用的表達式和它將生成的表達式很相似。在前一章里我們把?nil!?定義成:

(defmacro nil! (var)
  (list 'setq var nil))

借助反引用,這個宏可以定義成:

(defmacro nil! (var)
  `(setq ,var nil))

在本例中,是否使用反引用的差別還不算太大。不過,隨著宏定義長度的增加,反引用也會變得愈加重要。

[示例代碼 7.1] 包含了兩個?nif?可能的定義,這個宏實現(xiàn)了三路數(shù)值條件選擇?!咀?2】


[示例代碼 7.1] 一個使用和不使用反引用的宏定義

使用反引用:

(defmacro nif (expr pos zero neg)
  `(case (truncate (signum ,expr))
    (1 ,pos)
    (0 ,zero)
    (-1 ,neg)))

不使用反引用:

(defmacro nif (expr pos zero neg)
  (list 'case
    (list 'truncate (list 'signum expr))
    (list 1 pos)
    (list 0 zero)
    (list -1 neg)))

首先,第一個參數(shù)會被求值成數(shù)字。然后會根據(jù)這個數(shù)字的正負、是否為零,來決定第二、第三和第四個參數(shù)中哪一個將被求值:

> (mapcar #'(lambda (x)
    (nif x 'p 'z 'n))
  '(0 2.5 -8))
(Z P N)

[示例代碼 7.1] 中的兩個定義分別定義了同一個宏,但是前者使用的是反引用,而后者則通過顯式調用?list?來構造它的展開式。以?(nif x 'p 'z 'n)?為例,從第一個定義中很容易就能看出來,這個表達式會展開成:

(case (truncate (signum x))
  (1 'p)
  (0 'z)
  (-1 'n))

因為這個宏定義體的模樣就和它生成的宏展開式差不多。要想理解不使用反引用的第二個版本,你將不得不在腦海中重演一遍展開式的構造過程。

comma-at,即?,@,是逗號的變形,其行為和逗號相似,但有一點不同:comma-at?不像逗號那樣僅僅把表達式的值插入到所在的位置,而是把表達式拼接進去。拼接這個操作可以這樣理解:在插入的同時,剝去被插入對象最外層的括號:

> (setq b '(1 2 3))
(1 2 3)
> `(a ,b c)
(A (1 2 3) C)
> `(a ,@b c)
(A 1 2 3 C)

逗號導致列表?(1 2 3)?被插入到?b?所在的位置,而?comma-at?把列表中的元素插入到那里。對于comma-at?的使用,還另有限制:

  1. 為了確保其參數(shù)可以被拼接,comma-at?必須出現(xiàn)在序列(sequence)【注 3】 中。形如',@b?的寫法是錯誤的,因為無處可供?b?的值進行拼接。

  2. 要進行拼接的對象必須是個列表,除非它出現(xiàn)在列表最后。表達式?'(a ,@1)?將被求值成?(a . 1),但如果嘗試將原子【注 4】(atom) 拼接到列表的中間位置,例如?'(a ,@1 b),將導致一個錯誤。

comma-at?一般用在接受不確定數(shù)量參數(shù)的宏里,以及將這些參數(shù)傳給同樣接受不確定數(shù)量參數(shù)的函數(shù)和宏里。這一情況通常廣泛用于實現(xiàn)隱式的塊(block)。Common Lisp 提供幾種將代碼分組到塊的操作符,包括?block、tagbody,以及?progn?。這些操作符很少直接出現(xiàn)在源代碼里;它們一般不顯山露水,而是藏身在宏的背后。

隱式塊出現(xiàn)在任何一個帶有表達式體的內置宏里。例如?let?和?cond?里都有隱式的?progn?存在。做這種事情的內建宏里,最簡單的一個可能要算?when?了:

(when (eligible obj)
  (do-this)
  (do-that)
  obj)

如果?(eligible obj)?返回真,那么其余的表達式將會被求值,并且整個?when?表達式會返回其中最后一個表達式的值。下面是一個使用?comma-at?的示例,它是?when?的一種可能的實現(xiàn):

(defmacro our-when (test &body body)
  `(if ,test
    (progn
      ,@body)))

這一定義使用了一個?&body?參數(shù)(它和?&rest?功能相同,只有美觀輸出的時候不太一樣)來接受可變數(shù)量的參數(shù),然后一個?comma-at?將它們拼接到一個?progn?表達式里。在上述調用的宏展開式里,宏調用體里面的三個表達式將出現(xiàn)在單個?progn?中:

(if (eligible obj)
  (progn (do-this)
    (do-that)
    obj))

多數(shù)需要迭代處理其參數(shù)的宏都采用類似方式拼接它們。

comma-at?的效果也可以不用反引用實現(xiàn)。例如,表達式:

`(a ,@b c)

就和:

(cons 'a (append b (list 'c)))

等價。之所以用上?comma-at,只是為了改進這種由表達式生成的表達式的可讀性。

宏定義(通常)生成列表。盡管宏展開式可以用函數(shù)?list?來生成,但反引用的列表模板可以令這一任務更為簡單。用?defmacro?和反引用定義的宏,在形式上和用?defun?定義的函數(shù)非常相似。只要不被這種相似性誤導,反引用就能讓宏定義既容易書寫也方便閱讀。

由于反引用經(jīng)常出現(xiàn)在宏定義里,以致于人們有時誤以為反引用是?defmacro?的一部分。關于反引用的最后一件要記住的事情,是它有自己存在的意義,這跟它在宏定義中的角色無關。你可以在任何需要構造序列的場合使用反引用:

(defun greet (name)
  `(hello ,name))

7.3 定義簡單的宏

在編程領域,最快的學習方式通常是盡快地開始實踐。完全理論上的理解可以稍后再說。因此本章介紹一種可以立即開始編寫宏的方法。雖然該方法的適用范圍很窄,但在這個范圍內卻可以高度機械化地實現(xiàn)。

(如果你以前寫過宏,可以跳過本節(jié)。)

下面舉個例子,讓我們考慮一下如何寫出 Common Lisp 內置函數(shù)?member?的變形。member?缺省用?eql?來判斷等價與否。如果你想要用?eq?來判斷是否等價,你就必須顯式寫成這樣:

(member x choices :test #'eq)

如果常常這樣做,那我們可能會想要寫一個?member?的變形,讓它總是使用?eq?。有些早期的 Lisp 方言就有這樣的一個函數(shù),叫做?memq

(memq x choices)

通常應該將?memq?定義為內聯(lián)(inline) 函數(shù),但為了舉例子,我們會讓它以宏的面目出現(xiàn)。


[示例代碼 7.2] 用于寫 memq 的圖示

調用:

 (memq x choices)

展開:

(member x choices :test #'eq)

方法如下:從你想要定義的這個宏的一次典型調用開始。先把它寫在紙上,然后下面寫上它應該展開成的表達式。[示例代碼 7.2] 給出了兩個這樣的表達式。通過宏調用,構造出你這個宏的參數(shù)列表,同時給每個參數(shù)命名。這個例子中有兩個實參,所以我們將會有兩個形參,把它們叫做 obj 和 lst :

(defmacro memq (obj lst)

現(xiàn)在回到之前寫下的兩個表達式。對于宏調用中的每個參數(shù),畫一條線把它和它在展開式里出現(xiàn)的位置連起來。[示例代碼 7.2] 中有兩條并行線。為了寫出宏的實體,把你的注意力轉移到展開式。讓主體以反引用開頭。

現(xiàn)在,開始逐個表達式地閱讀展開式。每當發(fā)現(xiàn)一個括號,如果它不是宏調用中實參的一部分,就把它放在宏定義里。所以緊接著反引用會有一個左括號。對于展開式里的每個表達式

  1. 如果沒有線將它和宏調用相連,那么就把表達式本身寫下來。

  2. 如果存在一條跟宏調用中某個參數(shù)的連接,就把出現(xiàn)在宏參數(shù)列表的對應位置的那個符號寫下來,前置一個逗號。

由于第一個元素?member?上沒有連接,所以我們照原樣使用?member?:

(defmacro memq (obj lst)
  '(member

不過,x?上有一條線指向源表達式中的第一個實參,所以我們在宏的主體中使用第一個參數(shù),帶一個逗號:

(defmacro memq (obj lst)
  '(member ,obj

以這種方式繼續(xù)進行,最后完成的宏定義是:


[示例代碼 7.3] 用于寫 while 的圖示

(defmacro memq (obj lst)
  `(member ,obj ,lst :test #'eq))

(while hungry
  (stare-intently)
  (meow)
  (rub-against-legs))

(do ()
  ((not hungry))
  (stare-intently)
  (meow)
  (rub-against-legs))

到目前為止,我們寫出的宏,其參數(shù)個數(shù)只能是固定的?,F(xiàn)在假設我們打算寫一個?while?宏,它接受一個條件表達式和一個代碼體,然后循環(huán)執(zhí)行代碼直到條件表達式返回真。[示例代碼 7.3] 含有一個描述貓的行為的?while?循環(huán)示例。

要寫出這樣的宏,我們需要對我們的技術稍加修改。和前面一樣,先寫一個宏調用作為毛坯。然后,以它為基礎,構造宏的形參列表,其中,在想要接受任意多個參數(shù)的地方,以一個?&rest?或?&body形參作結:

(defmacro while (test &body body)

現(xiàn)在,在宏調用的下面寫出目標展開式,并且和之前一樣,畫線把宏調用的形參和它們在展開式中的位置連起來。然而,當你碰到一個系列形參,而且它們會被?&rest?或?&body?實參吸收時,就要把它們當成一組處理,并只用一條線來連接整個參數(shù)序列。[示例代碼 7.3] 給出了最后的展示。

為了寫出宏定義的主體,按之前的步驟處理表達式。在前面給出的兩條規(guī)則之外,我們還要加上一條:

  1. 如果在一系列展開式中的表達式和宏調用里的一系列形參之間存在聯(lián)系,那么就把對應的?&rest或?&body?實參記下來,在前面加上?comma-at。

于是宏定義的結果將是:

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

要想構造帶有表達式體的宏,就必須有參數(shù)充當打包裝箱的角色。這里宏調用中的多個參數(shù)被串起來放到?body里,然后在?body?被拼接進展開式時,再把它拆散開。

用本章所述的這個方法,我們能寫出最簡單的宏 這種宏只能在參數(shù)位置上做文章。但是宏可以比這做的多得多。第 7.7 節(jié)將會舉一個例子,這個例子無法用簡單的反引用列表表達,并且為了生成展開式,例子中的宏成為了真正意義上的程序。

7.4 測試宏展開

宏寫好了,那我們怎么測試它呢?像?memq?這樣的宏,它的結構較簡單,只消看看它的代碼就能弄清其行為方式。而當編寫結構更復雜的宏時,我們必須有辦法檢查它們展開之后正確與否。

[示例代碼 7.4] 給出了一個宏定義和用來查看其展開式的兩個方法。內置函數(shù)?macroexpand?的參數(shù)是個表達式,它返回這個表達式的宏展開式。把一個宏調用傳給?macroexpand?,就能看到宏調用在求值之前最終展開的樣子,但是當你測試宏的時候,并不是總想看到徹底展開后的展開式。如果有宏依賴于其他宏,被依賴的宏也會一并展開,所以完全展開后的宏有時是不利于閱讀的。

從[示例代碼 7.4] 給出的第一個表達式,很難看出?while?是否如愿展開,因為不僅內置的宏 do 被展開了,而且它里面的?prog?宏也展開了。我們需要一種方法,通過它能看到只展開過一層宏的展開結果。這就是內置函數(shù)?macroexpand-1?的目的,正如第二個例子所示。就算展開后,得到的結果仍然是宏調用,macroexpand-1?也只做一次宏展開就停手。


[示例代碼 7.4] 一個宏和它的兩級展開

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

> (pprint (macroexpand '(while (able) (laugh))))

(BLOCK NIL
  (LET NIL
    (TAGBODY
      #:G61
      (IF (NOT (ABLE)) (RETURN NIL))
      (LAUGH)
      (GO #:G61))))
T
> (pprint (macroexpand-1 '(while (able) (laugh))))

(DO NIL
  ((NOT (ABLE)))
  (LAUGH))
T

[示例代碼 7.5] 一個用于測試宏展開的宏

(defmacro mac (expr)
  `(pprint (macroexpand-1 ',expr)))

如果每次查看宏調用的展開式都得輸入如下的表達式,這會讓人很頭痛:

(pprint (macroexpand-1 '(or x y)))

[示例代碼 7.5] 定義了一個新的宏,它讓我們有一個簡單的替代方法:

(mac (or x y))

調試函數(shù)的典型方法是調用它們,同樣的道理,對于宏來說就是展開它們。不過由于宏調用涉及了兩次計算,所以它也就有兩處可能會出問題。如果一個宏行為不正常,大多數(shù)時候你只要檢查它的展開式,就能找出有錯的地方。不過也有一些時候,展開式看起來是對的,所以你想對它進行求值以便找出問題所在。

如果展開式里含有自由變量,你可能需要先設置一些變量。在某些系統(tǒng)里,你可以復制展開式,把它粘貼到 toplevel 環(huán)境里,或者選擇它然后在菜單里選 eval。在最壞的情況下你也可以把 macroexpand-1 返回的列表設置在一個變量里,然后對它調用 eval :

> (setq exp (macroexpand-1 '(memq 'a '(a b c))))
(MEMBER (QUOTE A) (QUOTE (A B C)) :TEST (FUNCTION EQ))
> (eval exp)
(A B C)

最后,宏展開不只是調試的輔助手段,它也是一種學習如何編寫宏的方式。Common Lisp 帶有超過一百個內置宏,其中一些還頗為復雜。通過查看這些宏的展開過程你經(jīng)常能了解它們是怎樣寫出來的。

7.5 參數(shù)列表的解構

解構(destructuring) 是用在處理函數(shù)調用中的一種賦值操作【注 5】的推廣形式。如果你定義的函數(shù)帶有多個形參:

(defun foo (x y z)
  (+ x y z))

當調用該函數(shù)時:

(foo 1 2 3)

函數(shù)調用中實參會按照參數(shù)位置的對應關系,賦值給函數(shù)的形參:1?賦給?x?,2?賦給?y?,3?賦給?z?。和本例中扁平列表?(x y z)?的情形類似,解構(destructuring) 同樣也指定了按位置賦值的方式,不過它能按照任意一種列表結構來進行賦值。

Common Lisp 的?destructuring-bind?宏(CLTL2 新增) 接受一個匹配模式,一個求值到列表的實參,以及一個表達式體,然后在求值表達式時將模式中的參數(shù)綁定到列表的對應元素上:

> (destructuring-bind (x (y) . z) '(a (b) c d)
  (list x y z))
(A B (C D))

這一新操作符和其它類似的操作符構成了第 18 章的主題。

在宏參數(shù)列表里進行解構也是可能的。Common Lisp 的?defmacro?宏允許任意列表結構作為參數(shù)列表。當宏調用被展開時,宏調用中的各部分將會以類似 destructuring-bind 的方式被賦值到宏的參數(shù)上面。內置的?dolist?宏就利用了這種參數(shù)列表的解構技術。在一個像這樣的調用里:

(dolist (x '(a b c))
  (print x))

展開函數(shù)必須把?x?和?'(a b c)?從作為第一個參數(shù)給出的列表里抽取出來。這個任務可以通過給dolist?適當?shù)膮?shù)列表隱式地完成【注 6】:

(defmacro our-dolist ((var list &optional result) &body body)
  '(progn
    (mapc #'(lambda (,var) ,@body)
      ,list)
    (let ((,var nil))
      ,result)))

在 Common Lisp 中,類似?dolist?這樣的宏通常把參數(shù)包在一個列表里面,而后者不屬于宏體。由于?dolist?接受一個可選的?result?參數(shù),所以它無論如何都必須把它參數(shù)的第一部分塞進一個單獨的列表。但就算這個多余的列表結構是畫蛇添足,它也可以讓?dolist?調用更易于閱讀。假設我們想要定義一個宏?when-bind?,它的功能和?when?差不多,除此之外它還能綁定一些變量到測試表達式返回的值上。這個宏最好的實現(xiàn)辦法可能會用到一個嵌套的參數(shù)表:

(defmacro when-bind ((var expr) &body body)
  '(let ((,var ,expr))
    (when ,var
      ,@body)))

然后這樣調用:

(when-bind (input (get-user-input))
  (process input))

而不是原本這樣調用:

(let ((input (get-user-input)))
  (when input
    (process input)))

審慎地使用它,參數(shù)列表解構技術可以帶來更加清晰的代碼。最起碼,它可以用在諸如?when-bind和?dolist?這樣的宏里,它們接受兩個或更多的實參,和一個表達式體。

7.6 宏的工作模式

關于 "宏究竟做了什么" 的形式化描述將是既拖沓冗長,又讓人不得要領的。就算有經(jīng)驗的程序員也記不住這樣讓人頭暈的描述。想象一下?defmacro?是怎樣定義的,通過這種方式來記憶它的行為會更容易些。


[示例代碼 7.6] 一個?defmacro?的草稿

(defmacro our-expander (name) '(get ,name 'expander))

(defmacro our-defmacro (name parms &body body)
  (let ((g (gensym)))
    `(progn
      (setf (our-expander ',name)
        #'(lambda (,g)
          (block ,name
            (destructuring-bind ,parms (cdr ,g)
              ,@body))))
      ',name)))

(defun our-macroexpand-1 (expr)
  (if (and (consp expr) (our-expander (car expr)))
    (funcall (our-expander (car expr)) expr)
    expr))

在 Lisp 里用這種方法解釋概念已由來已久。早在1962年首次出版的?Lisp 1.5 Programmer's Manual?,就在書中給出了一個用 Lisp 寫的?eval?函數(shù)的定義作為參考。由于?defmacro?自身也是宏,所以我們可以依法炮制,如 [示例代碼 7.6] 所示。這個定義里使用了幾種我們尚未提及的技術,所以某些讀者可能需要稍后再回過頭來讀懂它。

[示例代碼 7.6] 中的定義相當準確地再現(xiàn)了宏的行為,但就像任何草稿一樣,它遠非十全十美。它不能正確地處理?&whole?關鍵字。而且,真正的?defmacro?為它第一個參數(shù)的?macro-function?保存的是一個有兩個參數(shù)的函數(shù),兩個參數(shù)分別為:宏調用本身,和其發(fā)生時的詞法環(huán)境。還好,只有最刁鉆的宏才會用到這些特性。

就算你以為宏就是像 [示例代碼 7.6] 那樣實現(xiàn)的,在實際使用宏的時候,也基本上不會出錯。例如,在這個實現(xiàn)下,本書定義的每一個宏都能正常運行。

[示例代碼 7.6] 的定義里產(chǎn)生的展開函數(shù)是個被井號引用過的 λ表達式。那將使它成為一個閉包:宏定義中的任何自由符號應該指向?defmacro?發(fā)生時所在環(huán)境里的變量。所以下列代碼是可行的:

(let ((op 'setq))
  (defmacro our-setq (var val)
    (list op var val)))

上述代碼對?CLTL2?來說沒有問題。但在?CLTL1?里,宏展開器是在空詞法環(huán)境里定義的【注 7】,所以在一些老的 Common Lisp 實現(xiàn)里,這個?our-setq?的定義將不會正常工作。

7.7 作為程序的宏

宏定義并不一定非得是個反引用列表。宏的本質是函數(shù),它把一個表達式轉換成另一個表達式。這個函數(shù)可以調用?list?來生成結果,但是同樣也可以調用一整個長達數(shù)百行代碼的子程序達到這個目的。

第 7.3 節(jié)給出了一個編寫宏的簡易方案。借助這一技術,我們可以寫出這樣的宏,讓它的展開式包含的子表達式和宏調用中的相同。不幸的是,只有最簡單的宏才能滿足這一條件。現(xiàn)在舉個復雜一些的例子,讓我們來看看內置的宏?do?。要把?do?實現(xiàn)成那種只是把參數(shù)重新排列一下的宏是不可能的。在展開過程中,必須構造出一些在宏調用中沒有出現(xiàn)過的復雜表達式。

關于編寫宏,有個更通用的方法:先想想你想要使用的是哪種表達式,再設想一下它應該展開成的模樣,最后寫出能把前者變換成后者的程序??梢栽囍止ふ归_一個例子,分析在表達式從一種形式變換到另一種形式的過程中,究竟發(fā)生了什么。從實例出發(fā),你就可以大致明白在你將要寫的宏里將需要做些什么工作。


[示例代碼 7.7] do 的預期展開過程

(do ((w 3)
    (x 1 (1+ x))
    (y 2 (1+ y))
    (z))
  ((> x 10) (princ z) y)
  (princ x)
  (princ y))

應該被展開成如下的樣子:

(prog ((w 3) (x 1) (y 2) (z nil))
  foo
  (if (> x 10)
    (return (progn (princ z) y)))
  (princ x)
  (princ y)
  (psetq x (1+ x) y (1+ y))
  (go foo))

[示例代碼 7.7] 顯示了?do?的一個實例,以及它應該展開成的表達式。手工進行展開有助于理清你對于宏工作方式的認識。例如,在試著寫展開式時,你就不得不使用?psetq?來更新局部變量,如果沒有手工寫過展開式,說不定就會忽視這一點。

內置的宏?psetq?(因 "parallel setq" 而得名) 在行為上和?setq?相似,不同之處在于:在做任何賦值操作之前,它所有的(第偶數(shù)個) 參數(shù)都會被求值。如果是普通的?setq?,而且在調用時有兩個以上的參數(shù),那么在求值第四個參數(shù)的時候,第一個參數(shù)的新值將是可見的。

> (let ((a 1))
  (setq a 2 b a)
  (list a b))
(2 2)

這里,因為先設置的是?a?,所以?b?得到了它的新值,即?2?。而調用?psetq?時,應該就好像參數(shù)的賦值操作是并行的一樣:

> (let ((a 1))
  (psetq a 2 b a)
  (list a b))
(2 1)

所以這里的?b?得到的是?a?原來的值。這個?psetq?宏是特別為支持類似?do?這樣的宏而提供的,后者需要并行地對它們的一些參數(shù)進行求值。(如果這里使用的是setq?,而非?psetq?,那么最后定義出來的就不是?do?而是?do*?了。)

仔細觀察展開式,還可以看出另一個問題,我們不能真的把?foo?作為循環(huán)標簽使用。如果?do?宏里的循環(huán)標簽也是?foo?呢?第 9 章將會具體解決這個問題;至于現(xiàn)在,只要在宏展開里面,用gensym?生成一個專門的匿名符號,然后把?foo?換成這個符號就行了。


[示例代碼 7.8] 實現(xiàn) do

(defmacro our-do (bindforms (test &rest result) &body body)
  (let ((label (gensym)))
    `(prog ,(make-initforms bindforms)
      ,label
      (if ,test
        (return (progn ,@result)))
      ,@body
      (psetq ,@(make-stepforms bindforms))
      (go ,label))))

(defun make-initforms (bindforms)
  (mapcar #'(lambda (b)
      (if (consp b)
        (list (car b) (cadr b))
        (list b nil)))
    bindforms))

(defun make-stepforms (bindforms)
  (mapcan #'(lambda (b)
      (if (and (consp b) (third b))
        (list (car b) (third b))
        nil))
    bindforms))

為了寫出?do?,我們接下來考慮一下需要做哪些工作,才能把 [示例代碼 7.7] 中的第一個表達式變換成第二個。要完成這種變換,如果只是像以前那樣,把宏的參數(shù)放在某個反引用列表中的適當位置,是不可能的了,我們要更進一步。緊跟著最開始的prog 應該是一個由符號和它們的初始綁定構成的列表,而這些信息需要從傳給?do?的第二個參數(shù)里拆解出來。[示例代碼 7.8] 中的函數(shù)make-initforms?將返回這樣的一個列表。我們還需要為?psetq?構造一個參數(shù)列表,但本例中的情況要復雜一些,因為并非所有的符號都需要更新。在[示例代碼 7.8] 中,make-stepforms?會返回?psetq需要的參數(shù)。有了這兩個函數(shù),定義的其它部分就易如反掌了。

[示例代碼 7.8] 中的代碼并不完全是?do?在真正的實現(xiàn)里的寫法。為了強調在宏展開過程中完成的計算,make-initforms?和?make-stepforms?被分離出來,成為了單獨的函數(shù)。在將來,這樣的代碼通常會留在?defmacro?表達式里。

通過這個宏的定義,我們開始領教到宏的能耐了。宏在構造表達式時,可以使用Lisp 所有的功能。而用來生成展開式的代碼,其自身就可以是一個程序。

7.8 宏風格

對于宏來說,良好的風格有著不同的含義。風格既體現(xiàn)在閱讀代碼的時候,也體現(xiàn)在 Lisp 求值代碼的時候。宏的引入,使閱讀和求值在稍有些不一樣的場合下發(fā)生了。

一個宏定義牽涉到兩類不同的代碼,分別是:展開器代碼,宏用它來生成其展開式,以及展開式代碼,它出現(xiàn)在展開式本身的代碼中。編寫這兩類代碼所遵循的準則各不相同。通常,好的編碼風格要求程序清晰并且高效。兩類宏代碼在這兩點上側重的方面截然相反:展開器代碼更重視代碼的結構清晰可讀,而展開式代碼對效率的要求更高一些。

效率,只有在編譯了的代碼里才是最重要的,而在編譯了的代碼里宏調用已經(jīng)被展開了。就算展開器代碼很高效,它也只會使得代碼的編譯過程稍微快一些,但這對程序運行的效率沒有任何影響。

由于宏調用的展開只是編譯器工作中很小的一部分,那些可以高效展開的宏通常甚至不會在編譯速度上產(chǎn)生明顯的差異。

所以大多數(shù)時候,你大可不必字句斟酌,只要像寫一個程序的快速初版那樣,編寫宏展開代碼就可以了。如果展開器代碼做了一些不必要的工作或者做了很多?cons,那又能怎樣呢?你的時間最好花在改進程序的其他部分上面。如果在展開器代碼里,要在可讀性和速度兩者之間作一個選擇,可讀性當然應該勝出。

宏定義通常比函數(shù)定義更難以閱讀,因為宏定義里含有兩種表達式的混合體,它們將在不同的時刻求值。

如果可以犧牲展開器代碼的效率,讓宏定義更容易讀懂,那這筆買賣還是合算的。


[示例代碼 7.9] 兩個等價于 and 的宏

(defmacro our-and (&rest args)
  (case (length args)
    (0 t)
    (1 (car args))
    (t '(if ,(car args)
        (our-and ,@(cdr args))))))

(defmacro our-andb (&rest args)
  (if (null args)
    t
    (labels ((expander (rest)
          (if (cdr rest)
            '(if ,(car rest)
              ,(expander (cdr rest)))
            (car rest))))
      (expander args))))

舉個例子,假設我們想要把一個版本的and 定義成宏。由于:

(and a b c)

等價于:

(if a (if b c))

我們可以像 [示例代碼 7.9] 中的第一個定義那樣,用?if?來實現(xiàn)?and?。根據(jù)我們評判普通代碼的標準,our-and?寫得并不好。因為它的展開器代碼是遞歸的,而且在每次遞歸里都要需要計算同一個列表的每個后繼?cdr?的長度。

如果這個代碼希望在運行期求值,最好像?our-andb?那樣定義這個宏,它沒有做任何多余的計算,就生成了同樣的展開式。雖然如此,作為一個宏定義來說,our-and?即使算不上好,至少還過得去。盡管每次遞歸都調用?length?,這樣可能會比較沒效率,但是其代碼的組織方式更加清晰地說明了其展開式跟?and?的連接詞數(shù)量之間的依賴關系。

凡事都有例外。在 Lisp 里,對編譯期和運行期的區(qū)分是人為的,所以任何依賴于此的規(guī)則同樣也是人為的。

在某些程序里,編譯期也就是運行期。如果你在編寫一個程序,它的主要目的就是進行代碼變換,并且它使用宏來實現(xiàn)這個功能,那么一切就都變了:展開器代碼成為了你的程序,而展開式是程序的輸出。很明顯,在這種情況下,展開器代碼應該寫得盡可能高效。盡管如此,還是可以說大多數(shù)展開器代碼:

(a) 只會影響編譯速度,而且

(b) 也不會影響太多

換句話說,代碼的可讀性幾乎總是應該放在第一位。

對于展開式代碼來說,正好相反。對宏展開式來說,代碼可讀與否不太重要,因為很少有人會去讀它,而別人讀這種代碼的可能性更是微乎其微。平時嚴禁使用的?goto?在展開式里可以網(wǎng)開一面,備受冷眼的?setq?也可以稍微抬起頭來。

結構化編程的擁護者不喜歡源代碼里的?goto。他們心目中的洪水猛獸并非機器語言里的跳轉指令 前提是這些跳轉指令是通過更抽象的控制結構隱藏在源代碼里的。在 Lisp 里,goto?之所以備受責難,其實是因為很容易把它藏起來:你可以改用?do?,而且就算你沒有?do?可用,還可以自己寫一個。很明顯,如果你打算在?goto?的基礎上構建新抽象,goto?一定會存在于某些地方。因而,在新的宏定義中使用?goto?未必不好,前提是它不能用現(xiàn)成的宏來寫。

類似地,不推薦使用?setq?的理由是:它讓我們很難弄清楚一個給定變量的值是在哪里獲得的。雖然這樣,但是考慮到會去讀宏展開式代碼的人不是很多,所以對宏展開式里創(chuàng)建的變量使用?setq?也問題不大。如果你查看一些內置宏的展開式,你會看到許多?setq。

在某些場合下,展開式代碼的清晰性更重要一些。如果你在編寫一個復雜的宏,你可能最后還是得閱讀它的展開式,至少在調試的時候。

同樣,在簡單的宏里,只有一個反引用用來把展開器代碼和展開式代碼分開,所以,如果這樣的宏生成了難看的展開式,那么這種慘不忍睹的代碼在你的源代碼里將會一覽無余。

盡管如此,就算對展開式代碼的可讀性有了要求,效率仍然應該放在第一位。效率于大多數(shù)運行時代碼都至關重要。而對宏展開來說尤為如此,這里有兩個原因:宏的普遍性和不可見性。

宏通常用于實現(xiàn)通用的實用工具,這些工具會出現(xiàn)在程序的每個角落。如此頻繁使用的代碼是無法忍受低效的。一個宏,雖然看上去小小的,安全無害,但是在所有對它的調用都展開之后,可能會占據(jù)你程序的相當篇幅。

這樣的宏得到的重視應當比因為它們的長度所獲得的重視更多才對。

特別是要避免?cons。一個實用工具,如果做了不必要的?cons,那就會毀掉一個原本高效的程序。

關注展開式代碼效率的另一個原因就是它非常容易被忽視。倘若一個函數(shù)實現(xiàn)得不好,那么每次查看其定義時,它都會向你坦陳這一事實。宏就不是這樣了。展開式代碼的低效率在宏的定義里可能并不顯而易見,這也就是需要更加關注它的全部原因。

7.9 宏的依賴關系

如果你重定義了一個函數(shù),調用它的函數(shù)會自動用上新的版本【注 8】。 不過,這個說法對宏來說可就不一定成立了。當函數(shù)被編譯時,函數(shù)定義中的宏調用就會替換成它的展開式。如果我們在主調函數(shù)編譯以后,重定義那個宏會發(fā)生什么呢?由于對最初的宏調用的無跡可尋,所以函數(shù)里的展開式無法更新。該函數(shù)的行為將繼續(xù)反映出宏的原來的定義:

> (defmacro mac (x) '(1+ ,x))
MAC
> (setq fn (compile nil '(lambda (y) (mac y))))
#<Compiled-Function BF7E7E>
> (defmacro mac (x) '(+ ,x 100))
MAC
> (funcall fn 1)
2

如果在定義宏之前,就已經(jīng)編譯了宏的調用代碼,也會發(fā)生類似的問題。CLTL2?這樣要求,"宏定義必須在其首次使用之前被編譯器看到"。各家實現(xiàn)對違反這個規(guī)則的反應各自不同。幸運的是,這兩類問題都能很容易地避免。如果能滿足下面兩個條件,你就永遠不會因為過時或者不存在的宏定義而煩心:

  1. 在調用宏之前,先定義它。

  2. 一旦重定義一個宏,就重新編譯所有直接(或通過宏間接) 調用它的函數(shù)(或宏)。

有些人建議將程序中所有的宏都放在一個單獨的文件里,以便保證宏定義被首先編譯。這樣有點過頭了。

我們建議把類似?while?的通用宏放在單獨的文件里,不過無論如何,通用的實用工具都應該和程序其余的部分分開,不論它們是函數(shù)還是宏。

某些宏只是為了用在程序的某個特定部分而寫的,自然,這種宏應該跟使用它們的代碼放在一起。只要保證每個宏的定義都出現(xiàn)在任何對它們的調用之前,你的程序就可以正確無誤地編譯。僅僅因為它們是宏,所以就把所有的宏集中寫在一起,這樣做不會有任何好處,只會讓你的代碼更難以閱讀。

7.10 來自函數(shù)的宏

本節(jié)將說明把函數(shù)轉化成宏的方法。將函數(shù)轉化為宏的第一步是問問你自己是否真的需要這么做。難道,你就不能干脆把函數(shù)聲明成?inline?(第 2.9 節(jié)) 嗎?

話又說回來,"如何將函數(shù)轉化為宏" 這個問題還是有其意義的。當你剛開始寫宏的時候,假想自己寫的是個函數(shù),希望有助于思考,這樣做有時會有用 而用這種辦法編出來的宏一般多少會有些問題,但這至少可以幫助你起步。關注宏與函數(shù)之間關系的另一個原因是為了了解它們究竟有何不同。最后,Lisp 程序員有時確實需要把函數(shù)改造成宏。

函數(shù)轉化為宏的難度取決于該函數(shù)的一些特性。最容易轉化的一類函數(shù)有下面幾個特點:

  1. 其函數(shù)體只有一個表達式。

  2. 其參數(shù)列表只由參數(shù)名組成。

  3. 不創(chuàng)建任何新變量(參數(shù)除外)。

  4. 不是遞歸的(也不屬于任何相互遞歸的函數(shù)組)。

  5. 每個參數(shù)在函數(shù)體里只出現(xiàn)一次。

  6. 沒有一個參數(shù),它的值會在其參數(shù)列表之前的另一個參數(shù)出現(xiàn)之前被用到。

7. 無自由變量。

有一個函數(shù)滿足這些規(guī)定,它是 Common Lisp 的內置函數(shù)?second?,second?返回列表的第二個元素。它可以定義成:

(defun second (x) (cadr x))

如此這般,可見它滿足上述的所有條件,因而可以輕而易舉地把它轉化成等價的宏定義。只要把一個反引用放在函數(shù)體的前面,再把逗號放在每一個出現(xiàn)在參數(shù)列表里的符號前面就大功告成了:

(defmacro second (x) '(cadr ,x))

當然,這個宏也不是在所有相同條件下都可以使用。它不能作為?apply?或者?funcall?的第一個參數(shù),而且被它調用的函數(shù)不能擁有局部綁定。不過,對于普通的內聯(lián)調用,second?宏應該能勝任second?函數(shù)的工作。

倘若函數(shù)體里的表達式不止一個,就要把這個技術稍加變通,因為宏必須展開成單獨的表達式。所以無法滿足條件1,你必須加上一個?progn?。

函數(shù)?noisy-second?:

(defun noisy-second (x)
  (princ "Someone is taking a cadr!")
  (cadr x))

的功能也可以用下面的宏來完成:

(defmacro noisy-second (x)
  '(progn
    (princ "Someone is taking a cadr!")
    (cadr ,x)))

如果函數(shù)沒能滿足條件 2 的原因是,因為它有?&rest?或者?&body?參數(shù),那么道理是一樣的,除了參數(shù)的處理有所不同,這次不能只是把逗號放在前面,而是必須把參數(shù)拼接到一個?list?調用里。照此辦理的話:

(defun sum (&rest args)
  (apply #'+ args))

就變成了:

(defmacro sum (&rest args)
  '(apply #'+ (list ,@args)))

不過上面的宏如果改成這樣寫會更好些:

(defmacro sum (&rest args)
  '(+ ,@args))

當條件 3 無法滿足,即在函數(shù)體里創(chuàng)建了新變量時,插入逗號的步驟必須改一下。這時不能在參數(shù)列表里的所有符號前面放逗號了,取而代之,我們只把逗號加在那些引用了參數(shù)的符號前面。例如在:

(defun foo (x y z)
  (list x (let ((x y))
      (list x z))))

最后兩個 x 的實例都沒有指向參數(shù) x 。第二個實例根本就不求值,而第三個實例引用的是由 let 建立的新變量。所以只有第一個實例才會有逗號:

(defmacro foo (x y z)
  '(list ,x (let ((x ,y))
      (list x ,z))))

有時無法滿足條件 4,5 和 6 的函數(shù)也能轉化為宏。不過,這些話題將在以后的章節(jié)里分別討論。其中,第 10.4 節(jié)會解決宏里遞歸引出的問題,而第 10.1 節(jié)和 10.2 節(jié)將會分別化解多重求值和求值順序不一致造成的危險。

至于條件 7,用宏模擬閉包并非癡人說夢,有種技術或許可以做到,它類似 3.4 節(jié)中提到的錯誤。但是由于這個辦法有些取巧,和本書中名門正派的作風不大協(xié)調,因此我們就此點到為止。

7.11 符號宏(symbol-macro)

CLTL2 為 Common Lisp 引入了一種新型宏,即符號宏(symbol-macro)。普通的宏調用看起來好像函數(shù)調用,而符號宏 "調用" 看起來則像一個符號。

符號宏只能在局部定義。symbol-macrolet?的?special form?可以在其體內,讓一個孤立符號的行為表現(xiàn)和表達式相似:

> (symbol-macrolet ((hi (progn (print "Howdy")
        1)))
  (+ hi 2))
"Howdy"
3

symbol-macrolet 主體中的表達式在求值的時候,效果就像每一個參數(shù)位置的?hi?在之前都替換成了?(progn (print "Howdy") 1)?。

從理論上講,符號宏就像不帶參數(shù)的宏。在沒有參數(shù)的時候,宏就成為了簡單的字面上的縮寫。不過,這并非是說符號宏一無是處。它們在第 15 章和第 18 章都用到了,而且在以后的例子中同樣不可或缺。

備注:

+【注 1】反引用也可以用于創(chuàng)建向量(vector),不過這個用法很少在宏定義里出現(xiàn)。

+【注 2】這個宏的定義稍微有些不自然,這是為了避免使用 gensym 。在第 11.3 節(jié)上有一個更好的定義。

+【注 3】譯者注:序列 (sequence) 是 Common Lisp 標準定義的數(shù)據(jù)類型,它的兩個子類型分別是列表(list)和向量(vector)。

+【注 4】譯者注:原子(atom) 也是 Common Lisp 標準定義的數(shù)據(jù)類型,所有不是列表的 Lisp 對象都是原子,包括向量(vector) 在內。

+【注 5】解構通常用在創(chuàng)建變量綁定,而非do 那樣的操作符里。盡管如此,概念上來講解構也是一種賦值的方式,如果你把列表解構到已有的變量而非新變量上是完全可行的。就是說,沒有什么可以阻止你用解構的方法來做類似setq 這樣的事情。

+【注 6】該版本用一種奇怪的方式來寫以避免使用 gensym ,這個操作符以后會詳細介紹。

+【注 7】關于這一區(qū)別實際有影響的例子,請參見第 4 章的注釋。

+【注 8】編譯時內聯(lián)(inline) 的函數(shù)除外,它們和宏的重定義受到相同的約束。

以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號