第 9 章 變量捕捉

2018-02-24 15:54 更新

第 9 章 變量捕捉

宏很容易遇到一類被稱為變量捕捉的問題。變量捕捉發(fā)生在宏展開導(dǎo)致名字沖突的時候,名字沖突指:某些符號結(jié)果出乎意料地引用了來自另一個上下文中的變量。無意的變量捕捉可能會造成極難發(fā)覺的 bug。

本章將介紹預(yù)見和避免它們的辦法。不過,有意的變量捕捉卻也是一種有用的編程技術(shù),而且第 14 章的宏都是靠這種技術(shù)實現(xiàn)的。

9.1 宏參數(shù)捕捉

如果一個宏對無意識的變量捕捉毫無防備,那么它就是有 bug 的宏。為了避免寫出這樣的宏,我們必須確切地知道捕捉發(fā)生的時機(jī)。變量捕捉可以分為兩類情況:

宏參數(shù)捕捉自由符號捕捉。

所謂宏參數(shù)捕捉,就是在宏調(diào)用中作為參數(shù)傳遞的符號無意地引用到了宏展開式本身建立的變量??紤]下面這個?for?宏的定義,它像?Pascal?的?for?在一系列表達(dá)式上循環(huán)操作:

(defmacro `for` ((var start stop) &body body)
  '(do ((,var ,start (1+ ,var))
      (limit ,stop))
    ((> ,var limit))
    ,@body))

這個宏乍看之下沒有問題。它甚至似乎也可以正常工作:

> (for (x 1 5)
  (princ x))
12345
NIL

確實,這個錯誤如此隱蔽,可能用上這個版本的宏數(shù)百次,都毫無問題。但如果我們這樣調(diào)用它,問題就出來了:

(for (limit 1 5)
  (princ limit))

我們可能會認(rèn)為這個表達(dá)式和之前的結(jié)果相同。但它卻沒有任何輸出:它產(chǎn)生了一個錯誤。為了找到原因,我們仔細(xì)觀察它的展開式:

(do ((limit 1 (1+ limit))
    (limit 5))
  ((> limit limit))
  (print limit))

現(xiàn)在錯誤的地方就很明顯了。在宏展開式本身的符號和作為參數(shù)傳遞給宏的符號之間出現(xiàn)了名字沖突。宏展開捕捉了?limit。這導(dǎo)致它在同一個?do?里出現(xiàn)了兩次,而這是非法的。

由變量捕捉導(dǎo)致的錯誤比較罕見,但頻率越低其性質(zhì)就越惡劣。上個捕捉相對還比較溫和, 至少這次我們得到了一個錯誤。更普遍的情況是,捕捉了變量的宏只是產(chǎn)生錯誤的結(jié)果,卻沒有給出任何跡象顯示問題的源頭。在下面的例子中:

> (let ((limit 5))
  (for (i 1 10)
    (when (> i limit)
      (princ i))))
NIL

產(chǎn)生的代碼靜悄悄地什么也不做。

9.2 自由符號捕捉

偶爾會出現(xiàn)這樣的情況,宏定義本身有這么一些符號,它們在宏展開時無意中卻引用到了其所在環(huán)境中的綁定。假設(shè)有個程序,它希望把運行中產(chǎn)生的警告信息保存在一個列表里供事后檢查,而不是在問題發(fā)生時直接打印輸出給用戶。于是有人寫了一個宏?gripe?,它接受一個警告信息,并把它加入全局列表?w?:

(defvar w nil)

(defmacro gripe (warning) ; wrong
  '(progn (setq w (nconc w (list ,warning)))
    nil))

之后,另一個人希望寫個函數(shù)?sample-ratio?,用來返回兩個列表的長度比。如果任何一個列表中的元素少于兩個,函數(shù)就改為返回 nil ,同時產(chǎn)生一個警告說明這個函數(shù)處理的是一個統(tǒng)計學(xué)上沒有意義的樣本。(實際的警告本可以帶有更多的信息,但它們的內(nèi)容與本例無關(guān)。)

(defun sample-ratio (v w)
  (let ((vn (length v)) (wn (length w)))
    (if (or (< vn 2) (< wn 2))
      (gripe "sample < 2")
      (/ vn wn))))

