LunarMLの構文をイケイケにするために

Standard MLとLunarMLの関係について、前にこういう記事を書きました。

この時は割と互換性重視でしたが、しかし、クソリプおじさんからの批判に耐えるにはもう少し抜本的な改革が必要そうです。非互換性を厭わずに構文を変えるならどういう構文にしたら良いでしょうか。

(まあクソリプおじさんが前の記事を読んでいたかは定かではないのですが。人が百も承知なことに対して上から目線でご高説を垂れるからクソリプなのです。釈迦に説法という言葉もありますね。)

方針

前提として、既存のSML資産へのアクセスを可能としたいです。つまり、型システム・モジュールシステム・標準ライブラリーは大幅には変えません。

この記事で考えるのは、構文の変更です。意味論とかはいじらないようにしたいです。

ここではゼロベースではなく、Standard MLの構文に対する差分という形で考えます。本当はゼロベースの方が良い文法になるかもしれませんが、それだったら型システムやらモジュールシステムやらもいじりたいです。

言語拡張として導入できそうなもの

まず、既存の言語に対する拡張として、互換性を壊さない形で導入できそうなものを挙げます。

ハイフンマイナスを単項マイナスとして使えるようにする

クソリプおじさんが挙げていたのが、単項マイナスがチルダであるという点でした。これを変えたいです。

「Standard MLの単項マイナスがチルダである」というのにはいくつかの意味があります。

  • 標準ライブラリー:関数としての単項マイナスが ~ である。
  • 字句解析:負の数値リテラルを書くときに ~ を使う。
  • 標準ライブラリー:数値の toString が負の数に対して ~ を出力する。
  • 標準ライブラリー:数値の scan/fromString~ を単項マイナスとして解釈する。
    • これらはハイフンマイナスも単項マイナスとして解釈するので、そこまで困りません。

ここで考えたいのは最初の2つです。最初のやつはライブラリーの問題かというとそうでもなくて、ハイフンマイナス - は既に二項演算子の引き算として定義されているのと、infix statusがついているので、コンパイラーによる特別扱いが必要です。つまり、infix statusのついた - が前置演算子として使われたら ~ とみなすのです。

2番目ですが、単に - の直後に数字が並んでいた時にマイナスをリテラルの一部とみなすと、 x-1 みたいな式の解釈が変わってしまいます。なので、リテラルに対しての - は安易にリテラルの一部と見做さない方が良いかもしれません。

OCamlやHaskellなどの類似の言語がどうしているか調査しておきたいです。

型構築の順番

これは前の記事にも書きました。

Standard MLやOCamlは型構築の順番が、int list という風にコンストラクターが後置です。これは初見では違和感が大きいので変えたいです。

OCamlのrevised syntaxでは順番が list int みたいになりました。F#では list<int> という風に山括弧で書けるようです。ReScriptも山括弧のようです。

OCaml revised syntaxのように順番を変えるのは、既存のコードとの互換性に難があるので避けたいです。山括弧は字句解析が厄介になるので避けたいです。消去法で残るのは、丸括弧 () と角括弧 [] と波括弧 {} です。

この中だと角括弧がScalaやPythonで見慣れていて良さそうです。Eiffelが初出らしいとどこかで見ました。

識別子の簡便な中置化

これも前の記事に書きました。ドットで両側を挟むことによって識別子を中置化します。

これはLunarML v0.1.0に実装済みで、annotationを指定することによって有効化できます。

優先順位は、名前の最後の部分(structure名ではなく、valueの名前)に対するinfix宣言で決めることにしました。つまり、

infix 7 .quot. .rem.

という宣言があれば .Int.quot. のfixityも .Foo.Bar.quot. のfixityも infix 7 となります。

アロー関数

JavaScriptみたいに => だけで無名関数を書けるとクールです。

fn x => fn y => ...x => y => ... と書けるようにする感じです。

=> の左側はatomic patternのみが来るようにします。

デメリットは、パーサーが厄介になることです。

wordリテラルを0wから始めなくても良いようにする

Standard MLのリテラルはどうせオーバーロードされているので、本来 int 系の型にしかなれない 42 みたいなリテラルに word 系の型を許容しても良さそうです。

中置演算子を括弧で囲うことで識別子として使えるようにする

op + と書いているのを (+) と書けるようにしよう、みたいなやつです。OCamlとかHaskellのやつですね。

