第 7 章 宏

2018-02-24 15:54 更新

第 7 章 宏

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

第 7-10 章將帶你走入宏的世界。本章會(huì)解釋宏如何工作,介紹編寫(xiě)和調(diào)試它們的技術(shù),然后分析一些宏風(fēng)格中存在的問(wèn)題。

7.1 宏是如何工作的

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

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

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

用漢語(yǔ)轉(zhuǎn)述的話,這個(gè)定義相當(dāng)于告訴 Lisp: "無(wú)論何時(shí),只要看到形如?(nil!)?的表達(dá)式,請(qǐng)?jiān)谇笾抵跋劝阉D(zhuǎn)化成?(setq nil)?的形式。"

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

  1. 按照上述定義的要求構(gòu)造表達(dá)式,接著,

  2. 在調(diào)用宏的地方求值該表達(dá)式。

構(gòu)造新表達(dá)式的那一步被稱為宏展開(kāi)(macro expansion)。Lisp 查找?nil!?的定義,其定義展示了如何為宏調(diào)用構(gòu)建一個(gè)即將取代它的表達(dá)式。和函數(shù)一樣,nil!?的定義也應(yīng)用到了宏調(diào)用傳給它的表達(dá)式參數(shù)上。

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

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

如果把宏的展開(kāi)和求值分清楚,你遇到的和宏有關(guān)的困難,或許有很多就能避免。當(dāng)編寫(xiě)宏的時(shí)候,要清楚哪些操作是在宏展開(kāi)期進(jìn)行的,而哪些操作是在求值期進(jìn)行的,通常,這兩步操作的對(duì)象截然不同。宏展開(kāi)步驟處理的是表達(dá)式,而求值步驟處理的則是它們的值。

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

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

有能力改變編譯器所看到的東西,差不多等同于能夠?qū)Υa進(jìn)行重寫(xiě)。所以我們就可以為語(yǔ)言增加任何構(gòu)造,只要用變換的方法把它定義成已有的構(gòu)造。

7.2 反引用(backquote)

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

反引用字符 得名的原因是:它和通常的引號(hào)?'`?相似,只不過(guò)方向相反。當(dāng)單獨(dú)把反引用作為表達(dá)式前綴的時(shí)候,它的行為和引號(hào)一樣:

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

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

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

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

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

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

> (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)))

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

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

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

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

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

借助反引用,這個(gè)宏可以定義成:

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

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

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


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

使用反引用:

(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)))

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

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

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

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

因?yàn)檫@個(gè)宏定義體的模樣就和它生成的宏展開(kāi)式差不多。要想理解不使用反引用的第二個(gè)版本,你將不得不在腦海中重演一遍展開(kāi)式的構(gòu)造過(guò)程。

comma-at,即?,@,是逗號(hào)的變形,其行為和逗號(hào)相似,但有一點(diǎn)不同:comma-at?不像逗號(hào)那樣僅僅把表達(dá)式的值插入到所在的位置,而是把表達(dá)式拼接進(jìn)去。拼接這個(gè)操作可以這樣理解:在插入的同時(shí),剝?nèi)ケ徊迦雽?duì)象最外層的括號(hào):

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

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

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

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

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

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

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

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

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

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

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

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

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

