第 8 章 何時使用宏

2018-02-24 15:54 更新

第 8 章 何時使用宏

我們如何知道一個給定的函數是否真的應該是函數,而不是宏呢?多數時候,會很容易分清楚在哪種情況下需要用到宏,哪種情況不需要。缺省情況下,我們應該用函數,因為如果函數能解決問題,而偏要用上宏的話,會讓程序變得不優(yōu)雅。我們應當只有在宏能帶來特別的好處時才使用它們。

什么情況下,宏能給我們帶來優(yōu)勢呢?這就是本章的主題。通常這不是錦上添花,而是一種必須。大多數我們用宏可以做到的事情,函數都無法完成。第 8.1 節(jié)列出了只能用宏來實現的幾種操作符。盡管如此,也有一小類(但很有意思的) 情況介于兩者之間,對它們來說,不管把操作符實現成函數還是宏似乎都言之有理。對于這種情況,第 8.2 節(jié)給出了關于宏的正反兩方面考量。最后,在充分考察了宏的能力后,我們在第 8.3 節(jié)里轉向一個相關問題:人們都用宏干什么?

8.1 當別無他法時

優(yōu)秀設計的一個通用原則就是:當你發(fā)現在程序中的幾處都出現了相似的代碼時,就應該寫一個子例程,并把那些相似的語句換成對這個子例程的調用。如果也把這條原則用到 Lisp 程序上,就必須先決定這個 "子例程" 應該是函數還是宏。

有時,可以很容易確定應當寫一個宏而不是函數,因為只有宏才能滿足需求。一個像 1+ 這樣的函數或許既可以寫成函數也可以寫成宏:

(defun 1+ (x) (+ 1 x))
(defmacro 1+ (x) '(+ 1 ,x))

但是來自第 7.3 節(jié)的?while?,則只能被定義成宏:

(defmacro while (test &body body)
  '(do ()
    ((not ,test))
    ,@body))

無法用函數來重現這個宏的行為。while?的定義里拼接了一個作為?body?傳入?do?的主體里的表達式,它只有當?test?表達式返回?nil?時才會被求值。沒有函數可以做到這一點;是因為在函數調用里,所有的參數在函數調用開始之前就會被求值。

當你需要用宏時,你看中了它哪一點呢?宏有兩點是函數無法做到的:宏可以控制(或阻止) 對其參數的求值,并且它可以展開進入到主調方的上下文中。任何需要宏的應用,歸根到底都是要用上述兩個屬性中的至少一個。

"宏不對其參數進行求值",這個非正式的說法不太準確。更確切的說法應該是,"宏能控制宏調用中參數的求值"。取決于參數在宏展開式中的位置,它們可以被求值一次,多次,或者根本不求值。宏的這種控制主要體現在四個方面:

1. 變換

Common Lisp 的 setf 宏就是這類宏中的一員,它們在求值前都會對傳入的參數嚴加檢查。內置的訪問函數(access function) 通常都有一個對應的逆操作,其作用是對該訪問函數所獲取的對象賦值。car 的逆操作是 rplaca ,對于 cdr 來說是 rplacd ,等等。有了 setf ,我們就可以把對這些訪問函數的調用當成變量賦值。(setf (car x) 'a)?就是個例子,這個表達式可以展開成?(progn (rplaca x 'a) 'a).

為了有這樣的效果,setf 必須非常了解它的第一個參數。如果要知道上述的情況需要用到?rplaca?,

setf?就得清楚它的第一個參數是個以 car 開始的表達式。這樣的話,setf 以及其他修改參數的操作符,就必須被寫成宏。

2. 綁定

詞法變量必須在源代碼中直接出現。例如,由于 setq 的第一個參數是不求值的,所以,所有在setq 之上構建的東西都必須是展開到setq 的宏,而不能是調用它的函數。對于 let 這樣的操作符也是如此,它的實參必須作為 lambda 表達式的形參出現,還有類似 do 這樣展開到 let 的宏也是這樣,等等。任何新操作符,只要它修改了參數的詞法綁定,那么它就必須寫成宏。