如果用?w = (b)?來調(diào)用?sample-ratio?,那么它將會警告說它有個參數(shù)只含一個元素,因而得出的結(jié)果從統(tǒng)計上來講是無意義的。但是當(dāng)對 gripe 的調(diào)用被展開時,sample-ratio 就好像被定義成:

(defun sample-ratio (v w)
  (let ((vn (length v)) (wn (length w)))
    (if (or (< vn 2) (< wn 2))
      (progn (setq w (nconc w (list "sample < 2")))
        nil)
      (/ vn wn))))

這里的問題是,使用 gripe 時的上下文含有 w 自己的局部綁定。所以,產(chǎn)生的警告沒能保存到全局的警告列表里,而是被 nconc 連接到了 sample-ratio 的一個參數(shù)的結(jié)尾。不但警告丟失了,而且列表?(b)?也加上了一個多余的字符串,而程序的其他地方可能還會把它作為數(shù)據(jù)繼續(xù)使用:

> (let ((lst '(b)))
  (sample-ratio nil lst)
  lst)
(B "sample < 2")
> w
NIL

9.3 捕捉發(fā)生的時機(jī)

許多宏的編寫者都希望通過查看宏的定義,就可以預(yù)見到所有可能來自上述兩種捕捉類型的問題。變量捕捉有些難以捉摸,需要一些經(jīng)驗才能預(yù)料到那些被捕捉的變量在程序中所有搗亂的伎倆。幸運的是,還是有辦法在你的宏定義中找出那些可能被捕捉的符號,并排除它們的,而無需操心這些符號捕捉如何搞砸你的程序。本節(jié)將介紹一套直接了當(dāng)?shù)臋z測原則,用它就可以找出可捕捉的符號。本章的其余部分則解釋了避免出現(xiàn)變量捕捉的相關(guān)技術(shù)。

我們接下來提出的方法可以用來定義可捕捉的變量,但是它基于幾個從屬的概念,所以在繼續(xù)之前必須首先給這些概念下個定義:

自由(free):我們認(rèn)為表達(dá)式中的符號 s 是自由的,當(dāng)且僅當(dāng)它被用作表達(dá)式中的變量,但表達(dá)式卻沒有為它創(chuàng)建一個綁定。

在下列表達(dá)式里:

(let ((x y) (z 10))
  (list w x z))

w ,x 和 z 在 list 表達(dá)式中看上去都是自由的,因為這個表達(dá)式?jīng)]有建立任何綁定。不過,外圍的 let 表達(dá)式為 x 和 z 創(chuàng)建了綁定,從整體上說,在 let 里面,只有 y 和 w 是自由的。注意到在:

(let ((x x))
  x)

里 x 的第二個實例是自由的。因為它并不在為 x 創(chuàng)建的新綁定的作用域內(nèi)。

框架(skeleton): 宏展開式的框架是整個展開式,并且去掉任何在宏調(diào)用中作為實參的部分。

如果 foo 的定義是:

(defmacro foo (x y)
  '(/ (+ ,x 1) ,y))

并且被這樣調(diào)用:

(foo (- 5 2) 6)

那么它就會產(chǎn)生如下的展開式:

(/ (+ (- 5 2) 1) 6)

這一展開式的框架就是上面這個表達(dá)式在把形參 x 和 y 拿走,留下空白后的樣子:

(/ (+ 1) )

有了這兩個概念,就可以把判斷可捕捉符號的方法簡單表述如下:

可捕捉(capturable):如果一個符號滿足下面條件之一,那就可以認(rèn)為它在某些宏展開里是可捕捉的

(a) 它作為自由符號出現(xiàn)在宏展開式的框架里,或者 (b) 它被綁定到框架的一部分,而該框架中含有傳遞給宏的參數(shù),這些參數(shù)被綁定或被求值。

用些例子可以明確這個標(biāo)準(zhǔn)的含義。在最簡單的情況下:

(defmacro cap1 ()
  '(+ x 1))

x 可被捕捉是因為它作為自由符號出現(xiàn)在框架里。這就是導(dǎo)致?gripe?中 bug 的原因。在這個宏里:

(defmacro cap2 (var)
  '(let ((x ...)
      (,var ...))
    ...))