`(a ,@b c)

就和:

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

等價(jià)。之所以用上?comma-at,只是為了改進(jìn)這種由表達(dá)式生成的表達(dá)式的可讀性。

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

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

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

7.3 定義簡(jiǎn)單的宏

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

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

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

(member x choices :test #'eq)

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

(memq x choices)

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


[示例代碼 7.2] 用于寫(xiě) memq 的圖示

調(diào)用:

 (memq x choices)

展開(kāi):

(member x choices :test #'eq)

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

(defmacro memq (obj lst)

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

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

  1. 如果沒(méi)有線將它和宏調(diào)用相連,那么就把表達(dá)式本身寫(xiě)下來(lái)。

  2. 如果存在一條跟宏調(diào)用中某個(gè)參數(shù)的連接,就把出現(xiàn)在宏參數(shù)列表的對(duì)應(yīng)位置的那個(gè)符號(hào)寫(xiě)下來(lái),前置一個(gè)逗號(hào)。

由于第一個(gè)元素?member?上沒(méi)有連接,所以我們照原樣使用?member?:

(defmacro memq (obj lst)
  '(member

不過(guò),x?上有一條線指向源表達(dá)式中的第一個(gè)實(shí)參,所以我們?cè)诤甑闹黧w中使用第一個(gè)參數(shù),帶一個(gè)逗號(hào):

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

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


[示例代碼 7.3] 用于寫(xiě) 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))

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

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

(defmacro while (test &body body)

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

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

  1. 如果在一系列展開(kāi)式中的表達(dá)式和宏調(diào)用里的一系列形參之間存在聯(lián)系,那么就把對(duì)應(yīng)的?&rest或?&body?實(shí)參記下來(lái),在前面加上?comma-at

于是宏定義的結(jié)果將是:

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

要想構(gòu)造帶有表達(dá)式體的宏,就必須有參數(shù)充當(dāng)打包裝箱的角色。這里宏調(diào)用中的多個(gè)參數(shù)被串起來(lái)放到?body里,然后在?body?被拼接進(jìn)展開(kāi)式時(shí),再把它拆散開(kāi)。

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

7.4 測(cè)試宏展開(kāi)

宏寫(xiě)好了,那我們?cè)趺礈y(cè)試它呢?像?memq?這樣的宏,它的結(jié)構(gòu)較簡(jiǎn)單,只消看看它的代碼就能弄清其行為方式。而當(dāng)編寫(xiě)結(jié)構(gòu)更復(fù)雜的宏時(shí),我們必須有辦法檢查它們展開(kāi)之后正確與否。

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

從[示例代碼 7.4] 給出的第一個(gè)表達(dá)式,很難看出?while?是否如愿展開(kāi),因?yàn)椴粌H內(nèi)置的宏 do 被展開(kāi)了,而且它里面的?prog?宏也展開(kāi)了。我們需要一種方法,通過(guò)它能看到只展開(kāi)過(guò)一層宏的展開(kāi)結(jié)果。這就是內(nèi)置函數(shù)?macroexpand-1?的目的,正如第二個(gè)例子所示。就算展開(kāi)后,得到的結(jié)果仍然是宏調(diào)用,macroexpand-1?也只做一次宏展開(kāi)就停手。


[示例代碼 7.4] 一個(gè)宏和它的兩級(jí)展開(kāi)

> (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] 一個(gè)用于測(cè)試宏展開(kāi)的宏

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

如果每次查看宏調(diào)用的展開(kāi)式都得輸入如下的表達(dá)式,這會(huì)讓人很頭痛:

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

[示例代碼 7.5] 定義了一個(gè)新的宏,它讓我們有一個(gè)簡(jiǎn)單的替代方法:

(mac (or x y))

調(diào)試函數(shù)的典型方法是調(diào)用它們,同樣的道理,對(duì)于宏來(lái)說(shuō)就是展開(kāi)它們。不過(guò)由于宏調(diào)用涉及了兩次計(jì)算,所以它也就有兩處可能會(huì)出問(wèn)題。如果一個(gè)宏行為不正常,大多數(shù)時(shí)候你只要檢查它的展開(kāi)式,就能找出有錯(cuò)的地方。不過(guò)也有一些時(shí)候,展開(kāi)式看起來(lái)是對(duì)的,所以你想對(duì)它進(jìn)行求值以便找出問(wèn)題所在。

如果展開(kāi)式里含有自由變量,你可能需要先設(shè)置一些變量。在某些系統(tǒng)里,你可以復(fù)制展開(kāi)式,把它粘貼到 toplevel 環(huán)境里,或者選擇它然后在菜單里選 eval。在最壞的情況下你也可以把 macroexpand-1 返回的列表設(shè)置在一個(gè)變量里,然后對(duì)它調(diào)用 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)

最后,宏展開(kāi)不只是調(diào)試的輔助手段,它也是一種學(xué)習(xí)如何編寫(xiě)宏的方式。Common Lisp 帶有超過(guò)一百個(gè)內(nèi)置宏,其中一些還頗為復(fù)雜。通過(guò)查看這些宏的展開(kāi)過(guò)程你經(jīng)常能了解它們是怎樣寫(xiě)出來(lái)的。

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

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

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

當(dāng)調(diào)用該函數(shù)時(shí):

(foo 1 2 3)

函數(shù)調(diào)用中實(shí)參會(huì)按照參數(shù)位置的對(duì)應(yīng)關(guān)系,賦值給函數(shù)的形參:1?賦給?x?,2?賦給?y?,3?賦給?z?。和本例中扁平列表?(x y z)?的情形類似,解構(gòu)(destructuring) 同樣也指定了按位置賦值的方式,不過(guò)它能按照任意一種列表結(jié)構(gòu)來(lái)進(jìn)行賦值。

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

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

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

在宏參數(shù)列表里進(jìn)行解構(gòu)也是可能的。Common Lisp 的?defmacro?宏允許任意列表結(jié)構(gòu)作為參數(shù)列表。當(dāng)宏調(diào)用被展開(kāi)時(shí),宏調(diào)用中的各部分將會(huì)以類似 destructuring-bind 的方式被賦值到宏的參數(shù)上面。內(nèi)置的?dolist?宏就利用了這種參數(shù)列表的解構(gòu)技術(shù)。在一個(gè)像這樣的調(diào)用里:

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

展開(kāi)函數(shù)必須把?x?和?'(a b c)?從作為第一個(gè)參數(shù)給出的列表里抽取出來(lái)。這個(gè)任務(wù)可以通過(guò)給dolist?適當(dāng)?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ù)包在一個(gè)列表里面,而后者不屬于宏體。由于?dolist?接受一個(gè)可選的?result?參數(shù),所以它無(wú)論如何都必須把它參數(shù)的第一部分塞進(jìn)一個(gè)單獨(dú)的列表。但就算這個(gè)多余的列表結(jié)構(gòu)是畫(huà)蛇添足,它也可以讓?dolist?調(diào)用更易于閱讀。假設(shè)我們想要定義一個(gè)宏?when-bind?,它的功能和?when?差不多,除此之外它還能綁定一些變量到測(cè)試表達(dá)式返回的值上。這個(gè)宏最好的實(shí)現(xiàn)辦法可能會(huì)用到一個(gè)嵌套的參數(shù)表:

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

然后這樣調(diào)用:

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

而不是原本這樣調(diào)用:

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

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

7.6 宏的工作模式

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


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

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

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

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

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

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

7.7 作為程序的宏

宏定義并不一定非得是個(gè)反引用列表。宏的本質(zhì)是函數(shù),它把一個(gè)表達(dá)式轉(zhuǎn)換成另一個(gè)表達(dá)式。這個(gè)函數(shù)可以調(diào)用?list?來(lái)生成結(jié)果,但是同樣也可以調(diào)用一整個(gè)長(zhǎng)達(dá)數(shù)百行代碼的子程序達(dá)到這個(gè)目的。

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

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


[示例代碼 7.7] do 的預(yù)期展開(kāi)過(guò)程

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

應(yīng)該被展開(kāi)成如下的樣子:

(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?的一個(gè)實(shí)例,以及它應(yīng)該展開(kāi)成的表達(dá)式。手工進(jìn)行展開(kāi)有助于理清你對(duì)于宏工作方式的認(rèn)識(shí)。例如,在試著寫(xiě)展開(kāi)式時(shí),你就不得不使用?psetq?來(lái)更新局部變量,如果沒(méi)有手工寫(xiě)過(guò)展開(kāi)式,說(shuō)不定就會(huì)忽視這一點(diǎn)。

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

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

這里,因?yàn)橄仍O(shè)置的是?a?,所以?b?得到了它的新值,即?2?。而調(diào)用?psetq?時(shí),應(yīng)該就好像參數(shù)的賦值操作是并行的一樣:

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

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

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


[示例代碼 7.8] 實(shí)現(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))

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

[示例代碼 7.8] 中的代碼并不完全是?do?在真正的實(shí)現(xiàn)里的寫(xiě)法。為了強(qiáng)調(diào)在宏展開(kāi)過(guò)程中完成的計(jì)算,make-initforms?和?make-stepforms?被分離出來(lái),成為了單獨(dú)的函數(shù)。在將來(lái),這樣的代碼通常會(huì)留在?defmacro?表達(dá)式里。

通過(guò)這個(gè)宏的定義,我們開(kāi)始領(lǐng)教到宏的能耐了。宏在構(gòu)造表達(dá)式時(shí),可以使用Lisp 所有的功能。而用來(lái)生成展開(kāi)式的代碼,其自身就可以是一個(gè)程序。

7.8 宏風(fēng)格

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

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

效率,只有在編譯了的代碼里才是最重要的,而在編譯了的代碼里宏調(diào)用已經(jīng)被展開(kāi)了。就算展開(kāi)器代碼很高效,它也只會(huì)使得代碼的編譯過(guò)程稍微快一些,但這對(duì)程序運(yùn)行的效率沒(méi)有任何影響。

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

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

宏定義通常比函數(shù)定義更難以閱讀,因?yàn)楹甓x里含有兩種表達(dá)式的混合體,它們將在不同的時(shí)刻求值。

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


[示例代碼 7.9] 兩個(gè)等價(jià)于 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))))

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

(and a b c)

等價(jià)于:

(if a (if b c))

我們可以像 [示例代碼 7.9] 中的第一個(gè)定義那樣,用?if?來(lái)實(shí)現(xiàn)?and?。根據(jù)我們?cè)u(píng)判普通代碼的標(biāo)準(zhǔn),our-and?寫(xiě)得并不好。因?yàn)樗恼归_(kāi)器代碼是遞歸的,而且在每次遞歸里都要需要計(jì)算同一個(gè)列表的每個(gè)后繼?cdr?的長(zhǎng)度。

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

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

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

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

(b) 也不會(huì)影響太多

換句話說(shuō),代碼的可讀性幾乎總是應(yīng)該放在第一位。

對(duì)于展開(kāi)式代碼來(lái)說(shuō),正好相反。對(duì)宏展開(kāi)式來(lái)說(shuō),代碼可讀與否不太重要,因?yàn)楹苌儆腥藭?huì)去讀它,而別人讀這種代碼的可能性更是微乎其微。平時(shí)嚴(yán)禁使用的?goto?在展開(kāi)式里可以網(wǎng)開(kāi)一面,備受冷眼的?setq?也可以稍微抬起頭來(lái)。

結(jié)構(gòu)化編程的擁護(hù)者不喜歡源代碼里的?goto。他們心目中的洪水猛獸并非機(jī)器語(yǔ)言里的跳轉(zhuǎn)指令 前提是這些跳轉(zhuǎn)指令是通過(guò)更抽象的控制結(jié)構(gòu)隱藏在源代碼里的。在 Lisp 里,goto?之所以備受責(zé)難,其實(shí)是因?yàn)楹苋菀装阉仄饋?lái):你可以改用?do?,而且就算你沒(méi)有?do?可用,還可以自己寫(xiě)一個(gè)。很明顯,如果你打算在?goto?的基礎(chǔ)上構(gòu)建新抽象,goto?一定會(huì)存在于某些地方。因而,在新的宏定義中使用?goto?未必不好,前提是它不能用現(xiàn)成的宏來(lái)寫(xiě)。

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

在某些場(chǎng)合下,展開(kāi)式代碼的清晰性更重要一些。如果你在編寫(xiě)一個(gè)復(fù)雜的宏,你可能最后還是得閱讀它的展開(kāi)式,至少在調(diào)試的時(shí)候。

同樣,在簡(jiǎn)單的宏里,只有一個(gè)反引用用來(lái)把展開(kāi)器代碼和展開(kāi)式代碼分開(kāi),所以,如果這樣的宏生成了難看的展開(kāi)式,那么這種慘不忍睹的代碼在你的源代碼里將會(huì)一覽無(wú)余。

盡管如此,就算對(duì)展開(kāi)式代碼的可讀性有了要求,效率仍然應(yīng)該放在第一位。效率于大多數(shù)運(yùn)行時(shí)代碼都至關(guān)重要。而對(duì)宏展開(kāi)來(lái)說(shuō)尤為如此,這里有兩個(gè)原因:宏的普遍性和不可見(jiàn)性。

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

這樣的宏得到的重視應(yīng)當(dāng)比因?yàn)樗鼈兊拈L(zhǎng)度所獲得的重視更多才對(duì)。

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

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

7.9 宏的依賴關(guān)系

如果你重定義了一個(gè)函數(shù),調(diào)用它的函數(shù)會(huì)自動(dòng)用上新的版本【注 8】。 不過(guò),這個(gè)說(shuō)法對(duì)宏來(lái)說(shuō)可就不一定成立了。當(dāng)函數(shù)被編譯時(shí),函數(shù)定義中的宏調(diào)用就會(huì)替換成它的展開(kāi)式。如果我們?cè)谥髡{(diào)函數(shù)編譯以后,重定義那個(gè)宏會(huì)發(fā)生什么呢?由于對(duì)最初的宏調(diào)用的無(wú)跡可尋,所以函數(shù)里的展開(kāi)式無(wú)法更新。該函數(shù)的行為將繼續(xù)反映出宏的原來(lái)的定義:

> (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)編譯了宏的調(diào)用代碼,也會(huì)發(fā)生類似的問(wèn)題。CLTL2?這樣要求,"宏定義必須在其首次使用之前被編譯器看到"。各家實(shí)現(xiàn)對(duì)違反這個(gè)規(guī)則的反應(yīng)各自不同。幸運(yùn)的是,這兩類問(wèn)題都能很容易地避免。如果能滿足下面兩個(gè)條件,你就永遠(yuǎn)不會(huì)因?yàn)檫^(guò)時(shí)或者不存在的宏定義而煩心:

  1. 在調(diào)用宏之前,先定義它。

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

有些人建議將程序中所有的宏都放在一個(gè)單獨(dú)的文件里,以便保證宏定義被首先編譯。這樣有點(diǎn)過(guò)頭了。

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

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

7.10 來(lái)自函數(shù)的宏

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

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

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

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

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

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

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

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

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

7. 無(wú)自由變量。

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

(defun second (x) (cadr x))

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

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

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

倘若函數(shù)體里的表達(dá)式不止一個(gè),就要把這個(gè)技術(shù)稍加變通,因?yàn)楹瓯仨氄归_(kāi)成單獨(dú)的表達(dá)式。所以無(wú)法滿足條件1,你必須加上一個(gè)?progn?。

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

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

的功能也可以用下面的宏來(lái)完成:

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

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

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

就變成了:

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

不過(guò)上面的宏如果改成這樣寫(xiě)會(huì)更好些:

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

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

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

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

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

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

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

7.11 符號(hào)宏(symbol-macro)

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

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

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

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

從理論上講,符號(hào)宏就像不帶參數(shù)的宏。在沒(méi)有參數(shù)的時(shí)候,宏就成為了簡(jiǎn)單的字面上的縮寫(xiě)。不過(guò),這并非是說(shuō)符號(hào)宏一無(wú)是處。它們?cè)诘?15 章和第 18 章都用到了,而且在以后的例子中同樣不可或缺。

備注:

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

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

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

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

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

+【注 6】該版本用一種奇怪的方式來(lái)寫(xiě)以避免使用 gensym ,這個(gè)操作符以后會(huì)詳細(xì)介紹。

+【注 7】關(guān)于這一區(qū)別實(shí)際有影響的例子,請(qǐng)參見(jiàn)第 4 章的注釋。

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

以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)