スケールで完全一致を保つ
十行のデモは feature が存在することを証す。千行のプログラムは feature が組み合わさることを証す。千行を超える SQL 風エンジンが、インタプリタと三つのコード生成器すべてでまったく同じバイトを生まねばならないとき、現れるバグはどの一つの feature の中にもない —— 組み合わせ、順序、実コードだけが作る escape の中にある。そのいくつかは、基準そのものを変えることで直される。
いまや言語の実装は四つある —— インタプリタと三つのコード生成器 —— そして規則は、四つすべてが byte 単位で同一の出力を生むことだ。その線を十行のデモで保つのは易しい。今回は、それを小さくない プログラムで保つ話だ:SQL 風のエンジン、千行を超え、自前の自己テストを数十持つ。そこが、parity が 標語であることをやめ、バグを見つけ始めるところだ。大きなプログラムは、小さなものがしないやり方で 言語を行使するからだ。
小さなプログラムは feature を、大きなものは組み合わせを証す
短い例は一度に一つの feature を行使しがちだ —— ここに match、ここにクロージャ、ここにレコード。
それは feature が存在し、孤立して正しく下りることを確かめる。それが確かめられないのは、feature が
出会うとき何が起きるかだ:再束縛された変数を捕捉するクロージャが、region の中で、region より長生き
するコレクションを保持し、variant を再帰する show で表示される。実コードはそうした組み合わせの
中に住み、バグもそうだ。千行のプログラムを四つの backend でコンパイルし同一のバイトを要求すること
は、その組み合わせを自動的にテストする一つのやり方だ —— そして壊れたものは見る価値がある。
それぞれが、「同じ意味」の二つの実装が静かに乖離した場所だからだ。
同じ意味が乖離した三つのしかた
一つのバグは、C backend が、自分自身から計算された値に名前を再束縛する let x = f x をどう扱うか
にあった。明白な翻訳は C の初期化子の両辺に x を置き、それを C は禁じる(変数は自身の初期化子に
現れてはならない)ので、clang が拒否した。インタプリタにはそんな規則がなく、問題なく評価した。
修正は、束縛を一時変数を介した二段階でコンパイルし、新しい x が計算される間も古い x が見える
ようにすることだった。名前を決して隠さないデモには、これは何も現れない;実コードが普通の
let x = step(x) パターンを書いた瞬間に現れる。
もう一つはより微妙で、Wasm backend のメモリモデルに住んでいた。region はバンプポインタを巻き戻す ことでメモリを回収する —— だがプログラムが region の中でコレクションを確保し、それを region より 長生きするようescape させると、ポインタを巻き戻すことが、escape した値がまだ指すメモリを解放し、 次の確保がそれを上書きする。他の backend は escape するコレクションを別に管理してこれに当たらな かった;Wasm だけが region アロケータを escape 値と共有していた。修正は Wasm の region 意味論を 他と揃えた。これはまさに、region-内-確保して escape するほど豊かに構造化されたプログラムなしには 現れ得ないバグだ —— つまり、現実的な何かなしには。
三つ目は文字列の等価にあった:実行時に作られた文字列が、プログラムのデータに焼き込まれた文字列と 比較されるとき、内容ではなくポインタで比較されていて、由来の違う二つの等しい文字列が静かに 不等とテストされた。文字列リテラル同士しか比較しないデモは決してそれを引かない;実行時トークンを 期待キーワードと比較するパーサは、絶えずそれを引く。
このどれも劇的な失敗ではない。それらは静かな不一致 —— もっともらしいが一つの backend では偶々 間違っている出力を生む類 —— で、それらが捕まった唯一の理由は、出力がインタプリタに対して byte 単位で 比較されたことだ。目視は三つとも素通りしただろう。
修正が基準への場合
すべての乖離が、codegen 側で潰すべきコンパイラのバグではない。一つは、マップがエントリを反復する 順序についてだった。backend たちは一致せず、それは厳格な byte 単位の規則の下では失敗だ —— たとえどの backend の答えも間違いとは言い難くとも(マップに固有の順序はない)。解決は、順序を インタプリタで固定し、挿入順に反復させ、定義された答えが一つあって四つの backend がそれに一致 するようにすることだった。
これは立ち止まる価値がある。基準が何のためかを示すからだ。インタプリタは神聖ではない;それは 合意された定義で、定義が未規定のとき —— 「マップの反復順序」が本当にそうであるように —— 正しい 一手は、四つの backend を偶然一致するよう言いくるめるのではなく、他のすべてが測られる唯一の場所、 そこでそれを釘付けにすることでありうる。そして基準は意図して厳格なままだ:順序のような単に見た目の 違いも、なお違いであり、「十分近い」を許すことは、parity 規則が閉じるために存在するまさにその扉を 再び開けてしまう —— どの backend を使ったかに、見えるものを静かに変えさせることを。
保つのに何がかかるか
四つの実装を同一に保つことは、一度きりの達成ではない;それは立ち続けるコストだ。現実的なプログラム たちが回帰コーパスになる:十を超え、最大は千行超で、あらゆる backend で走らされ diff される、毎回。 後の変更が持ち込んだ乖離は、次にコーパスが走るときに捕まり、ユーザに発見されない。そしてカバレッジ 台帳の規律が保たれる —— 新しい feature は、全 backend に着地しコーパスが依然同一に出るまで、完成 ではない。「backend は意味論的変数ではない」の代価は、実プログラムのテストスイートと、そのどれも 乖離させない拒否とで、継続的に払われる。
見返りは、望みではなく事実として述べられるほど具体的だ:千行のプログラムが、四つの異なるやり方で —— インタプリタに歩かれ、C を通してコンパイルされ、LLVM を通してコンパイルされ、WebAssembly へコンパイル されて —— 同一のバイトを表示する言語実装。それが、現実的な何かの上で現金化された規律の全部だ。
四つの backend のうち三つが、いまや同じ feature の梯子を通り、同じ出力に保たれた。四つ目は、あらゆる 比較に居合わせてきたが、まだ直接には見ていない。そしてそれが、最も異例な制約を持つ —— リニアメモリを 持ち、関数ポインタのネイティブな概念を持たない、サンドボックス化されたターゲット。次回:WebAssembly backend、そして、ウェブページの中で走るマシンへ言語をコンパイルするのに何が要るか。