15 個の借り物と、9 個のバグ — Gleam のコンパイラを読んで Mere に入れたもの

Gleam のコンパイラを自分のコンパイラ —— 5 つの backend を持つ ML 系言語 Mere —— の隣に開いて、5 周読み、15 個を取り入れた。そのうち 11 個は、自分のコンパイラが既に計算していた答えを出口につなぐ配線でしかなく、言語機能と呼べたのは 2 個だけだった。返ってきたのは借り物ではない。小さな機能 1 つを 5 つの backend 全部に通させ、1 項目につき 1 本のゲートを書かせたこと —— そこから 9 個の欠陥が出た。うち 1 つは既に出荷されていた。

merecompilerslanguage-designdiagnosticsverificationgleam

数日、Gleam のコンパイラを自分のコンパイラの隣に開いて過ごした。Gleam は BEAM 上の 静的型付き言語で JavaScript にも出力する。こちら —— Mere —— は ML 系で、backend が 5 つあり、GC を持たない。下のレイヤーではほとんど何も共有していない。 だからこそ読む価値があった: 土台が何もかも食い違う 2 つのコンパイラの間で移るものは、 たいてい人が触る側の話だからだ。

5 周して、毎回同じ問いを立てた: 何を取るべきか。

1 周目の前に規則を 1 行だけ決めた。決めないと、この種の読書は願望リストになる。

既に計算している答えを、まだ配線していない出口につなぐものだけを採る。

最終的に 15 個を採った。11 個は配線だった。2 個は小さな新しい解析。 言語機能は 2 個。 5 周目は新機能ゼロ —— その頃には上流の changelog の コンパイラ節がほぼ全部「エラーメッセージの質」になっていて、それ自体が 1 つの発見だった。 後で戻る。

ただ、この記事は 15 個の話ではない。取り込みの作業が自分のコンパイラのバグを 9 個見つけたという話で、そのほとんどは取り込んだものの中には無かった。

規則が生んだもの

「配線」は定義より実例が早い。自分の language server は、欠けている match の腕を 既に計算できていた —— 網羅性検査がエラーメッセージの中で欠けた構成子を名指している。 それを code action として提供したことは一度もなかった。誰も求めなかったからだ。 Gleam の server は code action を 49 個持っている。こちらに 3 個足すのは半日だった。 答えはあり、プロトコルのフィールドが無かっただけ。

同じ形が繰り返し出た。hover は型を知っていたが、定義の上にある doc コメントは見ていなかった。 --decls は宣言の一覧を印字していたが、テキストなので誰にも読めなかった。 prelude の assert はユーザの行を名指すスタックを計算した上で、 prelude 自身の行の位置を報告していた。どれも、答えは手元にあり、出口で捨てていた。

これは気分のいい発見で、これだけなら退屈な記事だった。 居心地が悪くなるのは、これらをシステム全体に通し始めてからだ。

1. 機能 1 つ、backend 5 つ、バグ 4 つ

echo x は値を、書いた行と一緒に stderr に出す。デバッグの小道具で、 機能としては最小に近い —— prelude に定義 2 つと、スコープを見る書き換えパス 1 つ。

そして 5 つの backend 全部で走らせ、stderr をバイト単位で比べた。

WebAssembly のホストが print_err を提供していなかった。 emit 側は 243 版にわたってそれを import していて、呼ぶプログラムは instantiate に失敗していた —— 答えが違うのではなく、モジュールが読み込めない。 誰も呼んでいなかったからだ。echo が来るまで呼ぶ理由が無かった。

RISC-V backend の print_err は末尾の改行を書かなかった。他の 4 つは書く。 同じプログラム・同じ入力で、どの backend でコンパイルしたかによって stderr が違う。 parity スイートはまさにこの種の欠陥のために存在していて、そして stdout を比べていた。