3. 條件求值

函數的所有參數都會被求值。在像 when 這樣的結構里,我們希望一些參數僅在特定條件下才被求值。只有通過宏才可能獲得這種靈活性。

4. 多重求值

函數的所有參數不但都會被求值,而且求值的次數都正好是一次。我們需要用宏來定義像 do 這樣的結構,這樣子,就可以對特定的參數多次求值。

也有幾種方式可以利用宏產生的內聯展開式帶來的優(yōu)勢。這里必須強調一點,宏展開后生成的展開式將會出現在宏調用所在的詞法環(huán)境之中,因為下列三種用法有兩種都基于這個事實。它們是:

5. 利用調用方環(huán)境

宏生成的展開式可以含有這樣的變量,變量的綁定來自宏調用的上下文環(huán)境。下面這個宏:

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

的行為將因 foo 被調用時 y 的綁定而不同。

這種詞法交流通常更多地被視為瘟疫的傳染源,而非快樂之源。一般來說,寫這樣的宏不是什么好習慣。函數式編程的思想對于宏也同樣適用:與一個宏交流的最佳方式就是通過它的參數。事實上,需要用到調用方環(huán)境的情況極少,因此,如果出現了這樣的用法,那十有八九就是什么地方出了問題。(見第9 章)

縱觀本書中的所有宏,只有續(xù)延傳遞(continuation-passing)宏(第 20 章)和 ATN 編譯器(第23 章)的一部分以這種方式利用了調用方環(huán)境。

6. 包裝新環(huán)境

宏也可以使其參數在一個新的詞法環(huán)境下被求值。最經典的例子就是let ,它可以用lambda 實現成宏的形式(見 11.1 節(jié))。在一個?(let ((y 2)) (+ x y))?這樣的表達式里,y 將指向一個新的變量。

7. 減少函數調用

宏展開后,展開式內聯地插入展開環(huán)境。這個設計的第三個結果是宏調用在編譯后的代碼中沒有額外開銷。到了運行期,宏調用已經替換成了它的展開式。(這個說法對于聲明成inline 的函數也一樣成立。)

很明顯,如果不是有意為之,情形 5 和 6 將產生變量捕捉上的問題,這可能是宏的編寫者所有擔心的事情里面最頭疼的一件。變量捕捉將在第 9 章討論。

與其說有七種使用宏的方式,不如說有六個半。在理想的世界里,所有 Common Lisp 編譯器都會遵守 inline 聲明,所以減少函數調用將是內聯函數的職責,而不是宏的。這個建立理想世界的重任就作為練習留給讀者吧。

8.2 宏還是函數?

上一節(jié)解決了較簡單的一類問題。一個操作符,倘若在參數被求值前就需要訪問它,那么這個操作符就應該寫成宏,因為別無他法。那么,如果有操作符用兩種寫法都能實現,那該怎么辦呢?

比如說操作符?avg?。它返回參數的平均值。它可以定義成函數:

