良いコンパイラーは親切なエラーメッセージを出さなければならない:LunarMLの場合

プログラミング言語の静的解析の良い点として、一回の解析で複数の誤りを検出できる(場合がある)点があります。実行時エラーしか出ない言語だったら、一回の実行で一個のエラーに遭遇して、修正して、またエラーに遭遇して、ということを繰り返さなければなりません。

言い換えると、遭遇した最初の誤りでコンパイルが停止してしまうようなコンパイラーは、いくら静的な型システムを備えていても、動的言語並みの貧弱な開発体験しか得られないということです。コンパイルの手間がかかる分むしろマイナスです。

コンパイラーではなく言語サーバーでも同じことで、ソースコードの最初の誤りで解析が停止してしまう言語サーバーからは貧弱な開発体験しか得られないでしょう。

検出できるエラーの個数だけではなく、エラーメッセージの親切さも、結構開発体験を左右するのではないかと思います。よくある誤りに対して的確なエラーメッセージを出すことができれば、ユーザーがエラーの原因で悩む手間が省けます。

一つの言語仕様に対して複数のコンパイラーが存在する状況で、エラーメッセージの品質はコンパイラー間での差別化の要素になり得ます。この記事では、Standard MLコードに対して良いエラーメッセージを出す工夫について考えてみます。

Standard ML / LunarMLの場合

私が作っているStandard MLコンパイラー、LunarMLでも、親切なエラーメッセージを出すようにしたいです。現在リリース済みのバージョン0.2.1ではエラーメッセージにはそこまで力を入れていませんが、今後のリリースで徐々にエラーメッセージを改善していきたいです。

エラーメーセージの質は定量化が難しそうです。エラーメッセージの質を改善するには、実際にエラーに遭遇して、良し悪しを判定するのが良さそうです。なので、LunarML自身の開発で(エラーの確認のために)LunarMLを使ってみています。

以下では、私が思いついたエラーメッセージの改善案、改善のための工夫を挙げていきます。

ソースコードの範囲を示す

最近のコンパイラーは、エラーが含まれるコードを書き出して、どこが間違っているかを下線等で示してくれることがあります。LunarMLでもそういう機能を実装しています。他のコンパイラーと比較してみましょう。

$ lunarml compile test.sml
/private/tmp/mltest/test.sml:1:21-1:23: error: expected int, but got real
print (Int.toString 1.0);
                    ^~~
$ mlton test.sml
Error: test.sml 1.8-1.23.
  Function applied to incorrect argument.
    expects: [int]
    but got: [real]
    in: Int.toString 1.0
$ sml test.sml
Standard ML of New Jersey [Version 110.99.8; 64-bit; April 25, 2025]
[opening test.sml]
[autoloading]
[library $SMLNJ-BASIS/basis.cm is stable]
[library $SMLNJ-BASIS/(basis.cm):basis-common.cm is stable]
[autoloading done]
test.sml:1.9-1.25 Error: operator and operand do not agree [tycon mismatch]
  operator domain: int
  operand:         real
  in expression:
    Int.toString 1.0
$ smlsharp test.sml
test.sml:1.7(7)-1.22(22) Error:
  (type inference 016) operator and operand don't agree
  operator domain: int
          operand: 'BSE::{real, real32}
$ polyc test.sml
test.sml:1: error: Type error in function application.
   Function: Int.toString : int -> string
   Argument: 1.0 : real
   Reason:
      Can't unify real (*In Basis*) with int (*In Basis*)
         (Different type constructors)
Found near print (Int.toString 1.0)
Exception- Fail "Static Errors" raised

パターン中の構築子が不正な場合

プログラムのリファクタリングをやっていると、データ構築子の名前を変えることがあります。そうすると、古い名前をパターンとして使うとエラーになります。例えば、以下のようなコードです:

datatype t = T of int;
val x = T 42;
val _ = case x of U y => y + 2; (* エラー *)

まあ、それはいいんですが、SML/NJやMLtonは、間違ったパターン中で定義された変数(上記のコードなら y)の使用箇所で「y という変数が定義されていない」というエラーを出します。これは鬱陶しいです。

$ mlton test.sml
Error: test.sml 3.19-3.19.
  Undefined constructor: U.
Error: test.sml 3.26-3.26.
  Undefined variable: y.
$ sml test.sml   
Standard ML of New Jersey [Version 110.99.8; 64-bit; April 25, 2025]
[opening test.sml]
datatype t = T of int
val x = T 42 : t
test.sml:3.19-3.22 Error: non-constructor applied to argument in pattern: U
test.sml:3.26 Error: unbound variable or constructor: y

LunarMLでは、パターンの構築子が間違っていても、内部の変数が定義されるものとしました。SML#やPoly/MLもそういう挙動のようです。

$ lunarml compile test.sml
/private/tmp/mltest/test.sml:3:19-3:21: error: invalid pattern: constructor 'U' not found
val _ = case x of U y => y + 2;
                  ^~~