x?可被捕捉是因為它被綁定在一個表達(dá)式里,而同時也有一個宏調(diào)用的參數(shù)被綁定了。(這就是for 中出現(xiàn)的錯誤。)同樣對于下面兩個宏:

(defmacro cap3 (var)
  '(let ((x ...))
    (let ((,var ...))
      ...)))

(defmacro cap4 (var)
  '(let ((,var ...))
    (let ((x ...))
      ...)))

x 在兩個宏里都是可捕捉的。然而,如果 x 的綁定和作為參數(shù)傳遞的變量沒有這樣一個上下文,在這個上下文中,兩者是同時可見的,就像在這個宏里:

(defmacro safe1 (var)
  '(progn (let ((x 1))
      (print x))
    (let ((,var 1))
      (print ,var))))

那么 x 將不會被捕捉到。并非所有綁定在框架里的變量都是有風(fēng)險的。盡管如此,如果宏調(diào)用的參數(shù)在一個由框架建立的綁定里被求值:

(defmacro cap5 (&body body)
  '(let ((x ...))
    ,@body))

那么,這樣綁定的變量就有被捕捉的風(fēng)險:在?cap5?中,x 是可捕捉的。不過對于下面這種情況:

(defmacro safe2 (expr)
  '(let ((x ,expr))
    (cons x 1)))

x 是不可捕捉的,因為當(dāng)傳給?expr?的參數(shù)被求值時,x 的新綁定將是不可見的。同時,請注意我們只需關(guān)心那些框架變量的綁定。在這個宏里:

(defmacro safe3 (var &body body)
  '(let ((,var ...))
    ,@body))

沒有符號會因沒有防備而被捕捉(假設(shè)第一個參數(shù)的綁定是用戶有意為之)。

現(xiàn)在讓我們來檢查一下?for?最初的定義,看看使用新的規(guī)則是否能發(fā)現(xiàn)可捕捉的符號:

(defmacro for ((var start stop) &body body) ; wrong
  '(do ((,var ,start (1+ ,var))
      (limit ,stop))
    ((> ,var limit))
    ,@body))

現(xiàn)在可以看出?for?的這一定義可能遭受兩種方式的捕捉:limit 可能會被作為第一個參數(shù)傳給?for,就像在最早的例子里那樣:

(for (limit 1 5)
  (princ limit))

但是,如果 limit 出現(xiàn)在循環(huán)體里,也同樣危險:

(let ((limit 0))
  (for (x 1 10)
    (incf limit x))
  limit)

這樣用?for?的人,可能會期望他自己的 limit 綁定就是在循環(huán)里遞增的那個,最后整個表達(dá)式返回55;事實上,只有那個由展開式框架生成的?limit?綁定會遞增:

(do ((x 1 (1+ x))
    (limit 10))
  ((> x limit))
  (incf limit x))

并且,由于迭代過程是由這個變量控制的,所以循環(huán)甚至將無法終止。

本節(jié)中介紹的這些規(guī)則不過是個參考,在實際編程中僅僅具有指導(dǎo)意義。它們甚至不是形式化定義的,更不能完全保證其正確性。捕捉是一個不能明確定義的問題,它依賴于你期望的行為。例如,在下面的表達(dá)式里:

(let ((x 1)) (list x))

x 在 (list x)被求值時,會指向新的變量,不過我們不會把它視為錯誤。這正是 let 要做的事。檢測捕捉的規(guī)則也含混不清。你可以寫出通過這些測試的宏,而這樣的宏卻仍然有可能會遭受意料之外的捕捉。例如:

(defmacro pathological (&body body) ; wrong
  (let* ((syms (remove-if (complement #'symbolp)
          (flatten body)))
      (var (nth (random (length syms))
          syms)))
    '(let ((,var 99))
      ,@body)))

當(dāng)調(diào)用這個宏的時候,宏主體中的表達(dá)式就像是在一個?progn?中被求值 但是主體中有一個隨機(jī)選出的變量將帶有一個不同的值。這很明顯是一個捕捉,但它通過了我們的測試,因為這個變量并沒有出現(xiàn)在框架里。然而,實踐表明該規(guī)則在絕大多數(shù)時候都是正確的:很少有人(如果真有的話)會想寫出類似上面那個例子的宏。

9.4 取更好的名字避免捕捉

