バグはブラウザには無かった

四つ目のツールはブラウザゲーム —— 2048、テキストで描かれ矢印キーで操られ、人が実際に遊べるライブデモのつもりだった。bindings に欠けた一つの frontend 能力を足した:キーボード入力。だが盤面が壊れ始め、その scramble を追うと誰も予期しない場所に導かれた —— ブラウザでなく、一つの backend でさえなく、コンパイラがずっと nested 関数を lift してきたその仕方のバグへ、プログラミングで最もありふれた習慣に引かれて:ループを `go` と名づけること。

meredogfoodfrontendcompiler-buglanguage-design

四つ目のツールはコマンドラインを完全に去った。2048 —— あの小さな 4×4 のスライドタイルゲーム —— が web ブラウザで走り、盤面が平文テキストで描かれ矢印キーで操られる。native の能力を各々強制した 最初の三つと違い、これには二つ目の仕事があった:リンクを辿れば誰かが実際に遊べるデモであること、 counter や echo より遥かに説得力のある言語 playground への入口だ。言語を WebAssembly にコンパイルし ページに繋ぐ既存の frontend bindings を再利用し、そしてその bindings がまだ持たないちょうど一つを 要した —— キーボードを聞く術だ。

コンパイラを触らず育った能力

一つの gap はキーボード入力だった:bindings はクリックを聞けてもキー押下を聞けず、そしてキーは クリックが運ばないデータを運ぶ —— どのキーか。それを足すとは、ページ上のキー押下を聞き、キーの 名前を言語で書かれたコールバックに手渡す新しい binding を意味した。外から言語の closure へ文字列を 渡す経路は bindings がそれまで一度も行使したことがなく、それを建てることがマイルストーンの全て だった。静かに注目すべきは変更がどこに住んだかだ:完全に bindings 層の中で、コンパイラへの変更は 一切なく、それゆえ言語の新しいリリースも無かった。それ自体が一つの発見だ —— frontend インタフェースが、 コアを乱さず新しい力を育てられるほど清潔に層をなしている、と言っている。ゲームの要求をその単一の gap に 保つため、ランダムな新タイルは、この backend に在るとは限らない builtin に手を伸ばすでなく、手数を seed にした平文の言語コードで書かれた小さな擬似乱数生成器で生成された。gap 一つ、名づけて埋め; 他の全ては迂回された。

盤面が壊れ始めた

入力が動き、スライドロジックが interpreter で正しいと証され —— 2 と 2 は 4 にマージし、タイルは壁に 向かって圧縮する —— ゲームは WebAssembly にコンパイルされブラウザで走り、盤面が誤って出てきた。 タイルがルールの禁じる場所に着地し;空白・空白・2 と読むべき行が 2・空白・空白・1 と出た。ロジックは 証明的に正しく —— interpreter で一手ごとに通った —— ので疑いは新しく不慣れな部分に落ちた:ブラウザ、 WebAssembly backend、メモリを alloc することと closure で変数を capture することの相互作用。最初の 推測はこれが WebAssembly 固有の欠陥、盤面を capture しつつ list を alloc する nested 関数をその backend が扱う仕方の何か、というものだった。その推測は妥当に感じた。それはまた間違っていて、確かめる唯一の道は 推測をやめ、失敗を最小の形に還元することだった。

真因まで二分探索する

ブラウザ、ゲーム、グラフィックスを剥ぎ取ると、バグはその平明さにおいて驚くべきものに還元された。 if の二つの分岐を含む一つの関数を取り、各分岐にそれ自身の小さな再帰ヘルパーを与える —— そして、 世界で最も自然な習慣に従い、両ヘルパーを go と名づける。それらを closure 経由で呼ぶと、プログラムは 間違った方を走らせる:両分岐が二つ目の go に解決される。そしてこれは WebAssembly の癖では全く なかった —— native の C build がちょうど同じ仕方で誤り、interpreter だけが正しいままだった。それは 両コンパイル backend に存在する cross-backend の正当性バグで、その間ずっとコンパイラに座っていた。 原因は nested 関数がトップレベルに lift される仕方にあった:各々がsource 名をキーにした lookup 表に 登録されるので、同じ関数の中の二つ目の go が一つ目を上書きし、go と書いた全ての呼び出し地点が最後に 登録された方に解決される。lifted 関数それ自体は別々の内部名を与えられていた;衝突したのは、依然として 元の名前をキーにした呼び出し地点の lookup だった。ゲームの盤面が壊れたのは、左右と上下の move ロジック が各々内部ループを含み、そして —— 当然 —— それらのループの各々が go と名づけられていたからだ。

なぜありふれた名前が完璧な罠だったか

これは、誘発する形を誰も書かない間ちょうどその間だけ隠れる類のバグで、そして誘発する形はほとんど 攻撃的にありふれている。再帰ヘルパーを goloop と名づけるのは奇矯でない;考えずにすることだ。 一つの関数にそれが二つ、closure 経由で到達される、は先行するどのプログラムも当たらない程度に稀で、 時間の問題である程度に平凡だった。それは、違う関数の中の二つの同名ヘルパーが衝突した先行バグの 近縁だった —— これは単一の関数のの同じ衝突だ —— が、修正は古い方の単純な複製ではなかった。正しい 修理は、どちらの backend もそれを見る前に走る、プログラムへの一つのパスで、nested 関数を名前が実際に 衝突するときにだけリネームする:一つ目は名前を保ち、後の各再利用は一意にされる。backend への分岐前に 走るので、一つのパスが native と WebAssembly の出力を一度に修正し、そして本当の衝突にだけ触れるので、 一度も衝突しなかった全プログラムはバイト単位で不変に残される —— 特定の関数名を主張する全テストを含めて。

ゲームはその後 ship した。ブラウザでライブに遊べ、テキストで描かれ、矢印キーで操られ、小さな生成器 からタイルを生む。公言された目標 —— 誰かが実際に遊べる到達可能なデモ —— は達せられた。だがより深い 結果は探偵譚だった:frontend をテストするために建てられたツールが、壊れた盤面を通して、frontend とは 何の関係もないバグを見つけた、両コンパイル backend に跨ってコンパイラに潜み、誰かが go という二つの ループを書くのを待っていたバグを。先立つ三つの native ツールは C backend を鍛えた;これは WebAssembly backend を鍛えた —— そして backend の修正より良いものを手渡した、皆がループを名づける通りに自分の ループを名づける全プログラムを守る正当性の修正を。それが四つのツールの最後で、この Part は次に、全ての 中で最も奇妙な発見で閉じる:言語への修正が、それで書かれたプログラムを静かに壊した、という。

← Back to Mere: 言語を作る