UP | HOME

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 實現的。

  1. 不善應付遞迴定義的環境,假設 macro 引用到自己,正確實作的難度就很高,展開的 macro 也很笨重又難以反追蹤原本的程式
  2. 面對 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。

Date: 2021-05-11 Tue 00:00
Author: Lîm Tsú-thuàn