第 10 章 其他的宏陷阱

2018-02-24 15:54 更新

第 10 章 其他的宏陷阱

編寫宏需要格外小心。函數(shù)被隔離在它自己的詞法世界中,但是宏就另當(dāng)別論了,因為它要被展開成進(jìn)調(diào)用方的代碼,所以除非仔細(xì)編寫,否則它將會給用戶帶來意料之外的不便。第 9 章詳細(xì)說明了變量捕捉,它是這些不速之客中最常見的一個。本章將討論在編寫宏時需要避免的另外四個問題。

10.1 求值的次數(shù)


[示例代碼 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ù)。對于第二個版本的?forv來說就不是這樣了:用戶沒有理由會想要?stop form?被求值一次以上,而且事實(shí)上也不應(yīng)該這樣做。一個宏要是寫成第二個版本的?forv那樣,十有八九就是弄錯了。

對基于?setf?的宏來說,無意的多重求值尤其難以處理。Common Lisp 提供了幾個實(shí)用工具以便編寫這樣的宏。具體的問題,以及解決方案,將在第 12 章里討論。

10.2 求值的順序

表達(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?的正確版本將使錯誤更容易檢測出來。

10.3 非函數(shù)式的展開器

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 StyleandDesign>>?一書中就為之舉了一個非常丑陋的例子。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í)例。

10.4 遞歸

有時會自然而然地把一個函數(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?。

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

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號