拡張の必要性
私は現在、Standard ML処理系であるLunarMLを開発しています。しかし、Standard MLはほぼ進化の止まった言語です。一応Successor MLという取り組みがありますが、準拠を目指している処理系は多くはありません。
LunarMLが成功するためには、言語標準という枠に囚われずに「モダン」な機能を積極的に取り入れていくことが重要だと考えられます。つまり、拡張機能です。
Standard MLに足りない機能は何でしょうか。LunarMLにどんな拡張を入れれば、使いやすい言語になるでしょうか。
過去の記事ではすでにいくつか機能を挙げ、いくつかは実際に実装しました:
- 自作SML処理系進捗:Hello Lua!
- 十六進浮動小数点数リテラル…実装済み
- Unicodeエスケープシーケンス…実装済み
- 条件コンパイル
- LunarML進捗:signatureの実装に向けて
- 第一級モジュール
- LunarMLと継続
- 限定継続…実装済み
実装済みの拡張機能については以下に説明を書いています:
ここではもうちょっと色々アイディアを出してみます。
目次
方針
Standard MLとの互換性を壊す変更や、言語の在り方を変えてしまう拡張はなるべく考えないことにします。あくまで「拡張」です。例えば、単項マイナスを -
にすると互換性が壊れてしまいます。別の例として、型クラスを大々的に導入すると言語の在り方が変わってしまうでしょう(型クラスは便利で強力なので、うまい落とし所が見つかれば採用するかもしれませんが)。
また、LunarMLはスクリプト言語にコンパイルするという特性上、ランタイムを肥大化させたくないので、ランタイム側でのサポートが重くなりそうな機能は避けたいです。例えば十進浮動小数点数型はあると教育的に良いですが、ランタイムが重くなりそうなので実装は難しいです(ネイティブコンパイルする処理系だったら頑張って実装したかもしれません)。
なお、LunarMLはSuccessor MLの便利な機能を取り込みたいと考えています。ここにはSuccessor MLに取り込み済みの機能は書きません。
拡張案
Unicode識別子
プログラムの変数名や関数名に母語を使えるのは教育上重要だと考えられます。母語でなくても、数学のプログラムであればギリシャ文字が使えると便利そうです。なので、識別子にASCII外のUnicode文字を使えるようにしたいです。
Unicodeには識別子やハッシュタグに使える文字集合についての色々をまとめた文書があります:
これを参考にしてUnicode識別子を使えるようにすると良さそうです。
「ASCII外は全部許可」以外の選択肢を取るのであれば、コンパイラーにUnicodeの文字情報のデータを埋め込む必要があります。
ただ、Unicode正規化を実装するのは面倒そうです。コンパイラーとしてはUnicodeスカラー値の列として比較を行い、Unicode正規化で同一になるような紛らわしい識別子はlinter等の外部ツールに警告させるのが良いかもしれません。
複数行文字列リテラル
主にスクリプト言語かもしれませんが、複数行書ける文字列リテラルを持つ言語は多いです。JavaScriptの `...`
とか、Pythonの """ ... """
とか、Luaの [[...]]
とか。スクリプト言語じゃなくても最近の言語は複数行書けるものが多い気がします。
Standard MLやHaskellにも複数行にまたがる文字列リテラルを書く方法はあります。具体的には、バックスラッシュの直後に空白文字や改行文字を続けるとその次のバックスラッシュまでがスキップされるという機能(ギャップ)を使います。例えば "foo\ \bar"
は "foobar"
と等価です。これを使うと
"foo\n\ \bar\n\ \baz" = "foo\nbar\nbaz"
という風に複数行にまたがる文字列リテラルを書けるのです。
ですが、 \n\
とか書くのは面倒です。Haskell界隈にもそういう問題意識を持った人はいるようで、「"""
形式の文字列表記を導入しよう」という提案が最近出ています:
議論が長そうなので私はまだちゃんとは読んでいません(文字通りのtoo long; didn’t read状態です)が、良さそうだったら真似したいです。
識別子の簡便な中置化
Standard MLは任意の識別子を中置演算子化できます。アルファベットからなる
div mod before o
などはデフォルトでinfix化されているので演算子っぽく使えるのです(逆に、記号からなる名前であってもデフォルトでは中置にはなりません)。
ですが、この機能には(利用者から見て)2つの問題があります:
- ぱっと見でどれが中置演算子なのかわからない。
- プログラムの上の方を見て
infix
宣言を探す必要がある。
- プログラムの上の方を見て
- モジュール機能と相性が悪い。モジュール名がついた識別子(
Int.quot
みたいなやつ)を中置にできない。
一方、Haskellではアルファベットからなる識別子を中置にするにはあらかじめ宣言を行うのではなく、使用する際にバッククォートで囲います:a `quot` b
Haskellのように「その場で」識別子の中置化が行える機構をSMLに追加できないでしょうか。
実は、SMLでもライブラリーレベルでの解決方法があります:
ですが、準備が必要なのと、Haskellに比べて文字数が(2文字)多いのがネックです。
この記事はコンパイラー作者の気持ちになって言語拡張を考える場所です。ライブラリーではなくコンパイラーで対応するならばどうするのがいいでしょうか。
識別子の前後に特別な記号を置くことによって中置化するのが良さそうですが、Standard MLでは多くの記号が「識別子として使える文字」に指定されているので、選択肢は多くはありません。特に、Haskellと同じバッククォートは使えません。
ということで、ドットを識別子の前後に置く方法が消去法で残ります。Standard MLではドットは識別子の一部にはなれないのです。ドットであれば片側に置くのでも十分ですが、Haskellの同等機能との類似性を考えて両側に置くのが良いでしょう。
コード例は次の通りです:
val andb = Word.andb fun foo x = x .andb. 0wxFF fun bar y = y .Word.>>. 0w4
ところで、アルファベットからなる識別子の両側にドットを置くのはFortranで見かけます(私はFortranは書きませんが古のIEEE 754を読んでいる時に見かけました)。Fortranのやつは任意の識別子ではなくてどちらかというと使える記号が足りないのを補う用途な気もしますが(中置とは限らず、.NOT.
というのもある)。
記号列をドットで挟むのはHaskellのビット演算子 .&.
, .|.
っぽくもありますね。最近xorとビットシフトのやつも増えました:.^.
, .>>.
, .<<.
別の例として、Racketには (a . b . c)
が (b a c)
と解釈される機能があるらしいです:
そういえばRacketに (1 . + . 2)が(+ 1 2)として読まれるみたいな記法がある
— でこれき (@dico_leque) February 12, 2023
1.3 The Readerhttps://t.co/Z9aYPUSquS
ということで、消去法で選んだ「ドットで挟む」という記法には意外と先例があって受け入れられやすいのではという気がしてきました。あとは「ライブラリー機能による実現」の持つ「可搬性」というメリットとの勝負ですね。
演算子とfn
SMLでモナドみたいな >>=
演算子を作ると、Haskellのようには行かないということがわかります。具体的には、右側の関数式を括弧で囲う必要があるのです: foo >>= (fn y => ...)
これ不便ですよね。この制限は緩和した方が便利なんじゃないかという気がします。文法定義がどうなるかは知らん。
型構築の順番
Standard MLでは int
からなるリストの型は int list
という風に、型引数が先に来て型コンストラクターは後に来ます。OCamlでも同様です。
ですが、この順番は他の言語に慣れた人から見ると不自然です。
そのためか、OCamlのrevised syntaxでは順番を変えて(ついでにカリー化もして?)います:
LunarMLではrevised syntaxは作りませんが、より「自然な」順番で書ける選択肢があってもいいかなと思います。型の文法には []
は使われないので、これを活用して
list[int] (* int list と等価 *) ref[option[string]] (* string option ref と等価 *) Map.map[char] (* char Map.map と等価 *) StringCvt.reader[int, 'a] (* (int, 'a) StringCvt.reader と等価 *)
という記法を導入するのはどうでしょうか。型引数に []
を使うのはScalaやPythonでお馴染みですよね。
ただ、このような冗長な機能に []
を使ってしまうと他の拡張機能に []
を使いたくなった時に困るかもしれません。実際、SML#は多相型の表記に []
を使っています(ソースコードには書けないみたいですが)。
それに、こういう拡張を入れてしまうと「Standard MLらしさ」というものが失われてしまうかもしれません。人が感じる「言語の在り方」を変えてしまう可能性を懸念しています。たかがderived formですが、見た目のインパクトは重大だと思います。
ちなみに、個人的にはSML/OCamlの型構築の順番は慣れの問題かと思っています。また、「関数が先・引数は後」な記法に由来して関数合成の順番が直感に反する結果になったり、引数が前・関数は後に書くパイプライン演算子が持て囃されたりするのを見ると、「関数が前・引数は後」が絶対的な正義ではないという気もします。式の複雑さに依存する話かもしれませんが……。
レコード周り:レコード多相、レコード演算
Standard MLでは val getFoo = #foo
みたいな定義は「foo
というフィールドを持つ任意のレコード型」には一般化されず、少なくとも「フィールドの全体」は集合として固定されます。レコード多相があると、こういう定義を「foo
というフィールドを持つ任意のレコード型」に一般化できます。
Standard ML系の言語実装の中では、SML#がレコード多相を実装しています。
SML#と異なりLunarMLにはレコード拡張 (record extension) があるので、LunarMLに実装する場合はその兼ね合いをどうするかという問題があります。
レコードの連結 (record concatenation) もあると便利かもしれません。例えばHTMLを構築するライブラリーで属性値を
fun a attrs body = let val default = {href = NONE, id = NONE} (* デフォルト値 *) val attrs = default ## attrs (* レコード連結(文法は架空) *) val {href, id} = attrs val attrsList = [Option.map (fn x => " href=\"" ^ escape x) href, Option.map (fn x => " id=\"" ^ escape x) id] val attrsStr = String.concat (List.foldr (fn (NONE, acc) => acc | (SOME a, acc) => a :: acc) [] attrsList) in "<a" ^ attrsStr ^ ">" ^ body ^ "</a>" end val link = a {href = SOME "https://blog.miz-ar.info"} "ブログ" val anchor = a {id = SOME "foo"} "アンカー"
という風に指定できるとクールです。
ただ、フィールドの上書きを許すレコード連結は型推論が難しいみたいな話もあるので、設計を熟慮する必要がありそうです。ここに載せた利用例だとむしろ「フィールドの上書きのみを許すレコード連結」が欲しいのかもしれません。
GADTs, 存在型, ランクn多相
HaskellやOCamlにはGADTsやその他多相に関する拡張があります。LunarMLにもあると便利でしょうか。
2018年の ML Day#2 – connpass でLunarML(当時はまだこの名前ではありませんでしたが)について話した際に「いずれGADTsが欲しくなると思います」というコメントを頂いたのを覚えています。
第一級モジュール
OCamlやAlice MLやHaMLet Sは第一級モジュールを提供しています。
ただ、実行時に作ったstructureについてfunctorを呼び出せるようにすると、「functorを全部展開する」(static interpretation)というコンパイル戦略を採用できなくなります。「引数が静的にわかっているfunctorを全部展開する」になるだけかもしれませんが。
JSONシリアライズが楽になる何か
現代の標準的なデータ交換形式はJSONです。よってモダンな言語はJSONをいい感じに扱える必要があります(一昔前はXMLのための言語拡張が策定されたこともありました。E4Xってやつです)。
JSONを扱うためにはいくつか方針が考えられますが、個人的には既存のデータ型の定義を基にencoder / decoderを(半)自動生成できるのが良いと考えています。
また、データ交換形式にはJSONの他にもYAML / TOML / CBORなどがあり、それぞれ微妙に用意されたデータ型が異なります。参考:
LunarMLが埋め込み先言語とのやり取りで扱う「JavaScriptの値」や「Luaの値」もそういう「データ交換形式」の一種と思えるかもしれません。
もっと普段使いする形式だと、文字列だってデータ交換形式の一種です。
なので、JSONに限定せず、幅広いデータ交換形式についてencoder / decoderを生成できるシステムがあると良さそうです。
Haskell界隈で言うジェネリックプログラミングっぽいことができると良いのでしょうか。
データ交換形式とのやり取りについては、言語拡張でやる方針だけでなく、外部ツールによるコード生成でどうにかする方針もあります。
ちなみにJSONに関しては、SML#に「動的型付け機構」なるものがあります:
ライブラリーとしてはsmlnj-libにJSONを表すデータ型があります:
データ型とAPI互換性
ライブラリーでデータ型を公開しているとします。あまり良くないことですが、このデータ型の定義を変えたくなったら利用者側にはどんな影響が及ぶでしょうか。
まず、抽象データ型であれば(操作する関数を変えない限り)そんなに影響はなさそうです。一方、エイリアスとして定義された型や datatype
は定義を変えると非互換性が生じます。
変更の仕方によっては非互換性を避けられないこともありますが、ある種の非互換性は利用側で気をつければ回避できます。例えば、「datatypeのvariantを増やす」のは網羅的なパターンマッチを避ければ互換性を維持できますし、「レコードのフィールドを増やす」のはレコード値の構築を避ければ互換性を維持できます。
そういう「variantが増えるかもしれないdatatype」や「フィールドが増えるかもしれないレコード型」をライブラリー側でマークできると、コンパイラーがパターンマッチの網羅性判定を変えたり、レコードの構築を禁止したりできそうです。
例えば定義する側で次のように書くと、
structure S :> sig datatype t = FOO | BAR | ... (* ドット3文字 *) type u = { x : int , y : char , ... (* ドット3文字 *) } end
S.t
は FOO
, BAR
以外のvariantを持つかもしれないし、 u
は x
, y
以外のフィールドを持つかもしれない、という意味とします。S
を定義する側はこれらのデータ型の完全な定義を知っていて、パターンマッチとかレコードの構築を行えます。
なお、SMLのレコード型は構造的なので、
type u = { x : int , y : char , ... } type v = { x : int , y : char , ... }
というインターフェースがあった時に u
と v
は同じ型かという問題があります。なので、
type u_rest : { ~x, ~y } (* xというフィールドとyというフィールドのいずれも持たないような何らかの型を表す(文法は仮) *) type u = { x : int , y : char , ... : u_rest (* レコード拡張の文法 *) }
という風に ...
の部分に名前をつける必要がありそうです。
先行事例として、Rustには同様の目的の #[non_exhaustive]
という属性があるようです:
フォーマット文字列
フォーマット文字列にいい感じの型がつくと良いかもしれません。OCamlがやっているアレです。
ただ、フォーマット文字列のために型検査器をいじるのはあまりにもアドホックすぎる気もします。
String interpolation
あると良いかもしれません。
Successor MLのissueになっています:
文法はSwiftの "\(foo)"
という形式が特殊文字を増やさなくて良さそうですが、他の言語ではあまり見ないんですよね。
Pythonのf文字列やSwiftのやつはフォーマットも一緒に指定できます。導入するとしたらこれらの言語を真似てフォーマットも一緒に指定できるようにするのが良いでしょう。
正規表現
正規表現はライブラリーとして実装されることが多いです。SMLにも実装や案があります:
ですが、この記事は言語機能を考える記事なので、言語機能と統合された正規表現を考えたいです。パターンマッチと統合させたりとか。と言っても具体的な案はまだないのですが……。
そういえば私が昔Qiitaに書いた記事にこんなのがありました:
仮に正規表現リテラルを書けるようにする場合、いい感じの型がつくようにすると良さそうです。
モダンなところで言うと、Swift Regexも見てみると良さそうです。
なお、LunarML特有の事情として、複数のターゲットに対応したいというのがあります。ターゲット言語が正規表現を持っていても、ターゲットごとに文法に互換性がなかったり、対象とする文字列型が違ったり(バイト文字列 vs UTF-16 vs UTF-32)します。なので、提供するとしても基本は自前で実装して、コンパイル先の言語が持っている正規表現を活用できる場合のみ活用する、という形になりそうです。
SQL連携
最近ギョームでSQLを書いていたらSQLをいい感じに扱う言語機能があると良いのではと思うようになりました(ちょろい)。
SQLの統合に関しては、SML#が実装しています:
埋め込み先言語の型システムとの連携
LunarMLはLuaやJavaScriptなど、既存の言語にコンパイルする処理系です。将来、Javaや.NETなどをターゲットにする日が来るかもしれません。
そうすると、埋め込み先の言語の型システムといい感じに連携する必要が出てきます。今は「Luaの値」を Lua.value
みたいな型で扱うようにしています(型情報を「潰して」いる)が、もっといいやり方が欲しいです。
LuaやJavaScriptは型なしですが、型をつけるとしたらユニオン型や交差型などを含む部分型が必要になるでしょう。Standard MLには部分型はないので、連携させるとしたら型システムに対する変更が必要になります。
部分型が困難なのは型推論との兼ね合いです。SimpleSubの論文をチラッと見てみましたが、表現力がいまいちでした。部分型を入れるなら型推論をある程度犠牲にするのはやむを得ないと思われます。
ML界隈で部分型のある既存のシステムと統合しようとしているのは、JavaをターゲットにするMLj、.NETをターゲットにするSML.NET、そしてF#です。この辺を参考にすると良さそうです。
LunarMLはどこへ向かうべきか
色々妄想しましたが、LunarMLの優先順位としてはまずはStandard MLのコンパイラーとしての基本的な機能を実装するのが第一です。基礎がしっかりした処理系を作ってこそ、言語拡張が活きるというものでしょう。
それから、ユーザーの視点も大切です。有益な拡張機能を見極めるには実際のアプリケーションを書くことが重要でしょう。
なので、LunarMLに搭載する拡張機能を正式に決めるのはもうちょっと先のことになります。
将来、LunarMLが広く使ってもらえる段階まで辿り着いた時に、評判がよく実装コストも低い拡張機能があったら、Successor MLに提案してみるのも良いかもしれません。
ピンバック: LunarML進捗・2023年3月 | 雑記帳