無い機能が隠すもの — 1 つの構造的な穴の下にいた、ごく普通のバグ 2 つ
mere-ruby の IO は弱く、理由は明らかに見えた。この処理系の File はファイル記述子を持たない。記述子を入れようとしていた。先に測ったら、記述子が動かせるのは 35 行のうち 8 行だと分かり、代わりにやった作業が、その構造的な説明に覆い隠されていた素のバグを 2 つ掘り出した。作業の順序が「何を見つけられるか」を決めるという話。
mere-ruby は、自作の小さな ML 系言語 Mere で書いた Ruby サブセット処理系だ。
適合性の記録はリポジトリに入った表で、ruby/spec の全群を本物の ruby と
バイト単位で突き合わせている。
core/io はその表で最も赤字の大きい群のひとつだった — 81 ファイル中 45 が一致、
35 が食い違う。理由は決着済みに見えたし、プロジェクト自身のギャップ一覧にも
書いてあった。ここの File はパスとモードとバッファでできていて、
ファイル記述子を持たない。#fileno は適当な番号を返さず、拒否して理由を言う。
これは本物の制約で、多くを説明する。だから次にやるべき仕事は明らかに見えた — 処理系に本物の記述子を持たせる。ちゃんと open して、オフセットを持って、 その上に IO の表面を作る。数日の、構造的で、やりがいのある作業だ。
先に測った。測定は別のことを言った。
記述子が実際に買うもの
この処理系が答えられない IO のメソッドは 43 個ある。これを 1 つの問いで割った — これは記述子が要るのか、それともパスと位置だけで足りるのか。
記述子が要る 19 advise autoclose? close_on_exec? fcntl fdatasync
for_fd fsync ioctl pid popen read_nonblock sysopen
sysseek timeout to_i wait_priority wait_readable
wait_writable write_nonblock
記述子は不要 24 binmode binmode? close_read close_write copy_stream
eof eof? lineno open pos pread printf pwrite reopen
rewind seek set_encoding set_encoding_by_bom stat
syswrite tell to_io try_convert ungetbyte ungetc
43 のうち 24 は、処理系が既に持っているもので表現できる。そして失敗している 35 ファイルの内訳も同じ形をしていた — 15 はメソッドが無い、8 は 「記述子が無い」という明示的な拒否。記述子が動かすのは 8 行前後だ。
対して、記述子はモデルを変える。今の File はパスとバッファで、読み書きの経路は すべてそう書かれている。記述子と全文バッファは同居できない — 一方を入れることは 他方を書き直すことだ。8 行を動かすために数日かけ、終わった時点で残り 24 個の 名前はまだ書かれていない。
だから 24 個を先にやった。大きいからではなく、何も決めなくていい部分だったからだ。
その下にいた 2 つのバグ
1 バイトずつ読むメソッド群を書くというのは、read(1) をループで呼ぶということだ。
最初に書いた 1 本が、ファイル全体を返した。
f = File.open("/tmp/ten-bytes.txt")
f.read(3) # "abcdefghij" ← 全バイト
f.pos # 10
f.read(3) # ""
File#read は長さ引数を無視していた。ファイル全体を読み、位置を末尾に進め、
次の読み出しには何も残っていない。分割して読むコード — つまり読む
コードのほとんど — は、巨大な 1 塊とその後の EOF しか見ていなかった。
クラスメソッド側も同じ形だった。
File.binread(path, 3, 2) # "abcdefghij"、ruby は "cde"
どちらも長くそこにあった。どちらも直して、このプロジェクトが全てのオラクルに している参照実装と突き合わせ済みだ。
どちらもファイル記述子とは何の関係もない。
なぜ見えなかったのか
ここが残したい部分だ。
構造的な説明は心地よい。「IO が弱いのは記述子が無いからだ」は真で、具体的で、 失敗の形を説明する。いったんそれを手にすると、人は探すのをやめる — 意図的にではなく、問いが答え済みに感じられるからだ。その群が悪いのは、 もう知っている理由による、と。
しかし read が長さを無視していたことは、その理由ではない。同じ群の中に、
同じ見出しの後ろに座っていて、見出しがそれを庇っていた。
そして作業の順序が、見つけられるかどうかを決める。もし記述子を先に作っていたら、
read(len) はその作業の一部として書き直されていた。バグは数百行の差分の中に
消えていた — たぶん直ってはいるが、見られることはない。長さ引数がずっと
無視されていたという事実を私は学ばず、改善を記述子の手柄だと思っただろう。
それが修正と発見の違いだ。修正はコードを変える。発見はコードについての 自分の信念を変え、その信念こそが次の作業を選ぶのに使うものだ。
一般化した形
いまはこう考えている — 無い機能は影であり、普通のバグはその中に座っている。
影を落とすのは、人がそのコードに到達するのを止めるもの全部だ。
- 存在しないクラス。その後ろにあるものは一度も実行されない
NotImplementedErrorを投げるメソッド。その呼び出し先は決して走らない- 「無い」と皆が知っている機能。その領域は報告すらされない
呼び出し側が入れない間、中にあるものは測られていない。扉が開いた瞬間、 後ろにあったものが初めてテストされる — そしてその一部は、最初から扉とは 関係なかった。
同じプロジェクトで、これに気づかないまま一度見ていた。gzip ライタに欠けていた メソッドを 1 つ足したら — テストスイートが使うコンストラクタだ — それまで到達不能だった spec が走るようになり、即座にハーネスの CPU 上限に当たった。 圧縮エンジンは 1 MB あたり約 34 秒だと分かった。このコストはずっとそこにあった。 誰も到達していなかったから誰も測っておらず、記録には「メソッドが無い」という きれいなエラーが出ていた。真実は「メソッドが無いかつ性能問題がある」だった。
自分に言うとしたら
3 つ、重要な順に。
症状ではなく、説明の方を測る。 「IO が弱いのは記述子が無いからだ」は 「記述子が何行動かすか」についての主張だ。その数はコードを 1 行も書く前に数えられる。 35 行のうち 8 行だった。
判断が要らない作業を先に取る。 24 個の名前には設計判断が不要だった — 既にあるもので表現できた。記述子は File のモデル全体についての判断を要求する。 塞がれていない作業を先にやるのは先延ばしではない — その判断に必要な測定を 生むのがそれだ。
扉を開けた直後に出てきたものは、ノイズではなく証拠として扱う。 新たに到達可能になった領域が、いきなり無関係な形で失敗したら、それは不運ではない。 影が晴れたのだ。
記述子の判断はまだ開いている。ただ、いまは直感ではなく数字を持って決められるし、 その後ろに隠れているバグは 2 つ減っている。