久しぶりにLunarMLの話題です。LunarMLは、私が作っているStandard MLコンパイラーです。
最近は、JavaScriptとの連携に関する機能を追加しています。Webで使えるようにするのと、Promise/async/await周りの統合をいい感じにすることを目標にしています。
Web向けのコード出力とWeb API
従来のLunarMLはNode.js向けのJavaScriptコードを出力できましたが、Web向けのコードを出せるようにするのは難しいことではありません。単に標準ライブラリーを差し替えて、Node.js特有の機能を使わないようにするだけです。その過程で print
などの基本的な機能も使えなくなりますが……(Webには標準出力がないので、print
した文字列は虚無に消える。console.log
は毎回改行するので使いづらい)。
問題はライブラリーの方で、Web向けのコードを出すからには、Web API(ブラウザーが提供してJavaScriptから利用できるAPI)と連携したいです。window
, document
などのオブジェクトを触ったりしたいです。Web APIにどのような型をつけるかが問題です。
Standard MLはオブジェクト指向ではなく、部分型付けはありません。プロパティーのような概念もありません。Web APIには例えば Node
というインターフェースがあって多くのオブジェクトがそれを実装しているわけですが、Standard MLの型でそれをどうやって表現するかが問題です。
この辺はSMLToJsや、Js_of_ocamlを参考にすると良いのかもしれません。必要ならLunarMLの型システムを拡張することも選択肢に入りますが、それは最後の手段にしたいです。
根本的な問題として、単に window
や document
へのバインディングを用意するだけでは関数型という特性を活かせないのではないかという問題があります。関数型言語でWebフロントエンドを書くならば、React等の宣言的UIフレームワークに乗っかるべきではないでしょうか。あるいはThe Elm Architectureがいいのか。やるとしてもLunarML付属ライブラリーではなく、パッケージのような形で提供することになると思いますが。
まあしかし、TypeScriptがWebフロントエンド界の覇者となって久しく、私の限られた時間を投じてLunarMLでそれに挑戦することにどれほどの意味があるのだろうと思います。UIはTypeScriptに任せて、フロントエンドの一部の処理をLunarMLで書く、というような棲み分けが賢いのかもしれません。その場合、TypeScriptからLunarMLの資産に(できれば型付きで)アクセスする仕組みを整える必要がありますが。
LunarMLをWeb向けに使う場合、既存のバンドラー(webpackやvite)とどう統合するかという問題もあります。
Promiseの連携
最近のWeb APIには Promise
を返すものがちょいちょいあります。fetch
が代表例です。WebAssemblyやWebGPUを Promise
を利用します。なので、LunarMLでも Promise
をいい感じに扱えると良さそうです。
昔のJavaScriptでは、メソッドチェーンで .then
を繋げて Promise
を処理していました。LunarMLでも、パイプライン演算子を使えば似たような書き心地が実現できそうです。具体的には、以下のようなコードになります:
fetch (someUrl, options)
|> Promise.andThen (fn x => ...)
|> Promise.andThen (fn y => ...)
(* then は Standard ML のキーワードなので代わりに andThen を使う *)
(* Promise.andThen の型は ('a -> 'b promise) -> 'a promise -> 'b promise *)
さて、今のJavaScriptにはasync/await構文があります。async/awaitはそういう構文で、コンパイラーが頑張ってasync/awaitの入ったプログラムを変換します。しかし、LunarMLには限定継続があり、それを使うと文法を拡張しなくてもライブラリーレベルでasync/awaitを実現できます。具体的には、
val async : ('a -> 'b) -> 'a -> 'b Promise.promise
val await : ('a -> 'b Promise.promise) -> 'a -> 'b
という関数をライブラリーで実装できます。これを使うと
val x = await (fetch (someUrl, options))
という風に書けるようになります。awaitが後置じゃないと許せない人は
val x = fetch (someUrl, options) |> await
と書けます。
JavaScriptの Promise
の then
はコールバックが Promise
(正確にはThenable)を返したら更にそれを待ち受ける挙動をします。モナドのbindみたいな感じです。この仕様があると ('a promise) promise
みたいな型をいい感じに扱えないので、Standard MLの世界のコードが Promise
の中身として Promise
を返そうとした場合はラッパーを噛ませるようにします。
関数の連携をどうするか
現状のLunarMLでは 'a -> 'b
という関数と、TypeScriptで言う (_: a) => b
という関数は等価ではありません。LunarMLは末尾再帰を処理するためのトランポリンだとか、(JS-CPSバックエンドの場合は)CPS変換だとかを噛ませているので、同じように見える関数の型でも意味が違うのです。
したがって、JavaScriptの世界から呼び出すためのJavaScriptの関数をStandard MLの世界で作るには、単に fn x => ...
と書くのではダメで、JavaScript.function : (value vector -> value) -> value
という関数を使う必要があります。JavaScriptの関数の引数は可変長になれるので、引数を value vector
で表現しています。
しかし、引数が value vector
だと引数にアクセスするのにいちいち Vector.sub (args, 0)
と書く必要があってだるいです。function2 : (value * value -> value) -> value
のような便利関数を用意すれば引数へのアクセスの問題はマシになりますが、型が value
なのでキャストが必要になります。型を function2 : ('a * 'b -> 'c) -> value
という風にすればキャストは不要になりますが、キャストはunsafeなのでそういうのを便利関数として提供するのはよろしくなさそうです。
型クラスのようなものがあれば、
val function2 : (Marshal 'a, Marshal 'b, Marshal 'result) => ('a * 'b -> 'result) -> value
という風に「引数や返り値の型がJavaScriptでもいい感じになっている」ことを表現できそうですが、Standard MLには型クラスはありません。うまい設計を思いつければ型クラスを導入するのはアリなのですが、Standard MLと調和するうまい設計を思いつくかが問題です。
真面目な話、Standard MLでやるなら、型クラスの辞書渡しのノリで
val int : int rep
val string : string rep
val function2 : ('a rep * 'b rep * 'result rep) -> ('a * 'b -> 'result) -> value
という風にするのが良いのかもしれません。
そんな感じで、LunarMLの開発をちょっとずつ進めています。面白そうだと思った方はGitHubにスターをいただけると嬉しいです: