要約
誤った結果を出すプログラムは、行をでたらめに変えることによってではなく、調べることによって直す。このレッスンは、デバッグを実験的な方法として示す。故障を再現し、その原因について仮説を立て、仮説を確かめるか退ける実験を設計し、欠陥の場所を絞り込むのである。二分探索による絞り込みは、プログラムの大きさの対数のように増える手数で欠陥を見つける。道具(-g を付け最適化なしでのコンパイル、三つのシステムでの Visual Studio Code の設定、エディタが裏で何をしているかを示す GDB の実際のセッション)、単純なブレークポイントと条件付きブレークポイント、ステップ実行、コールスタック、状態の調べ方を、中心となる一つの例で展開する。データの個数が偶数のときだけ誤る中央値である。最後に、道具よりも価値のある戦略(ケースを縮小する、最後の正しい点を探す、assert で不変条件を確かめる、サニタイザのレポートを読む)と、三つの事例を扱う。1 ずれのエラー、終わらない再帰、無効になった参照である。
学習目標
- デバッグ情報付きのコンパイル(
-g、最適化なし)を設定し、自分のシステムで Visual Studio Code からデバッガを起動する。 - 単純なブレークポイントと条件付きブレークポイント、ステップ実行(中に入る、飛び越す、外に出る)、および変数・式・コールスタックの調査を使いこなす。
- 体系的な戦略(再現、縮小、二分、仮説の立案と検証)を適用して与えられた欠陥の場所を突き止め、各仮説を記録する。
- デバッガをサニタイザや自動テストと組み合わせ、レポートからメモリのエラーの場所を突き止める。
デバッグとは調査である
欠陥と故障
故障とは観察されるものである。誤った結果、終わらないプログラム、サニタイザのレポートなどである。欠陥(またはエラー)とはその原因である。仕様に合わない一行、一つの条件、あるいは設計上の決定である。故障は目に見えるが、欠陥は探し出さなければならず、故障から遠く離れていることもある。このことから、故障が見える場所で直すこと、たとえば「いつも 2 だけ多く出る」からといって結果から 2 を引くことは、症状を直して原因を残すことになり、原因は別のデータで再び現れる。
そうであるならば、デバッグとは原因についての調査であり、その方法は実験科学の方法である。
- 観察と再現。故障を正確に記述し(どの入力で、何が期待され、何が得られたか)、望むときにいつでも起こせるようにする。再現できない欠陥は調査できず、直ったことを確かめることもできない。
- 仮説を立てる。原因についての、偽と判明しうる主張である。「ソートがソートしていない」「中央の二つ目のデータの添字が一つ大きい」。
- 実験を設計する。プログラムの一点を選び、仮説が真ならそこでどんな状態が見えるはずか、偽ならどんな状態が見えるはずかを予測する。
- 結論と絞り込み。仮説を退けるか、確かめる。どちらの場合も、疑わしい範囲は小さくなる。範囲が一行になるまでこれを繰り返す。
ただし、この方法には規律が要る。一度に一つのことだけを変え、各仮説をその結果とともに書き留め、何を探しているのかわからないうちはコードに触れない。出力が正しく見えるまで行をでたらめに変えると、欠陥を取り除かないまま故障を隠してしまうことがある。
データのリストの中央値とは、個数が奇数ならソートしたリストの中央のデータであり、偶数なら中央の二つのデータの平均である。レッスン プログラムのテスト では、テストケースは関数がいつ故障するかを明らかにするが、その欠陥がどこにあるかは示さないことを見た。次の関数は、データが五つのときにはテストケースに合格し、四つのときには合格しない。
#include <algorithm>#include <cstddef>#include <print>#include <vector>// データのリストの中央値。// 事前条件:values は空でない。// 事後条件:ソートしたリストの中央のデータ、または中央の二つのデータの平均を返す。double median(std::vector<double> values){ std::ranges::sort(values); const std::size_t n{values.size()}; const std::size_t middle{n / 2}; if (n % 2 == 1) { return values[middle]; } return (values[middle] + values[middle + 1]) / 2.0;}int main(){ const std::vector<double> odd{7.0, 1.0, 5.0, 3.0, 9.0}; const std::vector<double> even{7.0, 1.0, 5.0, 3.0}; std::println("{} の中央値:{}", odd, median(odd)); std::println("{} の中央値:{}", even, median(even));}[7, 1, 5, 3, 9] の中央値:5[7, 1, 5, 3] の中央値:6観察は正確である。 では、ソートすると となり、 が期待されたが、6 が得られた。データが五つなら結果は正しい。少なくとも三つの仮説が考えられる。(H1)ソートがソートしていない。(H2)中央の二つのデータの添字が誤っている。(H3)和か割り算の書き方が誤っている。それぞれが、17 行目を実行する前の状態について異なることを予測する。H1 が真なら、values はソートされていない。H2 が真なら、values[middle] と values[middle + 1] は 3 と 5 ではない。H3 が真なら、二つのデータは 3 と 5 なのに計算結果は 6 になる。奇数の場合が正しく動くことから H1 はありそうにないが、「ありそうにない」は「退けられた」ではない。デバッガを使った実験とは、プログラムを 17 行目で止めて見ることである。
二分探索による絞り込み
はっきりした仮説がないときにも、失敗しない方法が残っている。二分探索による絞り込みである。プログラムの状態は最初は正しく最後には誤っていること、そして途中の各点で状態が正しいかどうかを判定できること(手で計算した値と比べるか、不変条件を確かめるかして)を仮定する。疑わしい範囲の中央で状態を調べる。正しければ欠陥は後半にあり、そうでなければ前半にある。これはレッスン 探索とソート の二分探索を、プログラムの行(あるいはループの周回、あるいはファイルの版)に当てはめたものである。
図は、欠陥が 11 行目にある十六行の二分探索による絞り込みをたどる。各目盛りは、その行の後の状態についての一つの実験であり、結果は正しい(✓)か誤り(✗)である。
証明
最初は十六行すべてが疑わしい。わかっているのは、状態が 1 行目の前では正しく、16 行目の後では誤っていることである。
8 行目の後で状態は正しい。1 行目から 8 行目までは容疑が晴れる。
12 行目の後では状態はすでに誤っている。欠陥は 9 行目から 12 行目までの間にある。
10 行目の後は正しい。残るのは 11 行目と 12 行目である。
11 行目の後は誤り。最後の正しい点と最初の誤った点が隣り合い、欠陥は 11 行目にある。
数え方は二分探索と同じである。各実験が範囲を二等分するので、 回の実験で 行に足りる。千行のプログラムなら十回で足りる。 だからである。ここからこの方法の利点が出てくる。その手間は大きさの対数のように増え、プログラム全体を理解することは求めず、ある一点で状態があるべき姿かどうかを判定できればよい。ただし、その判定こそが難しい部分である。仕様と不変条件が、途中の各状態を判断する基準となる。
コンテンツを読み込んでいます…