peep into Extension Points
[tags: CPS OCaml monad ppx]

8月29日, OCaml 釋出了 4.02 版本。

Jane Street 的介紹短文

Extension Points, or how OCaml is becoming more like Lisp

無意看到這篇短文後,就對裏面提到的新特性很感興趣了。

瞎扯幾句先

Camlp4

Camlp4 的原作者比較有品味,要是當初 caml 的語法是由他來設計就好了。可惜 OCaml 語法成了現在這幅醜樣子。

Camlp4 出現後,結合 OCaml 編譯器就能用 sml, revised syntax, 甚至 scheme/lisp 語法來寫程式啦。單獨用的話,可將各種語法源碼作輸入,匯出各種語法源碼。

只不過,若是自己想寫語法擴展,或者重新設計語法。查看 Camlp4 模組之后可能就会心生惧意。因为僅僅列印出簽名就有一萬六千多列。

一直不建議用巨集,就算使用,也得是在一種可控的方式下使用。所以一直避免使用 lisp 的 macro. 也一直不建議用源碼產生器,比如 lex, yacc 這類東西。

綜上, Camlp4 我也不愛用了。

現在 Camlp4 從 OCaml 源碼中移除出去獨立了,他倆不再相互拖累也是件好事情吧。

OCaml

OCaml 通過提供 compiler libs 將編譯器,直譯器作爲庫提供給使用者。所以,新寫個 ml 語法的編譯器,直譯器都是分分鍾能搞定。內部的資料結構的暴露使得寫語法擴展也十分容易. 4.02 裏新加 ast_helper, ast_mapper 後,就更容易啦。

monad syntax extension

雖然必需的只是 let 運算式,不過爲了方便,也把 sequence 運算式的轉換做了。

爲了方便,就用 'm' 來命名這個語法擴展吧。於是就可這樣寫程式了:

let%m v= get () in
begin%m
  sleep 1.;
  printl v;
end
begin%m
  sleep 1.;
  printl "hello";
end

轉換過程和結果

如同 haskell 的 `do notation'。可將

do
  a <- get ()
  print a
  print_newline ()
或者等同的
do
  a <- get ()
  _ <- print a
  print_newline ()

轉換成

get () >>= (\a -> (print a) >> (print_newline ()))
或者等同的
get () >>= (\a -> (print a) >>= (\_-> print_newline ()))

上述的 >>= 函數也就是 bind 函數的中綴形式。寫成前缀式為

bind
  (get ())
  (\a ->
    bind
      (print a)
      (\_-> print_newline ()))

源碼及示例說明

在此查看說明,及下載源碼: ok_monad

第 9 列新增一個標識符产生函数。因爲都是作插入用的,所以本來不存在,所以位置就是 Location.none 了。

cps_mapper 的話就是拿 default_mapper 過來改動了 expr 的處理部分。一旦發現有用 'm' 標記的擴展點就開始處理了。

cps_sequence 和 cps_let 都很簡單。前者就直接遞迴製造函數應用,不斷地將 sequence 對中的 expr1 和一個新製造的函數應用給 'bind', 新製造的函數裏面則將 expr2 看作一個 sequence 繼續製造應用。因爲每次應用的結果都被無視,所以新製造的函數用「Pat.any ()」模式來匹配。

後者的話, let 分爲 bindings 部分和之後的 expr 部分。對於 bindings 部分,和上述同樣方式處理即可。只要把里面的 binding 的模式和運算式部分提取出來。將提取出的運算式和新製造的函數應用給 bind, 新製造的函數用的模式匹配就用提取出的模式匹配即可。最後則接上 let 運算式的 expr 部分即可。

來測試一下 t.ml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
let ()=
  begin%m
    say "hi";
    let a= 1
    and b= 2
    in
    let%m c= getC () in
    let%m d= getD () in
    begin%m
      sleep 1.;
      printl a b c d;
      return
        begin
          normal ();
          sequence ();
        end;
    end
  end

看看處理後的結果

kandu@bomb:~/c$ ocamlfind ocamlc -package ppx_ok_monad -dsource t.ml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
let () =
  bind (say "hi")
    (fun _  ->
        let a = 1
        and b = 2 in
        bind (getC ())
          (fun c  ->
            bind (getD ())
              (fun d  ->
                  bind (sleep 1.)
                    (fun _  ->
                      bind (printl a b c d)
                        (fun _  -> return
                          (normal (); sequence ()))))))

OCaml 的語法,被 Camlp4 作者拿 revised syntax 打臉後,大家自然是點頭咂嘴地說它醜。現在 OCaml 自己提供了新的語法擴展 API 後,另一些醜也開始暴露出來了。

舉例,看 parsetree.mli 裏面宣告 expression_desc 節選如下

and expression_desc =
  | Pexp_sequence of expression * expression
  ...

然後

begin end (* 被解析成 (():unit) *)
begin expr end (* 被解析成 expr *)
begin expr1; expr2; end (* 被解析成 Pexp_sequence (expr1, expr2) *)
begin expr1; expr2; expr3; end (* 被解析成 Pexp_sequence (expr1, (Pexp_sequence (expr2,expr3))) *)

在通常的時候,以上做法不會造成任何問題,而且還簡化了 parsetree. 但是當別人要接收這樣的語法樹進行擴展或變換的時候,第二條解析就可能讓人無意間出錯了。 比如

let ()= begin%m expr1; expr2; end

會按照預期被轉換,但使用者無意間去掉 expr2 後

begin end 會被丟棄,結果就成了

let () = [%m expr]

於是 expr 就被非預期地包上了語法擴展標記。如果正好 expr 是個被擴展語法的運算式,那麼原本不擴展的運算式就被擴展了。讓人無意間就出錯了。 例子

begin%m
  let a= 2 in
  ();
end

實際被語法擴展 c 接收到的是

let%m a= 2 in
()

begin end, 是 (():unit) 的常數,或者是分隔符,此分隔符還能掛語法擴展標記點,它不是 sequence 產生運算子。只有 (;) 這個分號在它两边都存在表达式的情况下纔是 sequence 產生運算子。

復用太多,無意間就出問題了。(類似的例子可見 cpp, 被 borland 這個混蛋糟蹋後的 object pascal 等) (當年見過的醜八怪少,不過還是受不了 free pascal 官方,於是維護了一份 free pascal 自用,蛋疼的日子啊)

不過, OCaml 還算是目前我見過的所有醜八怪裏面相對來說不算太醜的一個,暫時先用着吧。

文件

OCaml 因爲做到了 interface(mli) implementation(ml) 分離。一般,說明都寫在 mli 裏。下載一份 OCaml 4.02 源碼,在 parsing 目錄下就有 compiler libs 的組成部分,直接看就好了。主要就 asttypes, parsetree 解析, ast_helper, ast_mapper 產生。都非常簡明,一看便知。