第 14 章 指代宏

2018-02-24 15:54 更新

第 14 章 指代宏

第 9 章只是把變量捕捉視為一種問題 某種意料之外,并且只會搗亂的負面因素。本章將顯示變量捕捉 也可以被有建設性地使用。如果沒有這個特性,一些有用的宏就無法寫出來。

在 Lisp 程序里,下面這種需求并不鮮見:希望檢查一個表達式的返回值是否為非空,如果是的話,使用這個值做某些事。倘若求值表達式的代價比較大,那么通常必須這樣做:

(let ((result (big-long-calculation)))
  (if result
    (foo result)))

難道就不能簡單一些,讓我們像英語里那樣,只要說:

(if (big-long-calculation)
  (foo it))

通過利用變量捕捉,我們可以寫一個?if,讓它以這種方式工作。

14.1 指代的種種變形

在自然語言里,指代(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?將會是不同的符號。

14.2 失敗

在 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)))

14.3 引用透明(Referential Transparency)

有時認為是指代宏破壞了引用透明,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 宏。) 在任何情況下,這類原則的最終目的只有一個:提高程序的可讀性。并且代詞確實讓程序更容易閱讀,正如它們讓英語更容易閱讀那樣。

以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號