前兩節(jié)將變量捕捉分為兩類:參數(shù)捕捉,在這種情況下,由宏框架建立的綁定會捕捉參數(shù)中用到的符號;和自由符號捕捉,而在這里,宏展開處的綁定會捕捉到宏展開式中的自由符號。常??梢酝ㄟ^給全局變量取個明顯的名字來解決后一類問題。在 Common Lisp 中,習(xí)慣上會給全局變量取一個兩頭都是星號的名字。

例如,定義當(dāng)前包的變量叫做 package 。(這樣的名字可以發(fā)音為 "star-package-star" 來強(qiáng)調(diào)它不是普通的變量。)

所以 gripe 的作者的的確確有責(zé)任把那些警告保存在一個名字類似?warnings?而非 w 的變量中。如果 sample-ratio 的作者執(zhí)意要用 warnings 做函數(shù)參數(shù),那他碰到的每個 bug 都是咎由自取,但如果他覺得用 w 作為參數(shù)的名字應(yīng)該比較保險,就不應(yīng)該再怪他了。

9.5 通過預(yù)先求值避免捕捉

有時,如果不在任何宏展開創(chuàng)建的綁定里求值那些有危險的參數(shù),就可以輕松消除參數(shù)捕捉。最簡單的情況可以這樣處理:讓宏以 let 表達(dá)式開頭。[示例代碼 9.1] 包含宏 before 的兩個版本,該宏接受兩個對象和一個序列,當(dāng)且僅當(dāng)?shù)谝粋€對象在序列中出現(xiàn)于第二個對象之前時返回真【注1】。第一個定義是不正確的。它開始的 let 確保了作為 seq 傳遞的 form 只求值一次,但是它不能有效地避免下面這個問題:


[示例代碼 9.1] 用 let 避免捕捉

易于被捕捉的:

(defmacro before (x y seq)
  '(let ((seq ,seq))
    (< (position ,x seq)
      (position ,y seq))))

一個正確的版本:

(defmacro before (x y seq)
  '(let ((xval ,x) (yval ,y) (seq ,seq))
    (< (position xval seq)
      (position yval seq))))

