分割できなかった 54,000 行のファイル
自作の Ruby 処理系がなぜ 54,063 行 1 ファイルなのかを調べたら、答えは「その方が好みだから」ではなく「言語が分割を表現できないから」だった。30,392 行までは言語変更なしで到達し、残り 29,606 行のために新しい宣言構文を実装したら、緑の単体テスト 7 本の下で欠陥が 4 つ生きていた。
Mere は自分のために書いている小さな言語です。mere-ruby はそれで書いた Ruby 処理系 で、ruby/spec の約 1,100 例が CRuby とバイト単位で一致し、207 本のコーパスが毎回 本物の ruby と一致することを要求されています。
それが全部 1 ファイルに入っていました。main.mere、54,063 行。
当然の質問を受けました。1 ファイルの方が本当に都合が良いのか、それとも単に分割 していないだけなのか。 私は「やっていないだけ」だと思っていました。違いました。 言語が分割を表現できなかったのです。それが分かるまでに丸一日かかりました。
これは実測の記録です。数字は全てこのマシンから出したもので、間違えた回も正しかった 回と同じ表に載せてあります。
1. 5 通りの綴り、全部だめ
処理系は処理系らしく相互再帰しています。eval_e が call_method を呼び、それが
eval_body を呼び、それが eval_e を呼ぶ。だから最初の問いは、相互再帰が越え
られない線を言語がどこに引いているかです。
ファイルかモジュールだろうと思っていました。測りました。
| 書き方 | 結果 |
|---|---|
同一ファイルの 2 本の let rec 鎖 |
unbound variable: g |
module A { } と module B { } |
unknown constructor: B |
import を定義の前に置く |
unbound variable: is_odd |
import を定義の後に置く |
同上 — splice 位置より前は見えない |
and で始まるファイルを import |
parse error: expected literal, identifier, or '(' |
境界はファイルでもモジュールでもなく、let rec ... and ... の鎖です。 import
はパーサが行う splice — import 位置に宣言を落とし込む — なので、鎖は splice が
起きた場所で閉じ、どの import もそれを越えられません。
つまり処理系は 1 本の鎖として書くしかない。そして鎖は 1 つの構文要素なので、鎖は 1 ファイルです。
2. 最初の壁の後ろにもう 1 枚あった
相互再帰していない 60% は切り出せるはずでした。位相順に並べて import すれば
良い。最初に切りたかった行でやってみたら
parse error: expected literal, identifier, or '('。
原因はコードではなくパーサにありました。
| _ -> let main, toks = expr toks
parse_decls は宣言を読み続け、宣言を始められないトークンに出会うと、それを
プログラムの main 式の先頭として読みます — そこから先はすべてその 1 つの式に属し
ます。だから import は宣言前置の中にしか置けない。
main.mere の宣言前置は 555 行目で終わっていました。残り 53,500 行は 1 つの式
です。import を置ける場所がありませんでした。
top-level の let ... in が前置を終わらせる構文の 1 つだったので let ...; に
変換しました。前置は 490 行から 555 行に動き、また別の構文に当たり、私は「潰す
たびに次が出る」と結論しました。
それは間違いで、間違っていたのは私の検索でした。 パターンが ^let .* in
だったので、in が同じ行にある let ... in しか拾っていなかった。残りは
ちょうど 3 個、いずれも複数行にまたがるものでした。31 個すべて変換すれば前置
はファイル末尾まで届き、54,000 行目の import が通ります。
教訓は単純で、既に書き留めました。パターンが出した数は、そのパターンが当たった 数でしかない。 私は 28 で止めて、そこから構造についての結論を引きました。
3. 検証の規則と、それが 1 度だけ破れたとき
ファイル間でコードを動かすことはプログラムを変えてはいけません。コンパイラは 1 本 の C ファイル(58 MB、36 秒)を出すので、強い検査が使えます — 前後で emit して diff を取る。
バイト単位ではありません。コンパイラは一時変数と region に通し番号を振る
(_uq214、__rp7、__v2)ので、何かを動かせばそれ以降が全部振り直されます。
使った規則は 「生成 C の差は採番だけ」。7 回の切り出しのうち 6 回はこれを厳密に
満たしました。前節の let ... in 変換も同じ検査にかけ、global map 183 個が完全
一致、関数 5,817 個が一致、差は cross-chain の重複名 2 つの mangling が _uq214
から __v2 に変わっただけでした。
7 回目は満たさず、しかも私はそれを誤って報告しました。
「4 つの関数が __direct を失い、4 つが得た」と書きました。__direct は、飽和した
呼び出しがクロージャ環境を作らずに飛べる非カリー化の入口です。もっともらしく聞こえ
ました — 呼び出しが静的に解決できるかは呼ばれる側の位置に依存し、840 個の定義
が動いたのですから。
原因は私の正規化でした。emit された名前から _uq\d+ と __v\d+ を落として、
単相化のサフィックスを忘れていた。だから
mu_bnd_snapshot__direct
mu_bnd_snapshot__Map_str_Val__Map_str_Val__direct
が別々の関数に見えた — 1 つ失われ、1 つ増えた、と。同じ関数です。ソースレベルの
名前で比べると 前 2,549 個、後 2,547 個、そのすべてが両方のビルドで __direct
を持っています。 実際に変わったのは、死んでいた関数 2 つが emit されなくなった
ことだけでした。
理由まで書いて訂正をコミットしました。記録に残った誤った測定値は、測定値が無い より悪いからです。
4. 言語を一切変えずに進んだ分割
鎖のメンバは 1,451 個。うち 867 個はどの循環にも属していませんでした。
let rec eval_e = ... and ... の中に書かれていたのは、それを必要とするコードが
そこにあったからで、そこになければならない理由があったからではありません。
| main.mere | ファイル数 | |
|---|---|---|
| その日の朝 | 54,063 | 1 |
| フロントエンドとドライバを出す | 46,168 | 3 |
| さらに 4 モジュール | 39,651 | 7 |
| 非循環の鎖メンバ 840 個を出す | 30,610 | 8 |
| 死んだコードを削除 | 30,535 | 8 |
| 最後の 5 個 | 30,392 | 8 |
⚠ 分割の単位は話題ではなくファイルです。 最初は 840 個を、その下に書かれていた
節コメント(Struct、Zlib、Marshal、sprintf)で束ねようとして、節と節の間に 13 個
の循環が出ました。それらのファイルを並べる順序は存在しません。1 つの
let rec ... and ... 群にしてしまえば内部の順序はまったく問われず、840 個は互いに
非循環(そのなかの強連結成分はすべて 1 要素)なので、ファイル内部にも並び順の制約
がありません。
1 ファイル = 1 相互再帰群。これが言語が実際に持っている粒度です。
5. 剥がしは不動点に達する
840 個が出た後、書いたばかりのゲートが誰も呼んでいない top-level 関数を 30 個
見つけました。それを消すとさらに 5 個が鎖の内側から到達不能になり
(arr_product2、errno_check、lp_seed、params_wo_defaults、
register_builtin_consts)、こちらも出せるようになりました。
残るのは 596 メンバ / 29,606 行で、これ以上は 1 つも動かせません。評価器の鎖は いま、その既約な部分そのものです。
6. 3 つのゲートと、書いた日にそれぞれが捕まえたもの
ファイルの分割はリファクタリングであり、リファクタリングには私の注意力以外の 見張りが要ります。
dup_defs_check.sh — 1 本の鎖の中で同じ top-level 名を 2 回定義してはいけない。
呼び出しは全て後の定義に解決されるので、前の定義はビルドが緑のまま静かに死に
ます。これは、既にある名前の隣に同名の helper を足して見つけました。型が合うので
ビルドは緑のまま、修正が効かない — 呼び出しが全部古い方に行っていたからです。
午後をほとんど溶かしました。6 件溜まっていて、うち 2 件は生きている版と
挙動が違い、一度も走ったことがありませんでした。
dead_defs_check.sh — 定義されて一度も呼ばれない top-level 関数を禁じる。
誰かが途中でやめた書き換えに取り残されたものが 30 件ありました。
⚠ これを書いているとき、生きているグローバルを危うく消しかけました。定義の終わりを
「次の = fn」と決めていたので、GC の root テーブルである
let gc_unsafe = map_new (); が 2 つの関数の間に挟まって巻き込まれた。コンパイラが
捕まえてくれました(unbound variable: gc_unsafe)。正しい規則は**「次の top-level
項目(種類を問わず)」**です。
gen_structure_map.py — どの定義がどの鎖にいて、循環の中にいるかどうかの地図。
⚠ これはプログラムを splice 順(import を展開した順)で読まなければなりません。
ファイルをアルファベット順に読むと、評価器の循環が 376 関数から 383 関数に動きます
— 別のプログラムを見ていることになります。
7. 言語の変更: let fn
残る 72% を割るには、「この 2 つは互いを呼ぶ」を、「この 2 つは 1 本の鎖にいる」と 言わずに表現する方法が必要でした。候補は 4 つ。
- 前方宣言 —
let fn f: A -> B;を先に置き、定義は後。 rec { ... }グルーピング — ファイルを跨げる明示的な相互再帰ブロック。- 鎖の継続 — 「この import は鎖を継ぐ」と宣言する import。
- ユニット単位の順序不問 — トップレベル全体を 1 つの再帰スコープにする。
しばらく 4 が最有力だと思っていました。そして、測っていなかったものを測りました。
let rec ident = fn x -> x
and useit = fn (n: int) -> ident n;
let b = ident "s"; → type error: expected `int`, got `str`
let rec ... and の群は、中も外も単相です。 候補 4 はトップレベル全体をその
1 群にするので、全 top-level 関数が互いに単相になります。多相な helper を
プログラムのどこでも 2 つの型で使えなくなる。候補 2 と 3 も同じ形です。
残るのは候補 1 で、その理由は好みではなく型システムにあります。宣言に書いた型は プログラマが量化するので、定義は多相のままでいられます。mere-ruby は失うものが ゼロです — 評価器の鎖はもともと 1 つの群なので、その 376 関数は既に互いに単相です。
構文は、Mere の外にある名前を宣言する既存の extern fn <name>: <ty>; と対に
なります。こちらは中にあって後に定義される。新しいキーワードを取りません —
let fn は今日の Mere では宣言の開始として合法でない(パターンは fn になれない)
からです。(val にしていたら、識別子として使っている 6 ファイル 14 箇所を壊して
いました。)
let fn is_even: int -> bool;
let is_odd = fn (n: int) -> if n == 0 then false else is_even (n - 1);
let is_even = fn (n: int) -> if n == 0 then true else is_odd (n - 1);
実装は調査の予想どおり小さく済みました。C backend は無改修 — もともと全関数を冒頭
で前方宣言しています。インタプリタは約束をプレースホルダ ref に束縛し、定義がそれを
埋める。let rec 群が既にやっている後埋めと同じです。
読んでいては分からなかったことが 2 つ。 1 つ目、書かれた型変数は多相の約束
であって剛体名ではありません。そして region parameter のせいでそれが常態です —
mere-ruby に必要な 161 本の宣言のうち 117 本が型変数を含みます。Map や Vec
を取る関数は全部そうだからです。単相版では 1 本も書けませんでした。2 つ目、
let rec 群のメンバが果たした約束も「果たされた」と数えなければならない。鎖は群
でできているので、そちらが本命の経路です。
mere --decls <file> を足しました。そのファイルが定義する top-level 関数の宣言を
印字します。161 本書くのは判断ではなく転記です。
本番で実証: mere-ruby の 38,856 行の評価器の鎖を、生成した宣言 6 本で 2 つに 割りました。生成 C は採番以外同一。
8. そして、その機能に欠陥が 4 つあった
単体テスト 7 本を付けて着地させました。全部緑。そして 4 つの欠陥が全部その下で 生きていました。
見つけたのは別の単体テストではありません。mere --decls の出力を、それが出てきた
ファイルの先頭に貼り戻して、出力がバイト単位で一致することを要求したこと —
178 本の実プログラムに対して。
| 欠陥 | 症状 |
|---|---|
| builtin を隠す名前の約束が履行されない | let fn odd; + let odd = ... が「未定義」で拒否される |
| 宣言より具体的な定義が通る | 定義の上の呼び出しが、定義に本体の無い型で呼べる。型検査は通り、C backend がコンパイルに失敗 |
| 宣言を書くと多相が消える | 宣言した 'a -> 'a が最初の呼び出しで固定 — 宣言しない方が一般的だった |
--decls が prelude の 70 名を印字し、プログラムを実行する |
process_decls が全 top-level let を評価するので、宣言を訊くとプログラムが走り、その標準出力が混ざる |
1 つ目は、builtin を隠す top-level 束縛を改名するパスです。前方宣言を知らなかった
ので、約束は odd、定義は odd__v2 で登録されていました。宣言と定義は 1 つの
束縛なので、改名は宣言の位置で行い、定義がそれを引き継ぐようにしました。
真ん中の 2 つは同じ根です。宣言と定義を具体化で結んでいましたが、正しいのは
包摂でした。宣言のスキームの新しいインスタンスに対して定義を unify すると、
定義が約束より狭くてよくなってしまう — そのうえインスタンスの型変数は外側のレベルで
作られるので、定義自身の型変数を一般化の届かない所まで引き下げます。書かれたまま
の宣言に対して unify すれば両方同時に直ります。パーサは 'a を、自分自身か未束縛の
変数としか単一化しない剛体パラメータにする — それがまさに skolem だからです。
往復検査が見つけた 5 つ目は欠陥ではありません。builtin を隠す名前に宣言を付けると、 影が宣言の位置まで繰り上がるので、定義より上に書いた呼び出しが builtin を見なく なります。これは機能が正しく働いているだけで(コーパスにはまさにその順序を押さえる ためのプログラムがあります)、しかし生成ファイルを貼る人が期待することではありません。 そういう行は理由付きでコメントアウトして印字するようにしました。
168 本中 167 本が往復します。通らない 1 本は module の中で宣言した record 型を 名指しており、それは宣言の有無に関係なく module の外からは注釈で名指せません。 名指しで除外し、通るようになったらゲートが落ちるようにしてあります。除外が その理由より長生きしないように。
9. そして、使わないことにした
let fn があれば残り 29,606 行は割れます。割る前に、割る代金を測りました。
| メンバ N の後で切る | ファイル A | ファイル B | 必要な宣言 |
|---|---|---|---|
| 50 | 3,579 | 26,027 | 91 |
| 200 | 13,404 | 16,202 | 147 |
| 250 | 16,289 | 13,317 | 156 |
| 300 | 21,143 | 8,463 | 126 |
| 450 | 25,588 | 4,018 | 53 |
バランスの取れた切り口は手で維持する型シグネチャ 150 本を要求し、得られるのは 15,000 行のファイル 2 つ。15,000 行は 30,000 行より読みやすいファイルではないし、 150 本のシグネチャは宣言と定義がずれる場所が 150 か所という意味です。
なので、やめました。核は 1 つのまま。機能はあり、正しく、文書もゲートもある — そして**ここでの正しい使い方は「使わない」**でした。
これは本当の結論なので、はっきり書いておきます。この話の誘惑的な版は、ファイルが 半分に割れて終わるからです。
何を払い、何を得たか
以下は全部変わっていません。それが要点です。ruby/spec は 1,094 一致・125 差分・ 0 クラッシュ、コーパス 207/207、CLI テスト 17 本。 前後で同一であることは、新しい コンパイラでビルドした処理系と古い方でビルドした処理系を、全コーパスプログラムで 突き合わせて確認しました — 差はゼロ。
| 前 | 後 | |
|---|---|---|
main.mere |
54,063 行 | 30,392 |
| ファイル数 | 1 | 8 |
| 合計 | 54,063 | 53,960 |
| 死んだ top-level 関数 | 30 | 0、以後ゲート |
| 同一鎖の重複名 | 6 | 0、以後ゲート |
消えた 103 行が死んだコードです。分割そのものは行を移すだけで減らしません。正直な 要約は、最初に開くファイルが 54,063 行から 30,392 行になったこと、そして残りの 23,568 行が「何をするか」で名付けられた 7 ファイルにあること、です。
同じ一日を始める人に伝えたいことが 3 つあります。
賢明かを問う前に、可能かを問う。 私は最初の 1 時間を、言語が実行できない リファクタリングについて可読性と churn を天秤にかけて過ごしました。本当に効いた測定 は 10 分と 3 行のプログラム 5 本で終わりました。
リファクタリングのオラクルはテストではなく成果物。 「前後で emit して採番を除いて diff」は、私が持っているどのテストもカバーしていないものを捕まえました。そして 実際に差が出たときは、コンパイラより先に私の正規化が間違っていました。
生成した記述の正直な検査は 1 つだけ — 入力に戻すこと。 機能を書いた本人が書いた
単体テスト 7 本は、その本人が想像した 3 行のプログラムしか見ません。--decls の出力
が元のファイルに貼り戻せることを、まったく別の理由で書かれたプログラム群に対して
要求したら、1 回の実行で欠陥が 4 つ出ました。