第 9 章只是把變量捕捉視為一種問題 某種意料之外,并且只會搗亂的負面因素。本章將顯示變量捕捉 也可以被有建設性地使用。如果沒有這個特性,一些有用的宏就無法寫出來。
在 Lisp 程序里,下面這種需求并不鮮見:希望檢查一個表達式的返回值是否為非空,如果是的話,使用這個值做某些事。倘若求值表達式的代價比較大,那么通常必須這樣做:
(let ((result (big-long-calculation)))
(if result
(foo result)))
難道就不能簡單一些,讓我們像英語里那樣,只要說:
(if (big-long-calculation)
(foo it))
通過利用變量捕捉,我們可以寫一個?if
,讓它以這種方式工作。
在自然語言里,指代(anaphor) 是一種引用對話中曾提及事物的表達方式。英語中最常用的代詞可能要
算 "it" 了,就像在 "Get the wrench and put it on the table(拿個扳手,然后把它放在桌上)" 里那樣。指代給日常語言帶來了極大的便利 試想一下沒有它會發(fā)生什么 但它在編程語言里卻很少見。這在很大程度上是為了語言著想。指代表達式常會產生歧義,而當今的編程語言從設計上就無法處理這種二義性。
盡管如此,在 Lisp 程序中引入一種形式非常有限的代詞,同時避免歧義,還是有可能的。代詞,實際上是一種可捕捉的符號。我們可以通過指定某些符號,讓它們充當代詞,然后再編寫宏有意地捕捉這些符號,用這種方式來使用代詞。
在新版的?if
?里,符號?it
?就是那個我們想要捕捉的對象。Anaphoricif
,簡稱?aif
?,其定義如下:
(defmacro aif (test-form then-form &optional else-form)
'(let ((it ,test-form))
(if it ,then-form ,else-form)))
并如前例中那樣使用它:
(aif (big-long-calculation)
(foo it))
當你使用?aif
?時,符號?it
?會被綁定到測試表達式返回的結果。在宏調用中,it
?看起來是自由的,但事實上,在?aif
?展開時,表達式?(foo it)
?會被插入到一個上下文中,而?it
?的綁定就位于該上下文:
(let ((it (big-long-calculation)))
(if it (foo it) nil))
這樣一個在源代碼中貌似自由的符號就被宏展開綁定了。本章里所有的指代宏都使用了這種技術,并加以變化。
[示例代碼 14.1] 包含了一些 Common Lisp 操作符的指代變形。aif
?下面是?awhen
?,很明顯它是when
?的指代版本:
原書勘誤:(acond (3))將返回 nil 而不是 3。后面的 acond2 也有同樣的問題。
[示例代碼 14.1] Common Lisp 操作符的指代變形
(defmacro aif (test-form then-form &optional else-form)
'(let ((it ,test-form))
(if it ,then-form ,else-form)))
(defmacro awhen (test-form &body body)
'(aif ,test-form
(progn ,@body)))
(defmacro awhile (expr &body body)
'(do ((it ,expr ,expr))
((not it))
,@body))
(defmacro aand (&rest args)
(cond ((null args) t)
((null (cdr args)) (car args))
(t '(aif ,(car args) (aand ,@(cdr args))))))
(defmacro acond (&rest clauses)
(if (null clauses)
nil
(let ((cl1 (car clauses))
(sym (gensym)))
'(let ((,sym ,(car cl1)))
(if ,sym
(let ((it ,sym)) ,@(cdr cl1))
(acond ,@(cdr clauses)))))))
(awhen (big-long-calculation)
(foo it)
(bar it))
aif
?和?awhen
?都是經常會用到的,但?awhile
?可能是這些指代宏中的唯一一個,被用到的機會比它的正常版的同胞兄弟?while
?(定義于 7.4 節(jié)) 更多的宏。一般來說,如果一個程序需要等待(poll) 某個外部數據源的話,類似?while
?和?awhile
?這樣的宏就可以派上用場了。而且,如果你在等待一個數據源,除非你想做的僅是靜待它改變狀態(tài),否則你肯定會想用從數據源那里獲得的數據做些什么:
(awhile (poll *fridge*)
(eat it))
aand 的定義和前面的幾個宏相比之下更復雜一些。它提供了一個?and
?的指代版本;每次求值它的實參,it 都將被綁定到前一個參數返回的值上。 在實踐中,aand
?傾向于在那些做條件查詢的程序中使用,例如這里:
(aand (owner x) (address it) (town it))
它返回?x
?的擁有者(如果有的話) 的地址(如果有的話) 所屬的城鎮(zhèn)(如果有的話)。如果不使用?aand
,該表達式就只能寫成:
(let ((own (owner x)))
(if own
(let ((adr (address own)))
(if adr (town adr)))))
盡管人們喜歡把?and
?和?or
?相提并論,但實現指代版本的?or
?沒有什么意義。一個?or
?表達式中的實參只有當它前面的實參求值到?nil
?才會被求值,所以?aor
?中的代詞將毫無用處。
從?aand
?的定義可以看出,它的展開式將隨宏調用中的實參的數量而變。如果沒有實參,那么aand
,將像正常的?and
?那樣,應該直接返回?t
?。否則會遞歸地生成展開式,每一步都會在嵌套的aif
?鏈中產生一層:
(aif <first argument>
<expansion for rest of arguments>)
aand
?的展開必須在只剩下一個實參時終止,而不是像大多數遞歸函數那樣繼續(xù)展開,直到?nil
?才停下來。
倘若遞歸過程一直進行下去,直到消去所有的合取式,那么最終的展開式將總是下面的模樣:
(aif <C>
.
.
.
(aif <Cn>
t)...)
這樣的表達式會一直返回?t
?或者?nil
?,因而上面的示例將無法正常工作。
第 10.4 節(jié)曾警告過:如果一個宏總是產生包含對其自身調用的展開式,那么展開過程將永不終止。雖然?aand
?是遞歸的,但是它卻沒有這個問題,因為在基本情形里它的展開式沒有引用?aand
。
最后一個例子是?acond
?,它用于?cond
?子句的其余部分想使用測試表達式的返回值的場合。(這種需求非常普遍,以至于 Scheme 專門提供了一種方式來使用?cond
?子句中測試表達式的返回值。)
在?acond
?子句的展開式里,測試結果一開始時將被保存在一個由?gensym
?生成的變量里,目的是為了讓符號?it
?的綁定只在子句的其余部分有效。當宏創(chuàng)建這些綁定時,它們應該總是在盡可能小的作用域里完成這些工作。這里,要是我們省掉了這個?gensym
,同時直接把?it
?綁定到測試表達式的結果上,就像這樣:
(defmacro acond (&rest clauses) ; wrong
(if (null clauses)
nil
(let ((cl1 (car clauses)))
'(let ((it ,(car cl1)))
(if it
(progn ,@(cdr cl1))
(acond ,@(cdr clauses)))))))
那么it 綁定的作用域也將包括后續(xù)的測試表達式。
[示例代碼 14.2] 更多的指代變形
(defmacro alambda (parms &body body)
'(labels ((self ,parms ,@body))
#'self))
(defmacro ablock (tag &rest args)
'(block ,tag
,(funcall (alambda (args)
(case (length args)
(0 nil)
(1 (car args))
(t '(let ((it ,(car args)))
,(self (cdr args))))))
args)))
[示例代碼 14.2] 有一些更復雜的指代變形。宏?alambda
?是用來字面引用遞歸函數的。不過什么時候會需要字面引用遞歸函數呢?我們可以通過帶?#'
?的 λ表達式來字面引用一個函數:
#'(lambda (x) (* x 2))
但正如第 2 章里解釋的那樣,你不能直接用λ–表達式來表達遞歸函數。代替的方法是,你必須借助labels
?定義一個局部函數。下面這個函數(來自 2.8 節(jié))
(defun count-instances (obj lsts)
(labels ((instances-in (lst)
(if (consp lst)
(+ (if (eq (car lst) obj) 1 0)
(instances-in (cdr lst)))
0)))
(mapcar #'instances-in lsts)))
接受一個對象和列表,并返回一個由列表中每個元素里含有的對象個數所組成的數列:
> (count-instances 'a '((a b c) (d a r p a) (d a r) (a a)))
(1 2 1 2)
通過代詞,我們可以將這些代碼變成字面遞歸函數。alambda
?宏使用?labels
?來創(chuàng)建函數,例如,這樣就可以用它來表達階乘函數:
(alambda (x) (if (= x 0) 1 (* x (self (1- x)))))
使用?alambda
?我們可以定義一個等價版本的?count-instances
?,如下:
(defun count-instances (obj lists)
(mapcar (alambda (list)
(if list
(+ (if (eq (car list) obj) 1 0)
(self (cdr list)))
0))
lists))
alambda
?與 [示例代碼 14.1] 和 14.2 節(jié)里的其他宏不一樣,后者捕捉的是 it,而?alambda
?則捕捉self
。alambda
?實例會展開進一個?labels
?表達式,在這個表達式中,self
?被綁定到正在定義的函數上。alambda
?表達式不但更短小,而且看起來很像我們熟悉的?lambda
?表達式,這讓使用alambda
?表達式的代碼更容易閱讀。
這個新宏被用了在?ablock
?的定義里,它是內置的?block special form
?的一個指代版本。在block
?里面,參數從左到右求值。在?ablock
?里也是一樣,只是在這里,每次求值時變量?it
?都會被綁定到前一個表達式的值上。
這個宏應謹慎使用。盡管很多時候?ablock
?用起來很方便,但是它很可能會把本可以被寫得優(yōu)雅漂亮的函數式程序弄成命令式程序的樣子。下面就是一個很不幸的反面教材:
> (ablock north-pole
(princ "ho ")
(princ it)
(princ it)
(return-from north-pole))
ho ho ho
NIL
如果一個宏,它有意地使用了變量捕捉,那么無論何時這個宏被導出到另一個包的時候,都必須同時導出那些被捕捉了的符號。例如,無論?aif
?被導出到哪里,it
?也應該同樣被導出到同樣的地方。否則出現在宏定義里的it 和宏調用里使用的?it
?將會是不同的符號。
在 Common Lisp 中符號?nil
?身兼三職。它首先是一個空列表,也就是
> (cdr '(a))
NIL
除了空列表以外,nil 被用來表示邏輯假,例如這里
> (= 1 0)
NIL
最后,函數返回 nil 以示失敗。例如,內置?find-if
?的任務是返回列表中第一個滿足給定測試條件的元素。
如果沒有發(fā)現這樣的元素,find-if 將返回 nil :
> (find-if #'oddp '(2 4 6))
NIL
不幸的是,我們無法分辨出這種情形:即 find-if 成功返回,而成功的原因是它發(fā)現了 nil :
> (find-if #'null '(2 nil 6))
NIL
在實踐中,用 nil 來同時表示假和空列表并沒有招致太多的麻煩。事實上,這樣可能相當方便。然而,用nil 來表示失敗卻是一個痛處。因為它意味著一個像 find-if 這樣的函數,其返回的結果可能是有歧義的。
對于所有進行查找操作的函數,都會遇到如何區(qū)分失敗和 nil 返回值的問題。為了解決這個問題,Common Lisp 至少提供了三種方案。在多重返回值出現之前,最常用的方法是專門返回一個列表結構。例如,區(qū)分 assoc 的失敗就沒有任何麻煩;當執(zhí)行成功時它返回成對的問題和答案:
> (setq synonyms '((yes . t) (no . nil)))
((YES . T) (NO))
> (assoc 'no synonyms)
(NO)
按照這個思路,如果擔心 find-if 帶來的歧義,我們可以用 member-if ,它不單單返回滿足測試的元素,而是返回以該元素開始的整個 cdr:
(member-if #'null '(2 nil 6)) (NIL 6)
自從多重返回值誕生之后,這個問題就有了另一個解決方案:用一個值代表數據,而用第二個值指出成功還是失敗。內置的gethash 就以這種方式工作。它總是返回兩個值,第二個值代表是否找到了什么東西:
> (setf edible (make-hash-table)
(gethash 'olive-oil edible) t
(gethash 'motor-oil edible) nil)
NIL
> (gethash 'motor-oil edible)
NIL
T
如果你想要檢測所有三種可能的情況,可以用類似下面的寫法:
(defun edible? (x)
(multiple-value-bind (val found?) (gethash x edible)
(if found?
(if val 'yes 'no)
'maybe)))
這樣就可以把失敗和邏輯假區(qū)分開了:
> (mapcar #'edible? '(motor-oil olive-oil iguana))
(NO YES MAYBE)
Common Lisp 還支持第三種表示失敗的方法:讓訪問函數接受一個特殊對象作為參數,一般是用個 gensym,然后在失敗的時候返回這個對象。這種方法被用于 get ,它接受一個可選參數來表示當特定屬性沒有找到時返回的東西:
> (get 'life 'meaning (gensym))
#:G618
如果可以用多重返回值,那么 gethash 用的方法是最清楚的。我們不愿意像調用 get 那樣,為每個訪問函數都再傳入一個參數。并且和另外兩種替代方法相比,使用多重返回值更通用;可以讓 find-if 返回兩個值,而 gethash 卻不可能在不做 consing 的情況下被重寫成返回無歧義的列表。這樣在編寫新的用于查詢的函數,或者對于其他可能失敗的任務時,通常采用gethash 的方式會更好一些。
[示例代碼 14.3] 多值指代宏
(defmacro aif2 (test &optional then else)
(let ((win (gensym)))
'(multiple-value-bind (it ,win) ,test
(if (or it ,win) ,then ,else))))
(defmacro awhen2 (test &body body)
'(aif2 ,test
(progn ,@body)))
(defmacro awhile2 (test &body body)
(let ((flag (gensym)))
'(let ((,flag t))
(while ,flag
(aif2 ,test
(progn ,@body)
(setq ,flag nil))))))
(defmacro acond2 (&rest clauses)
(if (null clauses)
nil
(let ((cl1 (car clauses))
(val (gensym))
(win (gensym)))
'(multiple-value-bind (,val ,win) ,(car cl1)
(if (or ,val ,win)
(let ((it ,val)) ,@(cdr cl1))
(acond2 ,@(cdr clauses)))))))
在 edible? 里的寫法不過相當于一種記帳的操作,它被宏很好地隱藏了起來。對于類似 gethash 這樣的訪問函數,我們會需要一個新版本的 aif ,它綁定和測試的對象不再是同一個值,而是綁定第一個值,并測試第二個值。這個新版本的 aif ,稱為 aif2 ,由 [示例代碼 14.3] 給出。使用它,我們可以將 edible? 寫成:
(defun edible? (x)
(aif2 (gethash x edible)
(if it 'yes 'no)
'maybe))
[示例代碼 14.3] 還包含有 awhen ,awhile ,和 acond 的類似替代版本。作為一個使用a cond2 的例子,見 18.4 節(jié)上 match 的定義。通過使用這個宏,我們可以用一個 cond 的形式來表達,否則函數將變得更長并且缺少對稱性。
內置的 read 指示錯誤的方式和 get 同出一轍。它接受一個可選參數來說明在遇到eof 時是否報錯,如果不報錯的話,將返回何值。[示例代碼 14.4] 中給出了另一個版本的 read ,它用第二個返回值指示失敗。read2 返回兩個值,分別是輸入表達式和一個標志,如果碰到eof 的話,這個標志就是nil 。它把一個 gensym 傳給 read ,萬一遇到 eof 就返回它,這免去了每次調用 read2 時構造 gensym 的麻煩,這個函數被定義成一個閉包,閉包中帶有一個編譯期生成的 gensym 的私有拷貝。
[示例代碼 14.4] 文件實用工具
(let ((g (gensym)))
(defun read2 (&optional (str *standard-input*))
(let ((val (read str nil g)))
(unless (equal val g) (values val t)))))
(defmacro do-file (filename &body body)
(let ((str (gensym)))
'(with-open-file (,str ,filename)
(awhile2 (read2 ,str)
,@body))))
[示例代碼 14.4] 中還有一個宏,它可以方便地遍歷一個文件里的所有表達式,這個宏是用 awhile2 和 read2 寫成的。舉個例子,借助 do-file ,我們可以這樣實現 load :
(defun our-load (filename)
(do-file filename (eval it)))
有時認為是指代宏破壞了引用透明,Gelernter 和Jagannathan 是這樣定義引用透明的:
一個語言是引用透明的,如果 (a) 任意一個子表達式都可以替換成另一個子表達式,只要后者和前者的值相等,并且 (b) 在給定的上下文中,出現不同地方的同一表達式其取值都相同。
注意到這個標準針對的是語言,而不是程序。沒有一個帶賦值的語言是引用透明的。在下面的表達式中:
(list x
(setq x (not x))
x)
第一個和最后一個 x 帶有不同的值,因為被一個 setq 干預了。必須承認,這是丑陋的代碼。這一事實意味著 Lisp 不是引用透明的。
Norvig 提到,倘若把 if 重新定義成下面這樣將會很方便:
(defmacro if (test then &optional else)
'(let ((that ,test))
(if that ,then ,else)))
但 Norvig 否定它的理由,也正是因為這個宏破壞了引用透明。
盡管如此,這里的問題在于:上面的宏重定義了內置操作符,而不是因為它使用了代詞。上面定義中的 (b) 條款要求一個表達式 "在給定的上下文中" 必須總是返回相同的值。如果是在這個 let 表達式中就沒問題了,
(let ((that 'which))
...)
符號 that 表示一個新變量,因為 let 就是被用于創(chuàng)建一個新的上下文。
上面那個宏的錯誤在于,它重定義了 if,而 if 的本意并非是被用來創(chuàng)建新的上下文的。如果我們給指代宏取個自己的名字,問題就迎刃而解。(根據?CLTL2,重定義 if 總是非法的。) 由于 aif 定義的一部分就是建立一個新的上下文,并且在這個上下文中,it 是一個新變量,所以這樣一個宏并沒有破壞引用透明。
現在,aif 確實違背了另一個原則,它和引用透明無關:即,不管用什么辦法,新建立的變量都應該在源代碼里能很容易地分辨出來。前面的那個 let 表達式就清楚地表明 that 將指向一個新變量。可能會有反對意見,說:一個 aif 里面的 it 綁定就沒有那么明顯。盡管如此,這里有一個不大站得住腳的理由:aif 只創(chuàng) 建了一個變量,并且創(chuàng)建這個變量是我們使用 aif 的唯一理由。
Common Lisp 自己并沒有把這個原則奉為不可違背的金科玉律。CLOS?函數 call-next-method 的綁定依賴上下文的方式和 aif 函數體中符號 it 的綁定方式是一樣的。(關于 call-next-method 應如何實現的一個建議方案,可見 25.2 節(jié)上的 defmeth 宏。) 在任何情況下,這類原則的最終目的只有一個:提高程序的可讀性。并且代詞確實讓程序更容易閱讀,正如它們讓英語更容易閱讀那樣。
更多建議: