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