> (before (progn (setq seq '(b a)) 'a)
  'b
  '(a b))
NIL

這相當(dāng)于問 "(a b) 中的 a 是否在 b 前面?" 如果 before 是正確的,它將返回真。宏展開式揭示了真相:對?<?的第一個參數(shù)的求值重新排列了那個將在第二個參數(shù)里被搜索的列表。

(let ((seq '(a b)))
  (< (position (progn (setq seq '(b a)) 'a)
      seq)
    (position 'b seq)))

要想避免這個問題,只要在一個巨大的?let?里求值所有參數(shù)就行了。這樣 [示例代碼 9.1] 中的第二個定義對于捕捉就是安全的了。

不幸的是,這種 let 技術(shù)只能在很有限的一類情況下才可行:

  1. 所有可能被捕捉的參數(shù)都只求值一次,并且

  2. 沒有一個參數(shù)需要在宏框架建立的綁定下被求值。

這個規(guī)則排除了相當(dāng)多的宏。我們比較贊成的?for?宏就同時違反了這兩個限制。然而,我們可以把這個技術(shù)加以變化,使類似?for?的宏免于發(fā)生捕捉,即將其 body forms 包裝在一個 λ表達(dá)式里,同時讓這個 λ表達(dá)式位于任何局部創(chuàng)建的綁定之外。

有些宏(其中包括用于迭代的宏),如果宏調(diào)用里面有表達(dá)式出現(xiàn),那么在宏展開后,這些表達(dá)式將會在一個新建的綁定中求值。例如在?for?的定義中,循環(huán)體必須在一個由宏創(chuàng)建的?do?中進(jìn)行求值。因此,do?創(chuàng)建的變量綁定會很容易就捕捉到循環(huán)里的變量。我們可以把循環(huán)體包在一個閉包里,同時在循環(huán)里,不再把直接插入表達(dá)式,而只是簡單地?funcall?這個閉包。通過這種辦法來保護(hù)循環(huán)中的變量不被捕捉。

[示例代碼 9.2] 給出了一個?for?的實現(xiàn),它使用的就是這種技術(shù)。由于閉包是?for?展開時生成的第一個東西,因此,所有出現(xiàn)在宏體內(nèi)的自由符號將全部指向宏調(diào)用環(huán)境中的變量?,F(xiàn)在?do?通過閉包的參數(shù)跟宏體通信。閉包需要從?do?知道的全部就是當(dāng)前迭代的數(shù)字,所以它只有一個參數(shù),也就是宏調(diào)用中作為索引指定的那個符號。

這種將表達(dá)式包裝進(jìn) lambda 的方法也不是萬金油。雖然你可以用它來保護(hù)代碼體,但閉包有時也起不到任何作用,例如,當(dāng)存在同一變量在同一個 let 或?do?里被綁定兩次的風(fēng)險時(就像開始的那個有缺陷的for 那樣)。幸運的是,在這種情況下,通過重寫?for?將其主體包裝在一個閉包里,我們同時也消除了do 為 var 參數(shù)建立綁定的需要。原先那個?for?中的 var 參數(shù)變成了閉包的參數(shù)并且在?do?里面可以被一個實際的符號 count 替換掉。所以這個for 的新定義對于捕捉是完全免疫的,就像 9.3 節(jié)里的測試所顯示的那樣。


[示例代碼 9.2] 用閉包避免捕捉

易于被捕捉的:

(defmacro for ((var start stop) &body body)
  '(do ((,var ,start (1+ ,var))
      (limit ,stop))
    ((> ,var limit))
    ,@body))

正確的版本:

(defmacro for ((var start stop) &body body)
  '(do ((b #'(lambda (,var) ,@body))
      (count ,start (1+ count))
      (limit ,stop))
    ((> count limit))
    (funcall b count)))

閉包的缺點在于,它們的效率可能不大理想。我們可能會因此造成又一次函數(shù)調(diào)用。更糟糕的是,如果編譯器沒有給閉包分配動態(tài)作用域(dynamicextent),那么一等到運行期,閉包所需的空間將不得不從堆里分配?!咀?】

9.6 通過 gensym 避免捕捉

這里有一種切實可行的方法可供避免宏參數(shù)捕捉:把可捕捉的符號換成 gensym。在?for?的最初版本中,當(dāng)兩個符號意外地重名時,就會出問題。如果我們想要避免這種情況:宏框架里含有的符號也同時出現(xiàn)在了調(diào)用方代碼里,我們也許會給宏定義里的符號取個怪異的名字,寄希望以此來擺脫參數(shù)捕捉的魔爪:

(defmacro for ((var start stop) &body body) ; wrong
  '(do ((,var ,start (1+ ,var))
      (xsf2jsh ,stop))
    ((> ,var xsf2jsh))
    ,@body))

但是這治標(biāo)不治本。它并沒有消除 bug,只是降低了出問題的可能性。并且還有一個可能性不那么小的問題懸而未決 不難想象,如果把同一個宏嵌套使用的話,仍會出現(xiàn)名字沖突。

我們需要一個辦法來確保符號都是唯一的。Common Lisp 函數(shù) gensym 的意義正是在于此。它返回的符號稱為 gensym ,這個符號可以保證不和任何手工輸入或者由程序生成的符號相等(eq)。

那 Lisp 是如何保證這一點的呢?在 Common Lisp 中,每個包都維護(hù)著一個列表,用于保存這個包知道的所有符號?!咀?】

一個符號,只要出現(xiàn)在這個列表上,我們就說它被約束(intern)在這個包里。每次調(diào)用 gensym 都會返回唯一的,未約束的符號。而 read 每見到一個符號,都會把它約束,所以沒人能輸入和 gensym 相同的東西。也就是說,如果你有個表達(dá)式是這樣開頭的:

(eq (gensym) ...

那么將無法讓這個表達(dá)式返回真。

讓 gensym 為你構(gòu)造符號,這個辦法其實和 "選個怪名字" 的方法異曲同工,而且更進(jìn)一步 gensym 給你的名字甚至在電話薄里也找不到。如果 Lisp 不得不顯示 gensym,

> (gensym)
#:G47

它打印出來的東西基本上就相當(dāng)于 Lisp 的 "張三",即為那種名字無關(guān)緊要的東西編造出來的毫無意義的名字。并且為了確保我們不會對此有任何誤會,gensym 在顯示時候,前面加了一個井號和一個冒號,這是一種特殊的讀取宏(read-macro),其目的是為了讓我們在試圖第二次讀取該 gensym 時報錯。

在 CLSH2 Common Lisp 里,gensym 的打印形式中的數(shù)字來自 gensym-counter ,這個全局變量總是綁定到某個整數(shù)。如果重置這個計數(shù)器,我們就可以讓兩個 gensym 的打印輸出一模一樣:

> (setq x (gensym))
#:G48
> (setq *gensym-counter* 48 y (gensym))
#:G48
> (eq x y)
NIL

但它們不是一回事。


[示例代碼 9.3] 用 gensym 避免捕捉

易于被捕捉的:

(defmacro for ((var start stop) &body body)
  '(do ((,var ,start (1+ ,var))
      (limit ,stop))
    ((> ,var limit))
    ,@body))

