Programming Languages: Application and Interpretation【譯13】

審校: @lotuc

原文:PLAI 第二版

GitHub:PLAI-cn

GitBook:PLAI-cn

翻譯聲明見 Github 倉庫


13 語言中支持去語法糖

關於去語法糖(desugaring),之前很多討論都談到、用到了,但是我們目前的去語法糖機制是薄弱的。實際上我們用兩種不同的方式來使用去語法糖。一方面,我們用它來縮小語言:輸入是一個大語言,去語法糖後得到其核心。另一方面,我們也用它來擴展語言:給定現有語言,為其添加新的功能。這表明,去語法糖是非常有用的功能。它是如此之有用,我們該思考一下如下兩個問題:

  • 我們創建語言的目的是簡化常見任務的創建,那麼,設計一種支持去語法糖的語言,它會長什麼樣子呢?請注意,這裡的「樣子」不僅僅指語法,也包括語言的行為特性。
  • 通用語言常常被用作去語法糖的目標,那為什麼他們不內建去語法糖的能力呢?比如說,擴展某個基本語言,添加上一個問題的答案所描述的語言。

本章我們將通過研究Racket提供的解決方案同時探索這兩個問題。

13.1 第一個例子

DrRacket有個非常有用的工具叫做Macro Stepper(宏步進器),它能逐步逐步地顯示程序的展開。你應該對本章中的所有例子嘗試Macro Stepper。不過現在,你應該用#lang plai而不是#lang plai-typed來運行。

回憶一下,前文我們添加let時,是將其當作lambda的語法糖的。它的模式是:

(let (var val) body)n

被轉換為

((lambda (var) body) val)n

思考題

如果這聽起來不太熟悉,那麼現在是時候回憶一下它是怎麼運作的了。

描述這個轉換最簡單的方法就是直接把它寫出來,比如:

(let (var val) body)n->n((lambda (var) body) val)n

事實上,這差不多正是Racket語法允許你做的。

我們將其命名為my-let而不是let,因為後者在Racket中已經有定義了。

(define-syntax my-let-1 ;定義語法n (syntax-rules () ;語法規則n [(my-let-1 (var val) body)n ((lambda (var) body) val)]))n

syntax-rules告訴Racket,只要看到的某個表達式在左括弧之後跟的是my-let-1,就應該檢查它是否遵循模式(my-let-1 (var val) body)。這裡varvalbody語法變數:它們是代表代碼的變數,可以匹配該位置的任意表達式。如果表達式和模式匹配,那麼語法變數就綁定為對應的表達式,並且在右邊(的表達式中)可用。

您可能已經注意到一些額外的語法,如()。 我們稍後再解釋。

右邊(的表達式)——在這裡是((lambda (var) body) val)——就是最後的輸出。每個語法變數都被替換(注意我們的老朋友,替換)其對應的輸入部分。這個替換過程非常簡單,不會做過多的處理。因此,如果我們嘗試這麼用

(my-let-1 (3 4) 5)n

第一步Racket不會抱怨3出現在標識符的位置;相反,它會照常處理,去語法糖得

((lambda (3) 5) 4)n

下一步會產生錯誤:

lambda: expected either <id> or `[<id> : <type>]n for function argument in: 3n

這就表明,去語法糖的過程在其功能上直截了當:它不會嘗試猜測啥或者做啥聰明事,就是簡單的替換重寫而已。其輸出是表達式,這個表達式也可以被進一步去語法糖。

前文中提到過,這種簡單的表達式重寫通常使用術語(macro)稱呼。傳統上,這種類型的去語法糖被稱為宏展開(macro expansion),不過這個術語有誤導性,因為去語法糖後的輸出可以比輸入更小(通常還是更大啦)。

當然,在Racket中,let可以綁定多個標識符,而不僅僅是一個。非正式的寫下這種語法的描述的話,比如在黑板上,我們可能會這樣寫,(let ([var val] ...) body) -> ((lambda (var ...) body) val ...),其中...表示「零或更多個」 ,意思是,輸出中的var ...要對應輸入中的多個var。同樣,描述它的Racekt語法長的差不多就是這樣:

(define-syntax my-let-2n (syntax-rules ()n [(my-let-2 ([var val] ...) body)n ((lambda (var ...) body) val ...)]))n

請注意...符號的能力:輸入中「對」的序列在輸出中變成序列對了;換句話說,Racket將輸入序列「解開」了。與之相對,同樣的符號也可以用來組合序列。

13.2 用函數實現語法變換器

之前我們看到,my-let-1並不會試圖確保標識符位置中的語法是真正的(即語法上的)標識符。用syntax-rules機制我們沒法彌補這一點,不過使用更強大的機制,稱為syntax-case,就可以做到。由於syntax-case還有很多其他有用的功能,我們分步來介紹它。

首先要理解的是,宏實際上是一種函數。但是,它並不是從常見的運行時值到(其他)運行時值的函數,而是從語法到語法的函數。這種函數執行的目的是創建要被執行的程序。注意這裡我們說的是要被執行的程序:程序的實際執行可能會晚得多(甚至根本不執行)。看看去語法糖的過程,這點就很清楚了,很顯然它是(一種)語法到(另一種)語法的函數。兩個方面可能導致混淆:

  • syntax-rules的表示中並沒有明確的參數名或者函數頭部,可能沒有明確表明這是一個轉換函數(不過重寫規則的格式有暗示這個事實)。
  • 去語法糖指的是,有個(完整的)函數完成了整個過程。這裡,我們實際寫的是一系列小函數,每個函數處理一種新的語法結構(比如my-let-1),這些小函數被某個看不見的函數組合起來,完成整個重寫過程。(比如說,我們並沒有說明,某個宏展開後的輸出是否還會進一步被展開——不過簡單試一下就知道,事實確實如此。)

練習

編寫一個或多個宏,以確定宏的輸出會被進一步展開。

還有個微妙之處。宏的外觀和Racket代碼非常類似,並沒有指明它「生活在另一個世界」。想像宏定義使用的是完全不同的語言——這種語言只處理語法——寫就很有助於我們建立抽象。然而,這種簡化並不成立。現實中,程序變換器——也被稱為編譯器(compiler)——也是完整的程序,它們也需要普通程序所需要的全部功能。也就是說我們還需要創立一種平行語言,專門處理程序。這是浪費和毫無意義的;因此,Racket自身就支持語法轉換所需的全部功能。

背景說完了,接下來開始介紹syntax-case。首先我們用它重寫my-let-1(重寫時使用名字my-let-3)。第一步還是先寫定義的頭部;注意到參數被明確寫出:

<sc-macro-eg> ::= ;syntax-case宏,示例nn (define-syntax (my-let-3 x)n <sc-macro-eg-body>)n

x被綁定到整個(my-let-3 ...)表達式

你可能想到了,define-syntax只是告訴Racket你要定義新的宏。它不會指定你想要實現的方式,你可以自由地使用任何方便的機制。之前我們用了syntax-rules;現在我們要用syntax-case。對於syntax-case,它需要顯式的被告知要進行模式匹配的表達式:

<sc-macro-eg-body> ::=nn (syntax-case x ()n <sc-macro-eg-rule>)n

現在可以寫我們想要表達的重寫規則了。之前的重寫規則有兩個部分:輸入結構和對應的輸出。這裡也一樣。前者(輸入匹配)和以前一樣,但後者(輸出)略有不同:

<sc-macro-eg-rule> ::=nn [(my-let-3 (var val) body)n #((lambda (var) body) val)]n

關鍵是多出了幾個字元:#』。讓我們來看看這是什麼。

syntax-rules中,輸出部分就指定輸出的結構。與之不同,syntax-case揭示了轉換過程函數的本質,因此其輸出部分實際上是任意表達式,該表達式可以執行任何它想要進行的計算。該表達式的求值結果應該是語法。

語法其實是個數據類型。和其他數據類型一樣,它有自己的構造規則。具體來說,我們通過寫#』來構造語法值;之後的那個s-expression被當作語法值。(順便提一句,上面宏定義中的x綁定的也是這種數據類型。)

語法構造器#』有種特殊屬性。在宏的輸出部分中,所有輸入中出現的語法變數都被自動綁定並替換。因此,比方說,當展開函數在輸出中遇到var時,它會將var替換為相應的輸入表達式。

思考題

在上述宏定義中去掉#』試試看。後果如何?

到目前為止,syntax-case似乎只是更為複雜的syntax-rules:唯一稍微好些的地方是,它更清楚地描述了展開過程的函數本質,同時明確了輸出的類型,但其他方面則更加笨拙。但是,我們將會看到,它還提供了強大的功能。

練習

事實上,syntax-rules可以被表述為基於syntax-case的。請定義這個宏。

13.3 防護裝置

現在我們可以回過來考慮到最初引致syntax-case的問題:確保my-let-3的綁定位置在語法上是標識符。為此,您需要知道syntax-case的一個新特性:每一條重寫規則可以包含兩個部分(如同前面的例子),也可以包含三個部分。如果有三個部分,中間那個被視為防護裝置(guard):它是一個判斷,僅當其計算值為真時,展開才會進行,否則就報告語法錯誤。在這個例子中,有用的判斷函數是identifier?,它能判定某個語法對象是否是標識符(即變數)。

思考題

寫出防護裝置,並寫出包含防護(裝置)的(重寫)規則。

希望你發現了其中的微妙之處:identifier?的參數是語法類型的。要傳給它的是綁定到var的實際語法片段。回想一下,var是在語法空間中綁定的,而#』會替換其中的綁定變數。因此,這裡防護裝置的正確寫法是:

(identifier? #var)n

有了這些信息,我們現在可以寫出整個規則:

<sc-macro-eg-guarded-rule> ::=nn [(my-let-3 (var val) body)n (identifier? #var)n #((lambda (var) body) val)]n

思考題

現在有了帶防護的規則定義,嘗試使用宏,在綁定位置使用非標識符,看看會發生什麼。

13.4 Or:簡單但是包含很多特性的宏

考慮or,它實現或操作。使用前綴語法的話,自然的做法是允許or有任意數目的子項。我們把or展開為嵌套的條件(表達式),以此判斷表達式的真假。

13.4.1 第一次嘗試

試試這樣的or:

(define-syntax (my-or-1 x)n (syntax-case x ()n [(my-or-1 e0 e1 ...)n #(if e0n e0n (my-or-1 e1 ...))]))n

它說,我們可以提供任何數量的子項(待會兒再解釋這點)。(宏)展開將其重寫為條件表達式,其中的條件是第一個子項;如果該項為真值,就返回這個值(待會再討論這點!),否則就返回其餘項的或。

我們來試一個簡單的例子。這應該計算為真,但是:

> (my-or-1 #f #t)nmy-or-1: bad syntax in: (my-or-1)n

發生了什麼?這個表達式變成了

(if #fn #fn (my-or-1 #t))n

繼續展開

(if #fn #fn (if #tn #tn (my-or-1)))n

對此我們沒有定義。這是因為,模式e0 e1 ...表示一個或更多子項,但是我們忽略了沒有子項的情況。

沒有子項時應該怎麼辦?或運算的單位元是假值。

練習

為什麼正確的默認值是#f

我們可以通過加上這條規則,展示不止一條規則的宏。宏的規則是順序匹配的,所以我們必須把最具體的規則放在最前面,以免它們被更一般的規則覆蓋(儘管在這個例子中,兩條規則並不重疊)。改進後的宏是:

(define-syntax (my-or-2 x)n (syntax-case x ()n [(my-or-2)n ##f]n [(my-or-2 e0 e1 ...)n #(if e0n e0n (my-or-2 e1 ...))]))n

現在宏可以和預期一樣展開了。雖然沒有必要,但是我們加上一條規則,處理只有一個子項的情況:

(define-syntax (my-or-3 x)n (syntax-case x ()n [(my-or-3)n ##f]n [(my-or-3 e)n #e]n [(my-or-3 e0 e1 ...)n #(if e0n e0n (my-or-3 e1 ...))]))n

這使展開的輸出更加簡約,對後文中我們的討論是有幫助的。

注意到在這個版本的宏中,規則再是互不重疊的了:第三條規則(一個或多個子項)包含了第二條(一個子項)。因此,第二條規則與第三條不能互換,這是至關重要的。

13.4.2 防護裝置的求值

之前說這個宏的展開符合我們的預期,是吧?試試這個例子:

(let ([init #f])n (my-or-3 (begin (set! init (not init))n init)n #f))n

請注意,or返回的是第一個「真值」的值,以便程序員在進一步的計算中使用它。因此,這個例子返回init的值。我們期望它是什麼?因為我們已經翻轉了init的價值,自然而然的,我們期望它返回#t。但是計算得到的是#f

這裡的問題不在set!。比如說,如果我們在這裡不放賦值,而是放上列印輸出,那麼列印輸出就會發生兩次。

要理解為何如此,我們必須檢查展開後的代碼:

(let ([init #f])n (if (begin (set! init (not init))n init)n (begin (set! init (not init))n init)n #f))n

啊哈!因為我們把輸出模式寫成了

#(if e0n e0n ...)n

當我們第一次寫下它時,看起來完全沒有問題,而這正表明了編寫宏(或,其他的程序轉換系統)時的一個非常重要的原則:不要複製代碼!在我們的設定中,語法變數永遠不應被重複;如果你需要重複某個語法變數,以至於它所代表的代碼會被多次執行,請確保已經考慮到了這麼做的後果。或者,如果只需要該表達式的,那麼綁定一下,接下來使用綁定標識符的名字就好。示例如下:

(define-syntax (my-or-4 x)n (syntax-case x ()n [(my-or-4)n ##f]n [(my-or-4 e)n #e]n [(my-or-4 e0 e1 ...)n #(let ([v e0])n (if vn vn (my-or-4 e1 ...)))]))n

這個引入綁定的模式會導致潛在的新問題:你可能會對不必要的表達式求值。事實上,它還會導致第二個、更微妙的問題:即使該表達式需要被求值,你可能在錯誤的上下文中對其求值了!因此,你必須仔細推敲表達式是否要被求值,如果是的話,只在正確的地方求一次值,然後存貯其值以供後續使用。

my-or-4重複之前包含set!的例子,結果是#t,符合我們的預期。

13.4.3 衛生

希望你現在覺得沒啥問題了。

思考題

還有啥問題?

考慮這個宏(let ([v #t]) (my-or-4 #f v))。我們希望其計算的結果是啥?顯然是#t:第一個分支是#f,但第二個分支是vv綁定到#t。但是觀察展開後:

(let ([v #t])n (let ([v #f])n (if vn vn v)))n

直接運行該表達式,結果為#f。但是,(let ([v #t]) (my-or-4 #f v))求值得#t。換種說法,這個宏似乎神奇地得到了正確的值:在宏中使用的標識符名稱似乎與宏引入的標識符無關!當它發生在函數中時,並不令人驚訝;宏展開過程也享有這種特性,它被稱為衛生(hygiene)。

理解衛生的一種方法是,它相當於自動將所有綁定標識符改名。也就是說,程序的展開如下:

(let ([v #t])n (or #f v))n

變成

(let ([v1 #t])n (or #f v1))n

(注意到v一致的重命名為v1),接下來變成

(let ([v1 #t])n (let ([v #f])n vn v1))n

重命名後變成

(let ([v1 #t])n (let ([v2 #f])n v2n v1))n

此時展開結束。注意上述每一個程序,如果直接運行的話,都會產生正確的結果。

13.5 標識符捕獲

衛生宏解決了語法糖的創造者常常會面對的重要痛點。然而,在少數情況下,開發人員需要故意違反衛生原則。回過來考慮對象,對於這個輸入程序:

(define os-1n (object/self-1n [first (x) (msg self second (+ x 1))]n [second (x) (+ x 1)]))n

(對應的)宏應該是什麼樣的?試試這樣:

(define-syntax object/self-1n (syntax-rules ()n [(object [mtd-name (var) val] ...)n (let ([self (lambda (msg-name)n (lambda (v) (error object "nothing here")))])n (beginn (set! selfn (lambda (msg)n (case msgn [(mtd-name) (lambda (var) val)]n ...)))n self))]))n

不幸的是,這個宏會產生以下錯誤:

self: unbound identifier in module in: selfn;self: 未綁定的標識符n

錯誤指向的是first方法體中的self。

練習

給出衛生展開的步驟,理解為何報錯是我們預期的結果。

在正面解決該問題之前,讓我們考慮輸入項的一種變體,使綁定顯式化:

(define os-2n (object/self-2 selfn [first (x) (msg self second (+ x 1))]n [second (x) (+ x 1)]))n

對應的宏只需要稍加修改:

(define-syntax object/self-2n (syntax-rules ()n [(object self [mtd-name (var) val] ...)n (let ([self (lambda (msg-name)n (lambda (v) (error object "nothing here")))])n (beginn (set! selfn (lambda (msg)n (case msgn [(mtd-name) (lambda (var) val)]n ...)))n self))]))n

這個宏展開正確。

習題

給出這個版本的展開步驟,看看不同在哪裡。

洞察其中的區別:如果進入綁定位置的標識符是由宏的用戶提供的話,那麼就沒有問題了。因此,我們想要假裝引入的標識符是由用戶編寫的。函數datum->syntax接收兩個參數,第一個參數是語法,它將第二個參數——s-expression——轉換為語法,假裝其是第一個參數的一部分(在我們的例子中,就是宏的原始形式,它被綁定為x)。為了將其結果引入到用於展開的環境中,我們使用with-syntax在環境中進行綁定:

(define-syntax (object/self-3 x)n (syntax-case x ()n [(object [mtd-name (var) val] ...)n (with-syntax ([self (datum->syntax x self)])n #(let ([self (lambda (msg-name)n (lambda (v) (error object "nothing here")))])n (beginn (set! selfn (lambda (msg-name)n (case msg-namen [(mtd-name) (lambda (var) val)]n ...)))n self)))]))n

於是我們可以隱式的使用self了:

(define os-3n (object/self-3n [first (x) (msg self second (+ x 1))]n [second (x) (+ x 1)]))n

13.6 對編譯器設計的影響

在一個語言的定義中使用宏對所有其工具都有影響,特別是編譯器。作為例子,考慮letlet的優點是,它可以被高效的編譯,只需要擴展當前環境就行了。相比之下,將let展開成函數調用會導致更昂貴的操作:創建閉包,再將其應用於參數,實際上獲得的效果是一樣的,但是花費更多時間(通常還要更多空間)。

這似乎是反對使用宏的論據。不過,聰明的編譯器會發現這個模式老是出現,並會在其內部將左括弧左括弧lambda轉換回let的等價形式。這麼做有兩個好處。第一個好處是,語言設計者可以自由地使用宏來獲得更小的核心語言,而不必與執行成本進行權衡。

第二個好處更微妙。因為編譯器能識別這個模式,其他的宏也可以利用它並獲得相同的優化;它們不再需要扭曲自己的輸出,如果自然的輸出恰好是左括弧左括弧lambda,將其再轉化成let(否則就必須這麼做)。比如說,在編寫某些模式匹配(的宏)的時候,左括弧左括弧lambda模式就會自然的出現,而想要將其轉換為let的話就必須多做一步——現在不必要了。

13.7 其他語言中的去語法糖

不僅僅是Racket,許多現代語言也通過去語法糖來定義操作。例如在Python中,for迭代就是語法模式。程序員寫下for x in o時,他

  • 引入了新標識符(稱之為i,但是,不要讓其捕獲了程序員定義的i,即,衛生的綁定i!),
  • 將其綁定到從o獲得的迭代器(iterator),
  • 創建(可能)無限的while循環,反覆調用i的.next方法,直到迭代器引發StopIteration異常。

現代編程語言中有許多這樣的模式。


推薦閱讀:

某愉悅的scheme之旅(5)--從零開始實現模式匹配宏
PLAI 編程環境簡單介紹
PLAI前6章回顧
Racket(Scheme)有哪些威力十足的庫?
Racket和Haskell誰更有發展潛力?

TAG:Racket | 程序设计语言设计 | 编程语言理论 |