## 第 10 章 其他的宏陷阱
編寫宏需要格外小心。函數被隔離在它自己的詞法世界中,但是宏就另當別論了,因為它要被展開成進調用方的代碼,所以除非仔細編寫,否則它將會給用戶帶來意料之外的不便。第 9 章詳細說明了變量捕捉,它是這些不速之客中最常見的一個。本章將討論在編寫宏時需要避免的另外四個問題。
### 10.1 求值的次數
* * *
**[示例代碼 10.1] 控制參數求值**
正確的版本:
~~~
(defmacro for ((var start stop) &body body)
(let ((gstop (gensym)))
'(do ((,var ,start (1+ ,var))
(,gstop ,stop))
((> ,var ,gstop))
,@body)))
~~~
導致多重求值:
~~~
(defmacro for ((var start stop) &body body)
'(do ((,var ,start (1+ ,var)))
((> ,var ,stop))
,@body))
~~~
錯誤的求值順序:
~~~
(defmacro for ((var start stop) &body body)
(let ((gstop (gensym)))
'(do ((,gstop ,stop)
(,var ,start (1+ ,var)))
((> ,var ,gstop))
,@body)))
~~~
* * *
在上一章中出現了幾種錯誤的?`for`版本。[示例代碼 10.1] 給出了另外兩個,同時還帶有一個正確的版本方便對比。
盡管第二個?`for`并不那么容易發生變量捕捉,但是它還是有個 bug。它將生成一個展開式,在這個展開式里,作為?`stop`?傳遞的?`form`?在每次迭代時都會被求值。在最理想的情況下,這只會讓宏變得低效,重復做一些它本來可以只做一次的操作。如果?`stop`?有副作用,那么宏可能就會出人意料地產生錯誤的結果。例如,這個循環將永不終止,因為目標在每次迭代時都會倒退:
~~~
> (let ((x 2))
(for (i 1 (incf x))
(princ i)))
12345678910111213...
~~~
在編寫類似?`for`的宏的時候,必須牢記:宏的參數是?`form`,而非值。取決于它們出現在表達式中位置的不同,它們可能會被求值多次。在這種情況下,解決的辦法是把變量綁定到?`stop form`?的返回值上,并在循環過程中引用這個變量。
除非是為了迭代而有意為之,否則編寫宏的時候,應該確保表達式在宏調用里出現的次數和表達式求值的次數一致。很明顯,這個規則對有些情況并不適用:倘若參數總會被求值的話,Common Lisp 的?`or`?的用處就會大打折扣(那就成 Pascal 的?`or`?了)。但是在這種情況下用戶知道他們期望的求值次數。對于第二個版本的?`for`v來說就不是這樣了:用戶沒有理由會想要?`stop form`?被求值一次以上,而且事實上也不應該這樣做。一個宏要是寫成第二個版本的?`for`v那樣,十有八九就是弄錯了。
對基于?`setf`?的宏來說,無意的多重求值尤其難以處理。Common Lisp 提供了幾個實用工具以便編寫這樣的宏。具體的問題,以及解決方案,將在第 12 章里討論。
### 10.2 求值的順序
表達式求值的順序,雖然不像它們的求值次數那樣重要,但有時先后次序也會成為問題。在 Common Lisp 的函數調用中,參數是從左到右求值的:
~~~
> (setq x 10)
10
> (+ (setq x 3) x)
6
~~~
對于宏來說,最好也這樣處理。宏通常應該確保表達式求值的順序和它們在宏調用中出現的順序一致。
在 [示例代碼 10.1] 中,第三個版本的?`for`同樣有個難以覺察的 bug。參數?`stop`?將會在?`start`?前被求值,盡管它們在宏調用中出現的順序和求值的順序是相反的:
~~~
> (let ((x 1))
(for (i x (setq x 13))
(princ i)))
13
NIL
~~~
這個宏給人一種莫名其妙的錯覺,就好像時間會倒退一樣。盡管?`start form`?在代碼里面出現在先,但?`stop form`?的求值操作卻能影響?`start form`?的返回值。
正確版本的?`for`會確保其參數以它們出現的順序被求值:
~~~
> (let ((x 1))
(for (i x (setq x 13))
(princ i)))
12345678910111213
NIL
~~~
這里,在?`stop form`?里設置?`x`?的值就不會影響到前一個參數的返回值了。
盡管上面的例子是杜撰的,但是這類問題確實還會時有發生,而且這種 bug 很難找出來。或許很少有人會寫出這樣的代碼,讓宏一個參數的求值影響到另一個參數的返回值,但是人們在無意中做的事情,有可能并非出自本心。盡管在有意這樣用時,應當正常工作,但是這不是讓 bug 藏身于實用工具的理由。如果有人寫出的代碼和前例相似,它很可能是誤寫成的,但?`for`?的正確版本將使錯誤更容易檢測出來。
### 10.3 非函數式的展開器
Lisp 期望那些生成宏展開式的代碼都是純函數式的,就像第 3 章里說的那樣。展開器代碼除了作為參數傳給它的?`form`?之外不應該有其他依賴,并且它影響外界的唯一渠道只能是它的返回值。
如 CLTL2(685 頁)所述,可以確信,在編譯代碼中的宏調用將不會在運行期重新展開。另一方面,Common Lisp 對宏調用展開的時機,和展開的次數并沒有作出保證。如果一個宏的展開式會因上面的兩個因素而不同的話,那么就可以認為這個宏是有問題的。例如,假設我們想要統計某個宏的使用次數。我們不能直接對源文件搜索一遍了事,因為在由程序生成的代碼里也可能會調用這個宏。所以,我們可能會這樣定義這個宏:
~~~
(defmacro nil! (x) ; wrong
(incf *nil!s*)
'(setf ,x nil))
~~~
使用這個定義,使得每次展開?`nil!`?的調用時,全局的?`\*nil!s\*`?的值都會遞增。然而,如果我們認為這個變量的值能告訴我們?`nil!`?被調用的次數,那就大錯特錯了。一個宏調用可以,并且經常會被展開不只一次。
例如,一個對你代碼進行變換的預處理器在它決定是否變換代碼之前,可能不得不展開表達式中的宏調用。
這是一條普適的規則,即:展開器代碼除其參數外不應依賴其他任何東西。所以任何宏,比如說通過字符串來構造展開式的那種,應當小心不要對宏展開時所在的包作任何假設。下面的這個例子雖說簡單,但相當有代表性,
~~~
(defmacro string-call (opstring &rest args) ; wrong
'(,(intern opstring) ,@args))
~~~
它定義了一個宏,這個宏接受一個操作符的打印名稱,并把它展開成對該操作符的調用:
~~~
> (defun our+ (x y) (+ x y))
OUR+
> (string-call "OUR+" 2 3)
5
~~~
對?`intern`?的調用接受一個字符串,并返回對應的符號。盡管如此,如果我們省略了可選的包參數,它將在當前包里尋找符號。該展開式將因此依賴于展開式生成時所在的包,并且除非?`our+`?在那個包里可見,否則展開式將是一個對未知符號的調用。
展開式代碼中的副作用有時會帶來一些問題,Miller 和 Benson 在?`<<Lisp Style`and`Design>>`?一書中就為之舉了一個非常丑陋的例子。CLTL2(78 頁)提到,Common Lisp 并不保證綁定在`&rest`?形參上的列表是新生成的。
它們可能會和程序其他地方的列表共享數據結構。后果就是,你不能破壞性地修改?`&rest`?形參,因為你不知道你將會改掉其他什么東西。
這種可能性對于函數和宏都有影響。對于函數來說,問題出在使用?`apply`?的時候。在合格的 Common Lisp 實現中,將發生下面的事情。假設我們定義一個函數?`et-al`?,它會在它的參數列表末尾加上?`'et 'al`?,再返回它:
~~~
(defun et-al (&rest args)
(nconc args (list 'et 'al)))
~~~
如果我們像平時那樣調用這個函數,它看起來工作正常:
~~~
> (et-al 'smith 'jones)
(SMITH JONES ET AL)
~~~
然而,要是我們通過?`apply`?調用它,就會改動已有的數據:
~~~
> (setq greats '(leonardo michelangelo))
(LEONARDO MICHELANGELO)
> (apply #'et-al greats)
(LEONARDO MICHELANGELO ET AL)
> greats
(LEONARDO MICHELANGELO ET AL)
~~~
至少 Common Lisp 的正確實現應該會這樣反應,雖然到目前為止沒有一個是這樣做的。
對宏來說就更危險了。如果一個宏會修改它的?`&rest`?形參,那它可能會因此改掉整個宏調用。這就是說,最終你可能寫出一個難以察覺的自我重寫的程序。這種危險也更有現實意義 -- 它實實在在地發生在現有的實現中。如果我們定義一個宏,它將某些東西?`nconc`?到它的?`&rest`?參數里: 【注 1】
~~~
(defmacro echo (&rest args)
'',(nconc args (list 'amen)))
~~~
然后定義一個函數來調用它:
~~~
(defun foo () (echo x))
~~~
在一個廣泛使用的 Common Lisp 中,則會觀察到下面的現象:
~~~
> (foo)
(X AMEN AMEN)
> (foo)
(X AMEN AMEN AMEN)
~~~
不只是?`foo`?返回了錯誤的結果,它甚至每次返回的結果都不一樣,因為每一次宏展開都替換了?`foo`的定義。
這個例子同時也闡述了之前提到的一個觀點:一個宏可能會被展開多次。在這個實現里,第一次調用`foo`?返回的是含有兩個?`amen`?的列表。出于某種原因,該實現在?`foo`?被定義時就做了一次宏展開,然后接下來每次調用時都會再展開一次。
將?`foo`?定義成這樣會更安全一些:
~~~
(defmacro echo (&rest args)
''(,@args amen))
~~~
因為?`comma-at`?等價于?`append`?而非?`nconc`?。在重定義這個宏之后,`foo`?也需要重新定義一下,就算它沒有編譯也是一樣,因為?`echo`?的前一個版本導致它把自己重寫了。
對宏來說,受到這種危險威脅的不單單是 &rest 參數。任何宏參數只要是列表就應該單獨對待。如果我們定義了一個會修改其參數的宏,以及一個調用該宏的函數,
~~~
(defmacro crazy (expr) (nconc expr (list t)))
(defun foo () (crazy (list)))
~~~
那么主調函數的源代碼就有可能被修改,正如在一個實現里,我們首次調用時所看到的:
~~~
> (foo)
(T T)
~~~
和解釋代碼一樣,這種情況在編譯的代碼里也會發生。
結論是,不要試圖通過破壞性修改參數列表結構,來避免構造?`consing`?。這樣得到的程序就算可以工作也將是不可移植的。如果你真想在接受變長參數的函數中避免`consing`?,一種解決方案是使用宏,由此將?`consing`?切換到編譯期。對于宏的這種應用,可見第 13 章。
宏展開器返回的表達式含有引用列表的話,就應該避免對它進行破壞性的操作。就其本身而言,這不只是對于宏的限制,而是第 3.3 節中提出原則的一個實例。
### 10.4 遞歸
有時會自然而然地把一個函數定義成遞歸的。而有些函數天生就是遞歸的,如下:
~~~
(defun our-length (x)
(if (null x)
0
(1+ (our-length (cdr x)))))
~~~
這樣定義從某種程度來說,比等價的迭代形式看起來更自然一些(盡管可能也更慢一些):
~~~
(defun our-length (x)
(do ((len 0 (1+ len))
(y x (cdr y)))
((null y) len)))
~~~
一個既不遞歸,也不屬于某個多重遞歸函數集合的函數,可以通過第 7.10 節描述的簡單技術被轉換為一個宏。然而,僅是插入反引用和逗號對遞歸函數是無效的。讓我們以內置的?`nth`?為例。(為簡單起見,這個版本的?`nth`?將不做錯誤檢查。)[示例代碼 10.2] 給出了一個將?`nth`?定義成宏的錯誤嘗試。表面上看?`nthb`?似乎和?`ntha`?等價,但是一個包含對?`nthb`?調用的程序將不能編譯,因為對該調用的展開過程無法終止。
* * *
**[示例代碼 10.2] 對遞歸函數的錯誤類比**
這個可以工作:
~~~
(defun ntha (n lst)
(if (= n 0)
(car lst)
(ntha (- n 1) (cdr lst))))
~~~
這個不能編譯:
~~~
(defmacro nthb (n lst)
'(if (= ,n 0)
(car ,lst)
(nthb (- ,n 1) (cdr ,lst))))
~~~
* * *
一般而言,是允許宏里含有對另一個宏的引用的,只要展開過程會最終停止就可以。`nthb`?的麻煩之處在于每次的展開都含有一個對其本身的引用。函數版本,`ntha`?,之所以會終止因為它在?`n`?的值上遞歸,這個值在每次遞歸中減小。但是宏展開式只能訪問到?`form`,而不是它們的值。當編譯器試圖宏展開,比如說,`(nthb x y)`?時,第一次展開將得到:
~~~
(if (= x 0)
(car y)
(nthb (- x 1) (cdr y)))
~~~
然后又會被展開成:
~~~
(if (= x 0)
(car y)
(if (= (- x 1) 0)
(car (cdr y))
(nthb (- (- x 1) 1) (cdr (cdr y)))))
~~~
如此這般地進入無限循環。一個宏展開成對自身的調用是可以的,但不是這么用的。
像?`nthb`?這樣的遞歸宏,其真正危險之處在于它們通常在解釋器里工作正常。而當你最終將程序跑起來,接著想編譯它的時候,它甚至無法通過編譯。非但如此,常常還沒有提示,告訴我們問題出自一個遞歸的宏; 相反,編譯器只會陷入無限循環,讓你來找出究竟哪里搞錯了。
在本例中,`ntha`?是尾遞歸的。尾遞歸函數可以輕易轉換成與之等價的迭代形式,然后用作宏的模型。一個像?`nthb`?的宏可以寫成:
~~~
(defmacro nthc (n lst)
'(do ((n2 ,n (1- n2))
(lst2 ,lst (cdr lst2)))
((= n2 0) (car lst2))))
~~~
所以從理論上說,把遞歸函數改造成宏也并非不可能。但是,要轉換更復雜的遞歸函數可能會比較困難,甚至無法做到。
這取決于你要宏做什么,有時候你可能會發現改成宏和函數的組合就夠用了。[示例代碼 10.3] 給出了兩種方式,可用來生成表面上似乎遞歸的宏。第一種策略就在?`nthd`?里面,它直接讓宏展開成為一個對遞歸函數的調用。
舉個例子,如果你使用宏的目的,僅僅是希望幫助用戶避免引用參數的麻煩,那么這種方法就可以勝任了。
* * *
**[示例代碼 10.3] 解決問題的兩個辦法**
~~~
(defmacro nthd (n lst)
'(nth-fn ,n ,lst))
(defun nth-fn (n lst)
(if (= n 0)
(car lst)
(nth-fn (- n 1) (cdr lst))))
(defmacro nthe (n lst)
'(labels ((nth-fn (n lst)
(if (= n 0)
(car lst)
(nth-fn (- n 1) (cdr lst)))))
(nth-fn ,n ,lst)))
~~~
* * *
如果你使用宏的目的,是想要將其展開式嵌入到宏調用的詞法環境中,那么你更可能會采用?`nthe`?一例中的方案。其中,內置的?`labels special form`?(見 2.7 節) 會創建一個局部函數定義。和`nthd`?每次展開都會調用全局定義的函數?`nth-fn`?不同,`nthe`?每個展開式里的函數都用的是該展開式自己定制的版本。
盡管你無法將遞歸函數直接轉化成宏,你卻可以寫出一個宏,讓它的展開式是遞歸生成的。宏的展開函數就是普通的 Lisp 函數,理所當然也是可以遞歸的。例如,如果我們想自己定義內置?`or`?,那么就會用到一個遞歸展開的函數。
* * *
**[示例代碼 10.4] 遞歸的展開函數**
~~~
(defmacro ora (&rest args)
(or-expand args))
(defun or-expand (args)
(if (null args)
nil
(let ((sym (gensym)))
'(let ((,sym ,(car args)))
(if ,sym
,sym
,(or-expand (cdr args)))))))
(defmacro orb (&rest args)
(if (null args)
nil
(let ((sym (gensym)))
'(let ((,sym ,(car args)))
(if ,sym
,sym
(orb ,@(cdr args)))))))
~~~
* * *
[示例代碼 10.4] 給出的兩個?`or`?定義,它們的內部實現都是遞歸地展開函數。宏?`ora`?調用遞歸函數`or-expand`?來生成展開式。這個宏能正常工作,并且與之等價的?`orb`?也一樣可以完成任務。盡管`orb`?是遞歸的,但它是在宏的參數個數上做遞歸(這在宏展開期可以得到),而不依賴于它們的值(這在宏展開期無法得到)。也許,初看之下它的展開式里應該有一個對?`orb`?自己的引用,其實不然,`orb`?宏的展開,將會需要多步才能完成。【注 2】
每一步宏展開都會生成一個對?`orb`?的調用,這個調用將在下一步展開時替換成一個?`let`?,最后表達式里得到的則是一層套一層的?`let;(orb x y)`?展開成的代碼和下式等價:
~~~
(let ((g2 x))
(if g2
g2
(let ((g3 y))
(if g3 g3 nil))))
~~~
事實上,`ora`?和?`orb`?是等價的,具體使用哪種風格不過是個人的喜好。
備注:
【注 1】'',(foo) 和 '(quote ,(foo)) 等價。
【注 2】譯者注:這里改掉一個原書錯誤,`nthc`?應為?`nthd`?。
- 封面
- 譯者序
- 前言
- 第 1 章 可擴展語言
- 第 2 章 函數
- 第 3 章 函數式編程
- 第 4 章 實用函數
- 第 5 章 函數作為返回值
- 第 6 章 函數作為表達方式
- 第 7 章 宏
- 第 8 章 何時使用宏
- 第 9 章 變量捕捉
- 第 10 章 其他的宏陷阱
- 第 11 章 經典宏
- 第 12 章 廣義變量
- 第 13 章 編譯期計算
- 第 14 章 指代宏
- 第 15 章 返回函數的宏
- 第 16 章 定義宏的宏
- 第 17 章 讀取宏(read-macro)
- 第 18 章 解構
- 第 19 章 一個查詢編譯器
- 第 20 章 續延(continuation)
- 第 21 章 多進程
- 第 22 章 非確定性
- 第 23 章 使用 ATN 分析句子
- 第 24 章 Prolog
- 第 25 章 面向對象的 Lisp
- 附錄: 包(packages)