一個正確的版本:

(defmacro for ((var start stop) &body body)
  (let ((gstop (gensym)))
    '(do ((,var ,start (1+ ,var))
        (,gstop ,stop))
      ((> ,var ,gstop))
      ,@body)))

[示例代碼 9.3] 中有一個使用 gensym 的?for?的正確定義。現(xiàn)在就沒有 limit 可以和傳進(jìn)宏的 form 里的符號有沖突了。它已經(jīng)被換成一個在現(xiàn)場生成的符號。宏每次展開的時候,limit 都會被一個在展開期創(chuàng)建的唯一符號取代。

初次就把?for?定義得完美無缺,還是很難的。完成后的代碼,如同一個完成了的定理,精巧漂亮的證明的背后是一次次的嘗試和失敗。所以不要擔(dān)心你可能會對一個宏寫好幾個版本。在開始寫類似for?這樣的宏時,你可以在不考慮變量捕捉問題的情況下,先把第一個版本寫出來,然后再回過頭來為那些可能卷入捕捉的符號制作 gensym。

9.7 通過包避免捕捉

從某種程度上說,如果把宏定義在它們自己的包里,就有可能避免捕捉。倘若你創(chuàng)建一個 macros 包,并且在其中定義?for?,那么你甚至可以使用最初給出的定義

(defmacro for ((var start stop) &body body)
  '(do ((,var ,start (1+ ,var))
      (limit ,stop))
    ((> ,var limit))
    ,@body))

這樣,就可以毫無顧慮地從其他任何包調(diào)用它。如果你從另一個包,比方說 mycode,里調(diào)用 for,就算把 limit 作為第一個參數(shù),它也是 mycode::limit 這和 macros::limit 是兩回事,后者才是出現(xiàn)在宏框架中的符號。

然而,包還是沒能為捕捉問題提供面面俱到的通用解決方案。首先,宏是某些程序不可或缺的組成部分,將它們從自己的包里分離出來會很不方便。其次,這種方法無法為 macros 包里的其他代碼提供任何捕捉保護(hù)。

9.8 其他名字空間里的捕捉

前面幾節(jié)都把捕捉說成是一種僅影響變量的問題。盡管多數(shù)捕捉都是變量捕捉,但是 Common Lisp 的其他名字空間里也同樣會有這種問題。

函數(shù)也可能在局部被綁定,因而,函數(shù)綁定也會因無意的捕捉而導(dǎo)致問題。例如,