$ smlsharp test.sml
test.sml:3.18(55)-3.18(55) Error:
  (name evaluation "040") constructor expected in a constructor pattern:
  U
$ polyc test.sml
test.sml:3: error: Constructor (U) has not been declared Found near U y
Exception- Fail "Static Errors" raised

この改修は未リリースです(0.3.0に入る予定)。LunarMLの現行のバージョン(0.2.1)では、パターンの構築子が間違っていたらそこでコンパイルが終了してしまうので、この記事の冒頭に書いた悪い例となっています。

カッコが足りない場合

Standard MLでは、二項演算子の右側に if, case, fn, raise, while などから始まる式を持ってくることはできません(andalsoorelse は通常の二項演算子とはちょっと違うので大丈夫です)。つまり、以下のコードには2箇所の構文エラーが含まれます:

infix >>=
fun f >>= g = fn x => fn a => g (f a) a;
fun m a = a + 1;
val _ = m >>= fn x => fn a => x * a; (* >>= の右辺がカッコに囲われていない *)
val (a, b, c) = (true, 2, "c");
val _ = 1 + if a then b else c; (* + の右辺がカッコに囲われていない *)

まあ、気をつければいい話なんですが、うっかり書き忘れることもあります。SMLコンパイラーがそういうコードに遭遇した場合、構文エラーで止まってしまって型検査等が行われなくなるか、無理に構文エラーを回復しようとして的外れなエラーが出ます。試してみましょう:

$ mlton test.sml
Error: test.sml 4.15-4.15.
  Syntax error: inserting  SEMICOLON.
Error: test.sml 6.13-6.13.
  Syntax error: inserting  SEMICOLON.
Error: test.sml 4.11-4.13.
  Expression ends with infix identifier: >>=.
    in: m >>=
Error: test.sml 4.9-4.13.
  Function applied to incorrect argument.
    expects: [int]
    but got: [(??? -> ???) * (??? -> ??? -> ???) -> ??? -> ??? -> ???]
    in: m >>=
Error: test.sml 6.11-6.11.
  Expression ends with infix identifier: +.
    in: 1 +
Error: test.sml 6.9-6.9.
  Function not of arrow type.
    function: [int]
    in: 1 +
Error: test.sml 6.13-6.30.
  Then and else branches disagree.
    then: [int]
    else: [string]
    in: if a then b else c
$ sml test.sml
Standard ML of New Jersey [Version 110.99.8; 64-bit; April 25, 2025]
[opening test.sml]
infix >>=
val >>= = fn : ('a -> 'b) * ('b -> 'a -> 'c) -> 'd -> 'a -> 'c
val m = fn : int -> int
test.sml:4.15 Error: syntax error: inserting  ORELSE

uncaught exception Compile [Compile: "syntax error"]
  raised at: ../compiler/Parse/main/smlfile.sml:19.24-19.46
             ../compiler/TopLevel/interact/evalloop.sml:45.54
             ../compiler/TopLevel/interact/evalloop.sml:306.20-306.23
$ smlsharp test.sml
test.sml:4.14(82)-4.14(82) Error: syntax error: inserting  ANDALSO
test.sml:6.12(147)-6.12(147) Error: syntax error: inserting  ANDALSO
$ polyc test.sml                   
test.sml:4: error: <identifier> expected but fn was found
test.sml:4: error: ; expected but => was found
Exception- Fail "Static Errors" raised

MLtonは一応リカバリーを試みて型検査まで進んでいますが、的確なエラーを出力できているとは言い難いです。他は構文エラーで止まっています。

LunarMLでは、こういう場合にも(エラーを出しつつ)構文解析と型検査を進めるようにしました。

$ lunarml compile test.sml
/private/tmp/mltest/test.sml:4:15-4:35: error: parentheses are missing
val _ = m >>= fn x => fn a => x * a;
              ^~~~~~~~~~~~~~~~~~~~~
/private/tmp/mltest/test.sml:6:13-6:30: error: parentheses are missing
val _ = 1 + if a then b else c;
            ^~~~~~~~~~~~~~~~~~
/private/tmp/mltest/test.sml:6:30: error: expected int, but got string
val _ = 1 + if a then b else c;
                             ^

Standard ML向けの言語サーバーであるMilletも、この誤り(カッコの不足)に対するエラーメッセージを持っているようです(millet/docs/diagnostics/3009.md at main · azdavis/millet)。流石ですね。


そんな感じで、LunarMLのエラーメッセージの質を徐々に上げていって、普段使いできるようにしたいです。

この記事では主にコンパイラー開発者の目線で、人間向けのメッセージを出すことについて書きましたが、もうちょっとエラーメッセージの機械可読性も意識した方がいいのかなという気がします。まあでもIDE側では正規表現とかでエラーメッセージを解釈するのでそこまで工夫しなくてよかったりするんでしょうか。

開発体験という意味では、LunarMLによるコンパイルをもっと高速化したいです。現状は型検査に時間がかかっているようです。最適化に時間がかかるのは仕方ないですが、エラーメッセージくらいはもっと瞬間的に返せるようにしたいです。

Spread the love