さらに行番号。RISC-V の経路では prelude がテキストとしてユーザのソースに前置されるので、 プログラムの 2 行目は lexer にとって 1925 行目になる。 診断は出口で既に補正していた —— どのエラーメッセージもオフセットを引いていた。 補正されていなかったのはプログラムの中に入っていく位置で、 それがまさに echo のすることだ(実行時に印字する文字列に「line 1925」を焼き込む)。 同じ未補正の経路のせいで、未読束縛の警告がprelude の中身を 「ユーザが書いた行」として報告していた。

欠陥 4 つ。うち 1 つは 243 版の潜伏。小さな機能 1 つを、どこでも走らせるくらい 真面目に扱っただけで出た。

2. フォーマッタが、足したばかりの糖衣を食べた

echo はパース時に降ろされる。echo x は、ソース位置を持つ呼び出しになる。 パーサより後ろの段は全部、降ろされた形を見る。それが狙いだ。

フォーマッタはパーサより後ろの段だ。

だから echo x を含むファイルに mere fmt をかけると echo_at "line 2" x が出力された。 自分がフォーマットしているプログラムを書き換えるフォーマッタを作ってしまっていて、 しかも糖衣を足したのと同じ日に。糖衣を残すパースモードが要り、呼び出し側はちょうど 1 つ。

これを書くのは、見つかり方のためだ。使い捨てのファイルに mere fmt を手で打った。 ゲートは 1 本も鳴らなかった。フォーマッタ自身のゲートはコメントの本数を比べ、 往復のゲートは echo を 1 つも含まないコーパスで走る —— 機能は 1 日前のものだった。

3. 同じ苦情が 2 回描かれていた

型を 2 回宣言すると、丁寧な文面が出て、位置が 1 つも出なかった。 直すのは 10 行だった。パーサの表はどの型名がどこで宣言されたかを既に記録していて、 2 回宣言された名前はそこに 2 つ残っている。

直して、ビルドして、コマンドラインの出力は変わらなかった。

mere check —— エディタの経路 —— はその苦情に 1 つの関数から到達する。 コンパイラは例外を投げ、それはまったく別の場所で描画される。 1 つの問いに 2 つの答えがあり、私は誰も読んでいない方を直していた。 直し方は面白くない。強いられた習慣の方が大事だ: 診断が直ったと言う前に、その診断に到達する経路を数える。 この 1 週間で 3 回必要になった。

4. 4 つのリポジトリのうち 3 つが空だった

最後の周で、他言語から来た綴り —— var x = 1;、def f(n):、a and b —— に対するヒントのカタログを足した。意味論側には help: 行が 26 本あるのに、 構文層には 0 本だったからだ。表は、パースが失敗した位置の token を鍵に引く。

そのうちいくつかの語は、自分のコードで本物の識別子だ。 var case val mut はどれも自分のリポジトリのどこかで束縛されている。 だから表は答える前に 1 つ訊く: このファイルはその名前を束縛しているか。 していれば、その語は識別子で、表は黙る。

どの語にガードが要るかを決めるため、コーパスを測った: 4 リポジトリ、889 ファイル。

その数字が間違っていた。 4 つのうち 3 つのパスが存在しないディレクトリを指していて、 それぞれ 0 ファイルを返し、合計は非ゼロなのでどこも壊れて見えなかった。 本当のコーパスは 989 ファイルで、測り直すと of は 424 → 943、 var は 4 → 29 になった。

増えた分はノイズではなかった。全部がタプルパターンの束縛だった —— let (val, j) = scan_octal s (i + 1) 0 0 in、 let (classes, var, prefix, r2) = parse_rescue_head r in —— そして私のガードは let NAME = の形しか知らなかった。 その実在する行の上で、コンパイラは作者に他言語の予約語の話をしていた。

コーパスの測定は、届いた範囲の分だけの価値しかない。 ファイル数をリポジトリごとに印字していれば、0 が 3 つ並ぶのが一目で見えた。今はそうしている。

