S-expression, macro and develop
這篇文章是從我在 Clojure Taiwan 的分享 clojure isn't lisp enough 改編而來的。由於演講與文章的差異,編排會略有不同,但主旨仍然是 macro 系統與開發方式之間的交互影響。
這篇文章前段的兩大主角分別是 Racket 和 Clojure。Clojure 是 2007 年從 Lisp 家族分支出的不可變範式(也有人說函數式,但對我來說函數式僅需要 first-class function 即可)語言。Lisp 在 1975 年有了一群人把 dynamic scoping 改掉,並稱為 scheme 的分支語言。這個分支語言又在之後多了一個實驗分支 PLT scheme,最後 PLT scheme 改名 Racket。
1. 善用 S-expression
要引出我們的主題,得從怎樣才算是 Lisp 開始,各家有各家的說法,因此有了 *Lisp 九宮格*的梗圖。首先我們從 Clojure 開始談起。
Clojure 的語法相較於傳統的 Lisp 更加的依賴 parser,下面我們舉三個例子,Racket 作為對照組。
1.1. 第一組案例
(condp = n [0 1 1 1 (+ (fib (- n 1)) (fib (- n 2)))])
假設我們忘了寫第一個 branch 的 expression。
(condp = n [0 1 1 (+ (fib (- n 1)) (fib (- n 2)))])
假設我們忘了寫第二個 branch 的 pattern。
(condp = n [0 1 1 (+ (fib (- n 1)) (fib (- n 2)))])
以上兩種情況語義竟然是一樣的,並且沒有出現應有的錯誤報告。同樣的情況 Racket 會報告:=match: expected at least one expression on the right-hand side in: ((0))= 跟 =match: expected at least one expression on the right-hand side in: ((1))=,並明確標出錯誤位置。
1.2. 第二組案例
(let [x n y 2] (+ x y))
假設我們忘了寫 =2=。
(let [x n y ] (+ x y))
Clojure 會給出匪夷所思的錯誤訊息:=[x n y] - failed: even-number-of-forms? at: [:bindings] spec: :clojure.core.specs.alpha/bindings=。Racket 則會說:=let: bad syntax (not an identifier and expression for a binding) in: (y)=,順便標出出錯的 binding。
1.3. 第三組案例
{:a 1 :b 2 :c 3}
假設少寫 2=,Clojure 會持續不斷的希望你知道什麼是 even number of
forms:=Map literal must contain an even number of forms
{:a 1 :b :c 3}
Racket 則是指出語法錯誤。
2. 差別在哪?
其實事情很簡單,Clojure 的作者並不明白 S-expression 的好處,沒有善用 S-expression 卻又抄了一部分 Lisp。S-expression 要嘛你就全都用,每個語義分隔都用 S-expression 完成;要嘛就有足夠好的 parsing 並報錯的能力。Clojure 缺乏後半部分,所以即使以下程式碼寫得出來(善用 S-expression 的 let form),還是會栽在報錯能力不夠好的問題上。
(defmacro let [bindings & body] (doseq [bind bindings] (assert (= (-> bind count) 2) "invalid binding")) `((fn ~@(map first bindings) body) ~@(map second bindings)))
要進一步探討,就要問下面的問題。
3. 什麼是 macro?
什麼是 macro?不同的語言給出了不同的解答。
對 C 語言來說,macro 就只是文字替換的機制。前面我們費心討論的問題到這裡根本沒有意義,反正 C macro 只能保障寫得出來,不保障產出的程式正確。
對 C++ 的 Template 來說,macro 就是 meta substitution。跟 C 的文字替換其實沒有太大分別,不過有一些小技巧可以提供還可以的錯誤報告。
到了 Clojure 這個程度,我們認為 macro 其實就是 compile-time function。錯誤報告的能力也有了,因此也是 syntax validator,但缺乏定位錯誤的能力(C++ 反而有相應功能)。
Racket 模型中語言是由多個 phase 組成的,phase 0 就是 runtime,phase 1, 2, 3… 都是 compile-time,數字越大越先執行。因此所謂的 macro 不過就是前一個 phase 裡的 function。
Lisp 的 f-expr 後來有個 formal 改進版本,叫做 vau operator,這個 operator 定義出來的東西跟 \(\lambda\) 很像,只是多一個參數拿環境。換句話說這樣的語言有 first-class environment。
到此我們知道 macro 有多種樣態,但萬變不離核心:製造「新」語法甚至「新」語義。
4. Racket macro 進化史
前面說了這麼多,那 Racket/Scheme 又擁有怎樣的能力呢?1975 年最早的
syntax-rules
其實只有完成新語法的能力,並不能做更多事情。
(define-syntax let (syntax-rules () [(_ ([var rhs] ...) body) ((λ (var ...) body) rhs ...)]))
到了 1988 年,=syntax-case= 引入了驗證語法的概念。
(define-syntax (let stx) (syntax-case stx () [(_ ([var rhs] ...) body) (not (check-duplicate-identifier (syntax->list #'(var ...)))) #'((λ (var ...) body) rhs ...)]))
我們可以用 raise-syntax-error
稍加改進
(define-syntax (let stx) (syntax-case stx () [(_ ([var rhs] ...) body) (begin (for ([var (syntax->list #'(var ...))]) (unless (identifier? var) (raise-syntax-error 'not-identifier "expected identifier" stx var))) (let ([dup (check-duplicate-identifier (syntax->list #'(var ...)))]) (when dup (raise-syntax-error 'dup "duplicate variable name" stx)))) #'((λ (var ...) body) rhs ...)]))
但這樣實在太麻煩也太醜了,所以 1993 年加入了 =syntax-parse=。
(define-syntax (let stx) (syntax-parse stx [(_ ([var:id rhs:expr] ...) body:expr) (check-duplicate-identifier (syntax->list #'(var ...))) #'((λ (var ...) body) rhs ...)]))
其中 var:id
會自動檢查 var
是不是 identifer?=,也就不需要像
=syntax-case
那樣大費周章只是為了驗證語法的類型。=id= 是
=syntax-class=,我們其實可以自己定義。
(define-syntax (let stx) (define-syntax-class binding #:description "binding pair" (pattern [var:id rhs:expr])) (define-syntax-class distinct-bindings #:description "sequence of binding pairs" (pattern (b*:binding ...) #:fail-when (check-duplicate-identifier (syntax->list #'(b*.var ...))) "duplicate variable name" #:with (var ...) #'(b*.var ...) #:with (rhs ...) #'(b*.rhs ...))) (syntax-parse stx [(_ d:distinct-bindings body:expr) #'((λ (d.var ...) body) d.rhs ...)]))
syntax-parse
這個名字其實已經說明了很多:這是一個完整的 parsing
工具。它甚至有 error selection 機制。
5. Racket macro 現代模型
正如前一節所述,Racket macro 並不是突然就這麼厲害的,其中經過多年中許多人的改進才成為今天的樣貌。其中最重要的要屬 1986 年的 Hygienic Expansion,這是第一個解決 macro 展開後與目標環境變數衝突的機制。
但 1986 年的 Hygienic Expansion 有一些問題,這時的 Hygienic 是靠簡單的 renaming 實現的。
- 不善應付遞迴定義的環境,假設 macro 引用到自己,正確實作的難度就很高,展開的 macro 也很笨重又難以反追蹤原本的程式
- 面對 hygiene bending(e.g.
datum->syntax
) 沒有效率又很難實作
直到 2016 年的 Binding as Sets of Scopes 才提出了新的模型:Scope sets。
其模型概念非常簡單:
- 為綁定處如
let=、=lambda
等 scope 生成新標籤,這些標籤必須獨一無二 - 內層擁有外層的標籤(lambda 捨棄上層標籤除外)。把當前層所有標籤連結到參考
- 參考往外尋找綁定處參考,擁有最大子集的綁定就是當前應該參照到的定義
- syntax 擁有的是一個標籤函數,端看展開位置(
eval
)決定函數要帶入什麼
(let ([x 1]) ; x{let1} (lambda (x) ; x{lambda1} x)) ; x{let1, lamdba1}
上面列出了各個 identifier 的 scope sets 做為參考。希望有助於理解 scope sets 的概念。
6. Macro 與開發
到此,我們可以總結:macro 就是 compiler。而目前 macro 目前在開發上還有很多的問題。因為僅是創造出新語法是不夠的,新語法還必須是容易開發的。這本來是顯而易見的,在 compiler 上人人如此追求,等到了 macro 這邊,大家卻又忘了這麼一回事。寫不出好的 compiler 就寫不出好的 macro,而僅僅是白費力氣。
macro 總得來說最差勁的地方就是重構與 code navigation。沒有人知道怎麼重構一個新的語法,也不知道怎麼提示使用者補全它。也沒辦法跳到用 macro 定義的定義處。其中一個可行的方向是讓 macro 的作者提供相關的資訊,也是我接下來的計畫之一。racket 在這方面只能算是解決了一半,macro 作者可以利用 compile to define 或是提供 missing reference 提供跳到定義的功能,但補全還是空白一片。
然而還有更難解的問題,racket 的 first-class class 就是典型的例子。在保持
class 跟 racket 本身可以同時使用的前提下,寫不出可以不污染環境又可以跳到
method 定義的 class。因為 new
擦掉了 phase 1 的資訊,然而以 phase 0
帶資訊給 send
沒辦法提供 class
information(被擦掉了)。這種困境顯然不只會出現在
class,而是任何具備擦除的 form,然而我們又不總是可以去修改 base
language。
7. 總結
希望讀者有更了解 macro,並且明白什麼時候該用,什麼時候不該。做出明智的判斷是每個開發者每天都會面對的挑戰 XD。