一つの int、四つの機械
欠けた bitwise 演算子を狙った probe が、その一段下にあるものを先に踏み抜いた: 言語の int が backend ごとに別の幅だった。interpreter は 63-bit、C backend は黙って 32、LLVM と wasm はそれぞれ別。SHA-256 の round 定数 —— 20 億超えが 36 個 —— は C backend で truncate し、診断ゼロで自信満々の誤った digest を吐いた。一方テストが走る interpreter では正しく計算されていた。bitwise 演算子が意味を持つ前に、言語は「整数の幅はいくつか」を決めねばならなかった。
この Part は前 Part の「安い probe」方法の一日を、安くなくなるまで回したものだ。4 本の小さな プログラムから 6 リリースが出た。その全部を貫く一本の糸は、バグ報告に聞こえて実は設計声明である 一文だ: 言語に backend が 4 つあるとき、真実は 4 つある。それらが同じ真実になるのは、誰かが そうしたときだけだ。 最初の probe は、そうなっていなかった場所を見つけた。
標的を外した probe
計画は控えめだった —— 言語で SHA-256 を書いて、欠けた bitwise 演算子がどれだけ痛いかを測る。
全ての AND と XOR は除算と剰余で偽装せねばならないからだ。偽装は醜いが interpreter では動き、NIST
test vector を全部通った。ところが同じプログラムを C backend で compile すると、完全に誤った
digest を吐いた。少し違うのではない —— 無関係だ。そして何も言わない: 警告なし、クラッシュなし、
診断なし。bit 偽装は正しかった。問題は一段下だった。SHA-256 の 64 個の round 定数のうち 36 個が
20 億超えで、C backend の整数は素の C int —— 32-bit —— だった。それらの定数は書かれた瞬間に
黙って負の数へ wrap した。最小再現は身も蓋もない: 2147483647 + 1 は interpreter で
2147483648、native で -2147483648 を印字する。docs は C backend がどの幅を使うか一度も
書いていなかった。テストは全部 interpreter で走り、そこでは算術は 63-bit で問題なかった。この
プロジェクトが出荷してきた全ての compile 済みプログラムは、テストした整数意味論とは別のもので
走っていた —— そして 20 億の先に届くプログラムだけがそれに気づく。
幅を決める
修正自体は微妙ではないが、その周りの規律は微妙だ。C backend の整数は 64-bit になった —— long long、リテラルには幅拡張前に 32-bit で wrap するのを防ぐ suffix を付け、印字と parse の経路も
合わせて拡張。interpreter は native の 63 のまま。wasm と LLVM は 32 のままだが、それについて
嘘をつくのをやめた: 範囲に収まらない整数リテラルは、silent に truncate されて garbage や下流
ツールの苦情として現れる代わりに、位置情報つきの compile エラーになった。そして一つの境界は
意図して統一しなかった —— foreign-function 境界、Mere の extern fn が libc や POSIX 関数を
名指す場所では、int は C の 32-bit int のまま。それがそれら関数のシグネチャが実際に使う幅
だからだ。ここで拡張すればレジスタの未定義ビットを読む。だから言語はいま正直な表を持つ: 4
backend、3 つの幅、各々に文書化された規則が一つ、そして差が偶然でなく意味を担う場所が一つ。この話
の要点は「バグが直った」ではない。「整数の幅はいくつか」は言語ができる限りどこでも同じように、
できない場所では見える形で答えねばならない問いだ、ということ —— そしてこのプロジェクトが答えを
見つけた方法は、真実が分岐するまさにその数値を使う仕様書を書き写すことだった。次の話でようやく
probe が必要としていた bitwise 演算子を建て、それがいくらの価値だったかを計る。