5. 捨てた実験が、出荷済みの穴を見つけた

4 周目に、配線より大きいものが出た。Gleam ではモジュール内の定義の順序が問題にならない。 コンパイラが呼び出しグラフを作り、強連結成分を依存順に型検査するからだ。 Mere では、下で定義した関数を呼べない。回避策は全部を 1 つの let rec ... and ... に 入れることで、自作 Ruby サブセットの main ファイルではそのグループが 650 個続いている。

実験として作った。動いた —— 後方参照がコンパイルされ、走った。 そして何が買えたかを測ったら、ほとんど何も買えていなかった: 2,858 個の宣言のうち、より一般的になったのは 4 個で、どれも誰も使わない偶然の一般性。 実験は捨て、理由を書いた。

出ていく途中で、置き土産があった。 実験の 1 段が隣り合う束縛をまとめていて、 その段が入っていると、拒否するはずの回帰テストが拒否しなくなった。

実験が検査を壊したのではない。元からあった穴に、普通の束縛を流し込んだだけだった。

Mere のライブラリモードは、export された呼び出しごとに専用のメモリ領域を与え、 戻るときに解放する。そして、呼び出し中に作ったコンテナをモジュールの状態に置くプログラムを 拒否する —— ポインタが領域より長生きするからだ。この拒否は let 束縛を型付けする場所に 入っている。let rec 束縛は同じ match の別の腕で型付けされ、それは手書きで、 4 つの宣言ループに分かれている。4 つとも、この検査を持っていなかった。

値制限も同じだった。let store = vec_new (); は意図して単相なのに、 let rec store = vec_new (); と綴ると一般化され、 1 つのベクタが int と str を同時に持てて、どちらの使用も文句を言わなかった。 こちらは出荷済みのコンパイラで、1 語変えるだけで届いた。

今は両方とも、4 つのループが共有する 1 つの入口を通る(let の方は既にそうしていた)。 書いたゲートは、どちらの規則についてのものでもない。 top-level 束縛の 5 通りの綴り全部で各性質を走らせ —— let / let rec / グループのメンバ / その 2 つを module の中に置いたもの —— 綴りごとに判定が食い違ったら落ちる。どちらが正しいかに関係なく。 前の build に当てると、ちょうど 6 箇所で赤くなる。

形

他のコンパイラを読むことは、ふつう「機能を見つける方法」として説明される。 そうはならなかった。 15 個の借り物のうち機能は 2 個で、5 周目はゼロだった —— Gleam 自身の最近の仕事は圧倒的にエラーメッセージの質についてで、 それは「利用者が居ること」がコンパイラの優先順位に何をするかを教えてくれる。

取り込みが実際にしたのは、私を動かすことだった。 デバッグの小道具 1 つを 5 つの backend に通したら欠陥が 4 つ出た。 借りた項目ごとにゲートを 1 本書いたら —— 11 本、どれも「赤くなれること」を 証明する毒見つき —— 残りが出た。以前 ガードレールは 1 つのプログラムを守り、ゲートは問いを守る と書いた。守る価値のある問いを 11 個増やす方法として、これがいちばん安かった。フォーマッタのバグも、2 経路のバグも、 コーパスのバグも、出荷済みの健全性の穴も、借り物そのものではなく、 借り物の周りの作業が見つけた。

この記事には、15 個の機能ときれいな表で終わる版もありうる。 正直な終わり方はもっと小さくて、そのぶん良い: 機能はたいてい既にそこにあり、配線を待っていた。 模倣は近道ではなかった。計器であり、測っていたのは自分のコンパイラだった。

記録のための数字: テスト 2,767 → 2,817。ゲート 11 本追加(すべて毒見つき)。 CI ジョブ 11 本追加。構文層は説明 0 行から 18 行のカタログへ —— パースエラーを投げる 149 箇所は 1 つも触らずに。

← Back to Notes