(defun avg (&rest args)
  (/ (apply #'+ args) (length args)))

但把它定義成宏也不錯:

(defmacro avg (&rest args)
  '(/ (+ ,@args) ,(length args)))

因為每次調用 avg 的函數版本時,都毫無必要地調用了一次 length。在編譯期我們可能不清楚這些參數的值,但卻知道參數的個數,所以那是調用 length 最佳的時機。當我們面臨這樣的選擇時,可以考慮下列幾點:

利:

  1. 編譯期計算。宏調用共有兩次參與計算,分別是:宏展開的時候,以及展開式被求值的時候。一旦程序編譯好,Lisp 程序中所有的宏展開也就完成了,而在編譯期每進行一次計算,都幫助程序在運行的時候卸掉了一個包袱。如果在編寫操作符時,可以讓它在宏展開的階段就完成一部分工作,那么把它寫成宏將會讓程序更加高效。因為只要是聰明的編譯器無法自己完成的工作,函數就只能把這些事情拖到運行期做。第13章介紹一些類似avg 的宏,這些宏能在宏展開的階段就完成一部分工作。

  2. 和Lisp 的集成。有時,用宏代替函數可以令程序和Lisp 集成得更緊密。解決一個特定問題的方法,可以是專門寫一個程序,你也可以用宏把這個問題變換成另一個Lisp 已經知道解決辦法的問題。如果可行的話,這種方法常??梢允钩绦蜃兊酶绦?,也更高效:更小是因為Lisp 代勞了一部分工作,更高效則是因為產品級Lisp 系統(tǒng)通常比用戶程序做了更多的優(yōu)化。這一優(yōu)勢大多時候會出現在嵌入式語言里,而我們從第19章起會全面轉向嵌入式語言。

  3. 免除函數調用。宏調用在它出現的地方直接展開成代碼。所以,如果你把常用的代碼片段寫成宏,那么就可以每次在使用它的時候免去一次函數調用。在Lisp 的早期方言中,程序員借助宏的這個屬性在運行期避免函數調用。而在Common Lisp 里,這個差事應該由聲明成 inline 類型的函數接手了。

通過將函數聲明成inline,你要求把這個函數就像宏一樣,直接編譯進調用方的代碼。不過,理想和現實還是有距離的; ???2(229 頁) 說 "編譯器可以隨意地忽略該聲明",而且某些 Common Lisp 編譯器確實也是這樣做的。

在某些情況下,效率因素和跟Lisp 之間緊密集成的組合優(yōu)勢可以充分證實使用宏的必要性。在第19章的查詢編譯器里,可以轉移到編譯期的計算量相當可觀,這使我們有理由把整個程序變成一個獨立的巨型宏。盡管效率是初衷,這一轉移同時也讓程序和Lisp 走得更近:在新版本里,能更容易地使用Lisp 表達式,比如說可以在查詢的時候用Lisp 的算術表達式。

弊:

  1. 函數即數據,而宏在編譯器看來,更像是一些指令。函數可以當成參數傳遞(例如用 apply),被函數返回,或者保存在數據結構里。但這些宏都做不到。

有的情況下,你可以通過將宏調用封裝在 lambda 表達式里來達到目的。如果你想用 apply 或 funcall 來調用某些的宏,這樣是可行的,例如:

> (funcall #'(lambda (x y) (avg x y)) 1 3)
2

不過這樣做還是有些麻煩。而且它有時還無法正常工作:如果這個宏帶有&rest 形參,那么就無法給它傳遞可變數量的實參,avg 就是個例子。

  1. 源代碼清晰。宏定義和等價的函數定義相比更難閱讀。所以如果將某個功能寫成宏只能稍微改善程序,那么最好還是改成使用函數。

  2. 運行期清晰。宏有時比函數更難調試。如果你在含有許多宏的代碼里碰到運行期錯誤,那么你在 backtrace 里看到的代碼將包含所有這些宏調用的展開式,而它們和你最初寫的代碼看起來可能會大相徑庭。

并且由于宏展開以后就消失了,所以它們在運行時是看不到的。你不是總能使用 trace 來分析一個宏的調用過程。如果 trace 真的奏效的話,它展示給你的只是對宏展開函數的調用,而非宏調用本身的調用。

  1. 遞歸。在宏里使用遞歸不像在函數里那么簡單。盡管展開一個宏里的展開函數可能是遞歸的,但展開式本身可能不是。第 10.4 節(jié)將處理跟宏里的遞歸有關的主題。

在決定何時使用宏的時候需要權衡利弊,綜合考慮所有這些因素。只有靠經驗才能知道哪一個因素在起主導作用。盡管如此,出現在后續(xù)章節(jié)里的宏的示例涵蓋了大多數對宏有利的情形。如果一個潛在的宏符合這里給出的條件,那么把它寫成這樣可能就是合適的。

最后,應該注意運行期清晰(觀點 6) 很少成為障礙。調試那種用很多宏寫成的代碼并不像你想象的那樣困難。如果一個宏的定義長達數百行,在運行期調試它的展開式的確是件苦差事。但至少實用工具往往出現在小而可靠的程序層次中。通常它們的定義長度不超過 15 行。所以就算你最終只得仔細檢查一系列的 backtrace ,這種宏也不會讓你云遮霧繞,摸不著頭腦。

8.3 宏的應用場合

在了解了宏的十八般武藝之后,下一個問題是:我們可以把宏用在哪一類程序里?關于宏的用途,最正式的表述可能是:它們主要用于句法轉換(syntactic transformations)。這并不是要嚴格限制宏的使用范圍。由于 Lisp 程序從列表中生成,而列表是 Lisp 數據結構,"句法轉換" 的確有很大的發(fā)揮空間。第 19-24 章展示的整個程序,其目的就可以說成 "句法轉換",而且從效果上看,所有宏莫不是如此。

宏的種種應用一起織成了一條緞帶,這些應用涵蓋了從像 while 這樣小型通用的宏,直到后面章節(jié)定義的大型、特殊用途的宏。緞帶的一端是實用工具,它們和每個 Lisp 都內置的那些宏是一樣的。它們通常短小、通用,而且相互獨立。盡管如此,你也可以為一些特別類型的程序編寫實用工具,然后當你有一組宏用于,比如說,圖形程序的時候,它們看起來就像是一種專門用于圖形編程的語言。在緞帶的遠端,宏允許你用一種和 Lisp 截然不同的語言來編寫整個程序。以這種方式使用宏的做法被稱為實現嵌入式語言。

實用工具是自底向上風格的首批成果。甚至當一個程序規(guī)模很小而不必分層構建時,它也仍然能夠對程序的最底層,即 Lisp 本身加以擴充,并從中獲益。nil! 將其參數設置為 nil ,這個實用工具只能定義成宏:

(defmacro nil! (x)
  '(setf ,x nil))

看到 nil! ,可能有人會說它什么都做不了,無非可以讓我們少輸入幾個字罷了。是的,但是充其量,宏所能做的也就是讓你少打些字而已。如果有人非要這樣想的話,那么其實編譯器的工作也不過是讓人們用機器語言編程的時候可以少些。不可低估實用工具的價值,因為它們的功用會積少成多:幾層簡單的宏拉開了一個優(yōu)雅的程序和一個晦澀的程序之間的差距。

多數實用工具都含有模式。當你注意到代碼中存在模式時,不妨考慮把它寫成實用工具。模式是計算機最擅長的。為什么有程序可以代勞,還要自己動手呢?假設在寫某個程序的時候,你發(fā)現自己以同樣的通用形式在很多地方做循環(huán)操作:

(do ()
  ((not <condition>))
  . <body of code>)

從列表中生成,是指列表作為編譯器的輸入。函數不再從列表中生成,雖然在一些早期的方言里的確是這樣處理的。

當你在自己的代碼里發(fā)現一個重復的模式時,這個模式經常會有一個名字。這里,模式的名字是 while 。如果我們想把它作為實用工具提供出來,那么只能以宏的形式,因為需要用到帶條件判斷的求值,和重復求值。倘若用第 7.4 節(jié)的定義實現 while ,如下:

(defmacro while (test &body body)
  '(do ()
    ((not ,test))
    ,@body))

就可以將該模式的所有實例替換成:

(while <condition>
  . <body of code>)

這樣做使得代碼更簡短,同時也更清晰地表明了程序的意圖。

宏的這種變換參數的能力使得它在編寫接口時特別有用。適當的宏可以在本應需要輸入冗長復雜表達式的地方只輸入簡短的表達式。盡管圖形界面減少了為最終用戶編寫這類宏的需要,程序員卻一直使用這種類型的宏。最普通的例子是 defun ,在表面上,它創(chuàng)建的函數綁定類似用 Pascal 或 C 這樣的語言定義的函數。第 2 章提到下面兩個表達式差不多具有相同的效果:

(defun foo (x) (* x 2))

(setf (symbol-function 'foo)
  #'(lambda (x) (* x 2)))

這樣 defun 就可以實現成一個將前者轉換成后者的宏。我們可以想象它會這樣寫:

(defmacro our-defun (name parms &body body)
  '(progn
    (setf (symbol-function ',name)
      #'(lambda ,parms (block ,name ,@body)))
    ',name))

像 while 和 nil! 這樣的宏可以被視為通用的實用工具。任何 Lisp 程序都可以使用它們。但是特定的領

域同樣也可以有它們自己的實用工具。沒有理由認為擴展編程語言的唯一平臺只能是原始的 Lisp。舉個例子,如果你正在編寫一個 ?? 程序,有時,最佳的實現可能會把它寫成兩層:一門專用于 CAD 程序的語言

(或者如果你偏愛更現代的說法,一個工具箱(toolkit)),以及在這層之上的,你的特定應用。

Lisp 模糊了許多對其他語言來說理所當然的差異。在其他語言里,在編譯期和運行期,程序和數據,以及語言和程序之間具有根本意義上的差異。而在 Lisp 里,這些差異就退化成了口頭約定。例如,在語言和程序之間就沒有明確的界限。你可以根據手頭程序的情況自行界定。因而,是把底層代碼稱作工具箱,還是稱之為語言,確實不過是個說法而已。將其視為語言的一個好處是,它暗示著你可以擴展這門語言,就像你通過實用工具來擴展 Lisp 一樣。

設想我們正在編寫一個交互式的 2D 繪圖程序。為了簡單起見,我們將假定程序處理的對象只有線段,每條線段都表示成一個起點 和一個向量 。并且我們的繪圖程序的任務之一是平移一組對象。

這正是 [示例代碼 8.1] 中函數 move-objs 的任務。出于效率考慮,我們不想在每個操作結束后重繪整個屏幕 只畫那些改變了的部分。因此兩次調用了函數 bounds ,它返回表示一組對象的矩形邊界的四個坐標(最小x ,最小y ,最大x ,最大y)。move-objs 的操作部分被夾在了兩次對 bounds 調用的中間,它們分別找到平移前后的矩形邊界,然后重繪整個區(qū)域。

函數 scale-objs 被用來改變一組對象的大小。由于區(qū)域邊界可能隨縮放因子的不同而放大或者縮小,這個函數也必須在兩次 bounds 調用之間發(fā)生作用。隨著我們繪圖程序開發(fā)進度的不斷推進,這個模式一次又一次地出現在我們眼前:在旋轉,翻轉,轉置等函數里。

通過一個宏,我們可以把這些函數中相同的代碼抽象出來。[示例代碼 8.2] 中的宏with-redraw 給出了一個框架,

它是圖 8.1 中幾個函數所共有的。 這樣的話,這些函數每一個的定義都縮減到了四行代碼,如圖 8.2 末尾


[示例代碼 8.1] 最初的平移和縮放

(defun move-objs (objs dx dy)
  (multiple-value-bind (x0 y0 x1 y1) (bounds objs)
    (dolist (o objs)
      (incf (obj-x o) dx)
      (incf (obj-y o) dy))
    (multiple-value-bind (xa ya xb yb) (bounds objs)
      (redraw (min x0 xa) (min y0 ya)
        (max x1 xb) (max y1 yb)))))

(defun scale-objs (objs factor)
  (multiple-value-bind (x0 y0 x1 y1) (bounds objs)
    (dolist (o objs)
      (setf (obj-dx o) (* (obj-dx o) factor)
        (obj-dy o) (* (obj-dy o) factor)))
    (multiple-value-bind (xa ya xb yb) (bounds objs)
      (redraw (min x0 xa) (min y0 ya)
        (max x1 xb) (max y1 yb)))))

[示例代碼 8.2] 骨肉分離后的平移和縮放

(defmacro with-redraw ((var objs) &body body)
  (let ((gob (gensym))
      (x0 (gensym)) (y0 (gensym))
      (x1 (gensym)) (y1 (gensym)))
    '(let ((,gob ,objs))
      (multiple-value-bind (,x0 ,y0 ,x1 ,y1) (bounds ,gob)
        (dolist (,var ,gob) ,@body)
        (multiple-value-bind (xa ya xb yb) (bounds ,gob)
          (redraw (min ,x0 xa) (min ,y0 ya)
            (max ,x1 xb) (max ,y1 yb)))))))

(defun move-objs (objs dx dy)
  (with-redraw (o objs)
    (incf (obj-x o) dx)
    (incf (obj-y o) dy)))

(defun scale-objs (objs factor)
  (with-redraw (o objs)
    (setf (obj-dx o) (* (obj-dx o) factor)
      (obj-dy o) (* (obj-dy o) factor))))

所示。通過這兩個函數,這個新寫的宏在簡潔性方面作出的貢獻證明了它是物有所值的。并且,一旦把屏幕重繪的細節(jié)部分抽象出來,這兩個函數就變得清爽多了。

對 with-redraw ,有一種看法是把它視為一種語言的控制結構,這種語言專門用于編寫交互式的繪圖程序。

隨著我們開發(fā)出更多這樣的宏,它們不管從名義上,還是在實際上都會構成一門專用的編程語言,并且我們的程序也將開始表現出其不俗之處,這正是我們用特制的語言撰寫程序所期望的效果。

宏的另一主要用途就是實現嵌入式語言。Lisp 在編寫編程語言方面是一種特別優(yōu)秀的語言,因為Lisp 程序可以表達成列表,而且Lisp 還有內置的解析器(read) 和編譯器(compile) 可以用在以這種方式表達的程序中。多數時候甚至不用調用 compile ;你可以通過編譯那些用來做轉換的代碼(第 2.9 節(jié)),讓你的嵌入式語言在無形中完成編譯。

這個宏的定義使用了下一章才出現的 gensym 。它的作用接下來就會說明。

與其說嵌入式語言是構建于 Lisp 之上的語言,不如說它是和Lisp 融為一體的,這使得其語法成為了一個 Lisp 和新語言中特有結構的混合體。實現嵌入式語言的初級方式是用Lisp 給它寫一個解釋器。有可能的話,一個更好的方法是通過語法轉換實現這種語言:將每個表達式轉換成 Lisp 代碼,然后讓解釋器可以通過求值的方式來運行它。這就是宏大展身手的時候了。宏的工作恰恰是將一種類型的表達式轉換成另一種類型,所以在編寫嵌入式語言時,宏是最佳人選。

一般而言,嵌入式語言可以通過轉換實現的部分越多越好。主要原因是可以節(jié)省工作量。舉個例子,如果新語言里含有數值計算,那你就無需面對表示和處理數值量的所有細枝末節(jié)。如果 Lisp 的計算功能可以滿足你的需要,那么你可以簡單地將你的算術表達式轉換成等價的Lisp 表達式,然后將其余的留給 Lisp 處理。

代碼轉換通常都會提高你的嵌入式語言的效率。而解釋器在速度方面卻一直處于劣勢。當代碼里出現循環(huán)時,通常每次迭代解釋器都必須重新解釋代碼,而編譯器卻只需做一次編譯。因此,就算解釋器本身是編譯的,使用解釋器的嵌入式語言也會很慢。但如果新語言里的表達式被轉換成了 Lisp,那么 Lisp 編譯器就會編譯這些轉換出來的代碼。這樣實現的語言不需要在運行期承受解釋的開銷。要是你還沒有為你的語言編寫一個真正編譯器,宏會幫助你獲得最優(yōu)的性能。事實上,轉換新語言的宏可以看作該語言的編譯器 -- 只不過它的大部分工作是由已有的 Lisp 編譯器完成的。

這里我們暫時不會考慮任何嵌入式語言的例子,第19-25 章都是關于該主題的。第 19 章專門講述了解釋與轉換嵌入式語言之間的區(qū)別,并且同時用這兩種方法實現了同一種語言。

有一本 Common Lisp 的書斷言宏的作用域是有限的,依據是:在所有 CLTL1 里定義的操作符中,只有少于 10% 的操作符是宏。這就好比是說因為我們的房子是用磚砌成的,我們的家具也必須得是。宏在一個 Common Lisp 程序中所占的比例多少完全要看這個程序想干什么。有的程序里可能根本沒有宏,而有的程序可能全是宏。

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

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號