注意点としてStandard MLでは (* がコメントの開始を表すので、 (*) と書いても op * の意味にはなりません。OCamlにも同様の罠があります。

非互換な変更の案

ここからは、非互換な変更を考えます。「非互換」というのは、具体的には予約語を増やすようなことです。これらを使う際の拡張子は .sml から変えることになるでしょう。

try handle

Standard MLの例外捕捉式は <exp> handle <match> の形で、try のようなキーワードは前に来ません。でも前にもキーワードがあった方が読みやすいと思います。前に来るキーワードはやっぱり try が適切でしょう。

ちなみにOCamlは try ... with ... のようです。

andalso, orelseの代わりに&&, ||

論理積や論理和について、他の言語で &&and と書くところを、Standard MLは andalso と書く必要があります。2〜3倍も長いです。イケてません。and は既に別の用途で使っているので、&& にしましょう。

すごく細かい話をすると、Standard MLの andalso/orelse は他の中置演算子とは構文上の規則が異なります。&&/||andalso/orelse と同様にパースするのか、それとも他の中置演算子と同様にパースするのかは、考える必要があります。

追加の予約語

構文を改造するにあたって、Standard MLで許容されているいくつかの識別子を予約したいです。候補は try, begin, lazy, &&, || などです。

予約語を識別子として使えるようにする

Standard MLで書かれた資産が追加の予約語を使っていた場合にLunarML独自構文で書かれたコードからアクセスできないのは困ります。

まず、long identifierの一部であれば、予約語が出現しても構わないようにしましょう。Foo.trytry は予約語として扱わないようにするのです。

次に、バッククォートで囲うことで予約語を識別子として使えるようにするのはどうでしょうか。Swiftみたいなやつです。

Standard MLではバッククォートも識別子の一部になれるので、それも利用できるメカニズムも必要です。前と後ろのバッククォートが取り除かれるようにして、単独のバッククォートは ``` という風に、間にバッククォートを含む識別子は `@`@`@`@ を表すという風に書くのはどうでしょうか。

まあ、バッククォートは他の用途で使いたくなるかもしれませんが。複数行文字列リテラルとか。

文字リテラルを 'a' と書けるようにする

Standard MLで 'a' と書くと、シングルクォートから始まるのでこれは型変数と解釈されます。文字リテラルは #"a" と書きます。

ですが、文字リテラルは 'a' と書きたいですよね。1文字短いし。OCamlでは字句解析の規則をゴチャゴチャさせて文字リテラルを 'a' と書けるようにしているみたいです。

デメリットは、字句解析がゴチャゴチャすることです。文字リテラルの中にはエスケープシーケンスも来る可能性があります。面倒ですね。

関数の型宣言

現在のLunarMLには、拡張機能(手動で有効化が必要)として (*: ... *) という形の特殊なコメントで関数の型を確認できる機能があります。

(*: val fact : int -> int *)
fun fact 0 = 1
  | fact n = n * fact (n - 1);

と書くとコンパイラーが fact 関数の型を検査してくれるのです。あくまでコメントという体裁なので、型推論には影響しません。

しかし、どうせ非互換な変更をバンバン入れるなら、コメントという体裁じゃなくて本物の宣言の一部にしたいですよね。Haskellみたいに。

この際の文法はどうするのがいいでしょうか。単にコメントのマーカーを外して

val fact : int -> int
fun fact 0 = 1
  | fact n = n * fact (n - 1)

と書けるようにするのか、あるいは where などのキーワードを使って

val fact : int -> int where
fun fact 0 = 1
  | fact n = n * fact (n - 1)
end

とするのか、あるいは val type みたいな複合キーワードを使って

val type fact : int -> int
fun fact 0 = 1
  | fact n = n * fact (n - 1);

とするのか。考え中です。

逐次実行

Standard MLで逐次実行をするには (exp1; exp2; ...; expN) という風に丸括弧とセミコロンを使います。ですが、他の言語ではあまり丸括弧で逐次実行するのは見かけません。

なので、波括弧で逐次実行できるようにするか、あるいは begin ... end みたいな感じで逐次実行できるようにするかしたいです。波括弧はレコードで使うので、追加のキーワードはいずれにせよ必要でしょう。どうしても波括弧を使うなら先頭にキーワードを置いて do { exp1; exp2; ...; expN } とかですかね。Haskellか。

【2024年1月6日 追記】do はSuccessor MLのdo宣言で使うので、やっぱり begin がキーワードとして適切でしょうか。それから、逐次実行の途中に val などの宣言も入れられると良いかもしれません。つまり、begin exp1; val a = exp2; exp3 end みたいな書き方をできるようにするのです。let in end(;) の統合です。最後の式以外は unit 型がつくように要求するべきかもしれません。【追記終わり】

let val

他の言語では let だけで変数の導入ができたりしますが、SMLでは let val x = ... in ... end と書く必要があり、手間です。もっと短い記法があれば良いのですが、SMLでは let の前半は任意の宣言を書ける(datatypeexceptionopen など)ので、安易に val の省略を可能にするのは良いアイディアとは思えません。

この件に関しては正直妙案はないです。

インデントベースの文法?

HaskellやF#はオフサイドルールを活用しています。パーサーが大変になりそうですが、検討の余地はあるかもしれません。(検討してない)

数値リテラルの直後にアルファベットが来ないようにする

123foo みたいなコードを 123 foo と解釈するのではなく、エラーにしたいです。現行のLunarMLでは警告を出すようにしています。

これは、色々拡張する上で非互換性を気にしなくて良くなるというメリットがあります。例えば、1.23e-10 を1つの数値トークンとして解釈できるようになります。

時期尚早か

というわけで、色々考えています。しかし、前の記事にも書きましたが、今の段階ではこれです:

色々妄想しましたが、LunarMLの優先順位としてはまずはStandard MLのコンパイラーとしての基本的な機能を実装するのが第一です。基礎がしっかりした処理系を作ってこそ、言語拡張が活きるというものでしょう。

それから、ユーザーの視点も大切です。有益な拡張機能を見極めるには実際のアプリケーションを書くことが重要でしょう。

なので、LunarMLに搭載する拡張機能を正式に決めるのはもうちょっと先のことになります。

Standard MLに対する拡張のアイディア

直前の記事にも

ただ、LunarMLが成熟するまでは拡張機能を前面に出していくのは控えた方が良いかもしれません。具体的には、独自路線を突き進むならばREPLや独自のネイティブコンパイラーは欲しいところです。

LunarMLの進捗2023と、今後の方針

と書きました。

クソリプじゃない意見は随時募集しています。具体的には、Standard ML/LunarMLへのリスペクトを感じられる意見を募集しています。まあ、Standard MLを実際に使っておられる方の意見であれば多少言語へのリスペクトを欠いていても構わないかもしれません(自虐はいいけど余所者にディスられるのは怒る、みたいな話)。