> (defun fn (x) (+ x 1))
FN
> (defmacro mac (x) '(fn ,x))
MAC
> (mac 10)
11
> (labels ((fn (y) (- y 1)))
  (mac 10))
9

正如捕捉規(guī)則預(yù)料的那樣,以自由之身出現(xiàn)在 mac 框架中的 fn 帶來了被捕捉的風(fēng)險。如果 fn 在局部被重新綁定的話,那么 mac 的返回值將和平時不一樣。

對于這種情況,該如何應(yīng)對呢?當(dāng)有捕捉風(fēng)險的符號與內(nèi)置函數(shù)或宏重名時,那么聽之任之應(yīng)該是上策。CLTL2(260 頁) 說,如果任何內(nèi)置的名字被用作局部函數(shù)或宏綁定,"后果是未定義的。" 所以你的宏無論做了什么都沒關(guān)系 -- 任何人,如果重新綁定內(nèi)置函數(shù),那么他將來碰到的問題會比你的這個宏更多。

另一方面,保護(hù)變量名的方法同樣可以用來幫助函數(shù)名免于宏參數(shù)捕捉:通過使用 gensym 作為宏框架局部定義的任何函數(shù)的名字。但是,如果要避免像上面這種情況中的自由符號捕捉,就會稍微麻煩一點。要讓變量免受自由符號捕捉,采用的保護(hù)方法是使用一目了然的全局名稱:例如把 w 換成 warnings 。

然而,這個解決方案對函數(shù)有些不切實際,因為沒有把全局函數(shù)的名字區(qū)分出來的習(xí)慣 大多數(shù)函數(shù)都是全局的。如果你擔(dān)心發(fā)生這種情況,一個宏使用了另一個函數(shù),而調(diào)用這個宏的環(huán)境可能會重定義這個函數(shù),那么最佳的解決方案或許就是把你的代碼放在一個單獨的包里。

代碼塊名字(block-name) 同樣可以被捕捉,比如說那些被?go?和?throw?使用的標(biāo)簽(tag)。當(dāng)你的宏需要這些符號時,你應(yīng)該像 7.8 節(jié)的?our-do?的定義那樣,使用 gensym。

還需要注意的是像?do?這樣的操作符隱式封裝在一個名為?nil?的塊里。這樣在?do?里面的一個return?或?return-from nil?將從?do?本身而非包含這個?do?的表達(dá)式里返回:

> (block nil
  (list 'a
    (do ((x 1 (1+ x)))
      (nil)
      (if (> x 5)
        (return-from nil x)
        (princ x)))))
12345
(A 6)

如果?do?沒有創(chuàng)建一個名為?nil?的塊,這個例子將只返回 6 ,而不是(A 6)。

do?里面的隱式塊不是問題,因為?do?的這種工作方式廣為人知。盡管如此,如果你寫一個展開到do?的宏,它將捕捉 nil 這個塊名稱。在一個類似?for?的宏里,?return?或 return-from nil 將從for?表達(dá)式而非封裝這個?for?表達(dá)式的塊中返回。

9.9 為何要庸人自擾

前面舉的例子中有些非常牽強(qiáng)做作??粗鼈?,有人可能會說,"變量捕捉既然這么少見 為什么還要操心它呢?" 回答這個問題有兩個方法。一個是用另一個問題反詰道:要是你寫得出沒有 bug 的程序,為什么還要寫有小 bug 的程序呢?

更長的答案是指出在現(xiàn)實應(yīng)用程序中,對你代碼的使用方式做任何假設(shè)都是危險的。任何 Lisp 程序都具備現(xiàn)在被稱之為 "開放式架構(gòu)" 的特征。如果你正在寫的代碼以后會為他人所用,很可能他們調(diào)用你代碼的方式是出乎你預(yù)料的。而且你要擔(dān)心的不光是人。程序也能編寫程序。可能沒人會寫這樣的代碼

(before (progn (setq seq '(b a)) 'a)
  'b
  '(a b))

但是程序生成的代碼看起來經(jīng)常就像這樣。即使單個的宏生成的是簡單合理的展開式,一旦你開始把宏嵌套著調(diào)用,展開式就可能變成巨大的,而且看上去沒人能寫得出來的程序。在這個前提下,就有必要去預(yù)防那些可能使你的宏不正確地展開的情況,就算這種情況像是有意設(shè)計出來的。

最后,避免變量捕捉不管怎么說,并非難于上青天。它很快會成為你的第二直覺。Common Lisp 中經(jīng)典的 defmacro 好比廚子手中的菜刀:美妙的想法看上去會有些危險,但是這件利器一到了專家那里,就如入庖丁之手,游刃有余。

【注1】 這個宏只是個例子。實際編程中,它既不應(yīng)當(dāng)實現(xiàn)成宏,也不該用這種低效的算法。若需要正確的定義,可見 4.4 節(jié)。

【注2】 譯者注:dynamicextent 是一種Lisp 編譯器優(yōu)化技術(shù),詳情請見 Common Lisp Hyper Spec 的有關(guān)內(nèi)容。

【注3】 關(guān)于包(package) 的介紹,可見附錄。

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

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號