デバッグコードとは

それじゃあ、バグを早期発見するには仕組みが重要だと分かったところで、「デバッグコード」のテクニックを覚えていくのだけど……
はい。バグを捕まえる「罠」をプログラムに仕掛けるんでしたよね。
そう。そのためには、「バグとは何か」を知っておかなきゃね。
やだなぁ。それくらいは知ってますよー。
まぁ知ってるわよね。でも、ちゃんと言葉で説明できるかしら?
そうですね、バグとは……プログラムの間違い……とか?
うん。プログラムの間違い、つまり「欠陥」のことね。じゃあ、「欠陥とは何か」って聞かれたらどうする?
えっと。なんとなく分かってはいるつもりなんですけど、どう説明したらいいものか……

あらためて「バグとは何か」を考える

欠陥というのはね、「理想的な状態」と「実際の状態」のギャップのことよ。
ギャップ……ですか?
そう。例えばプログラミングするときは、「どんなプログラムにしたいか」っていうのがあるわよね。
はい。
で、そのとおりに作れれば理想的だけれど、実際には思ったとおりに動かないこともあるでしょう?
ありますねぇ。
それがプログラムの欠陥、つまり「バグ」ね。

「理想的な状態」と「実際の状態」のギャップ(差)のことを「欠陥」といいます。欠陥は、ソフトウェア開発のいろいろな場面にみられるものです。プログラムに限らず仕様書や設計書などのドキュメント、あるいは開発計画などに欠陥が見つかることもあるでしょう。

「バグ」というのは、おもにプログラムの欠陥のことを指す比喩的な表現です。「欠陥」という言葉には少しキツイ印象があるので、「仕様のバグだね」なんていう言い方をすることもありますが。もともとは、コンピューターに「虫(bug)」が入り込んで悪さをしているせいで、プログラムの動作がおかしくなってしまったという意味です。

プログラムがおかしいのを虫のせいにしたくなる気持ち、分かるなぁ……
そうよねー。でも結局、バグは自分の力で直すしかないわね。
はい。
そのためには「理想的な状態」、つまりどんなプログラムを作ろうとしてたのかを認識できるかどうかがカギなのよ。
ええと……そっか。何を作ろうとしてたのかが分からなかったら、直しようがないですもんね。
そういうことね。

テストは正常に動作していることを確認する作業

じゃあ、次はテストについて考えましょうか。
あのー、デバッグコードの話をしていたのでは?
まぁまぁ。これもデバッグコードについて理解するためなのよ。
そうなんですね。
というわけで、「テストとは何か」を言葉で説明できるかしら?
えっと……動作確認のこと……ですかね。
うん。プログラムを動作させてみて、正常かどうかを確認するのよね。

テストとは、作ったプログラムを実際に動作させてみて、正常に振る舞うかどうかをチェックする作業のことです。これは、プログラムが「理想的な状態」であることを確認しているのだといえます。

そのためには、「どんなプログラムを作ろうとしていたか」を認識することが重要です(バグを直すときも、そうでしたね)。この認識がなければ、そもそも何をテストすればいいのか分かりません。

テストっていうのは、プログラムが正常だと確認するためのものなの。バグを見つけることが目的ではないっていうのを意識しておくといいわね。
えっ?だって、テストしたらデバッグしますよね?
それはテストに「不合格」だった場合ね。デバッグしたらまたテストして、直ったかどうか確認するでしょう?
はい。
つまり、テストではいつも、動作が正常なことを確認しているのよ。確認が取れたら「合格」になって、テスト完了というわけね。
なるほど、分かったような気がします……けど、いまいち現実のプログラミングと結び付かないというか……
じゃあ、もう少し具合的な例を見てみましょうか。

例として、整数の配列を昇順(小さい順)にソートする関数があるものとしましょう。

/* 整数の配列を小さい順にソートする。
 * - pNumbers: ソートする配列の先頭アドレス
 * - numberCount: ソートする要素数
 * pNumbersが0(NULL)の場合の動作は未定義。
 */
void SortNumbers(int *pNumbers, size_t numberCount) {

  /* ここに、ソートの実装 */

}

この関数が何をするためのものなのかは、コメントに書かれています。具体的には、次のようなことを理解できるでしょう。

  • 整数の配列pNumbersと要素数numberCountを渡すと、配列の要素がソートされる
  • pNumbersに0(NULL)を指定する呼び出し方は禁止

この関数をテストするためのテストコード、つまり単体テスト(ユニットテスト)は、どのように書けるでしょうか。

関数がコメントのとおりに動くか確認したいっていうことよ。
ふむふむ。それが正常な動作だってことですね。
そういうこと。それが、この関数の仕様だからね。
そっか!仕様ですね。
ん?
つまり、プログラムのテストって、仕様どおりに動作するか確認することなんじゃないかなって。
うんうん。仕様が明記されているなら、そのとおりよ。関数の仕様は今回みたいにコメントに書かれていることもあれば、仕様書になっている場合もあるわね。
やっぱり!
反対に、仕様がよく分からない関数の場合は、何が正常な動作なのか不明だから……
テストできないってわけですね。
うん、そういうこと。それじゃあ、今回の関数はテストできそうね。

では、具体的なテストコードについて考えていきましょう。

まず、実際にプログラムを動かしてソートの動作を確認するために、テストデータとして「ソート前の配列」を用意する必要があります。例えば、次のようなものです。

  int numbers[] = {56, 0, -34, 2, 976, 2, 5, 55, -20, 48, 16};

これをソートした結果が正しいかどうかをチェックするには、「ソート後の配列」も必要です。次のような、ソートの「正解」を表すデータです。

  const int expectedNumbers[] = {-34, -20, 0, 2, 2, 5, 16, 48, 55, 56, 976};

numbersを実際にソートして、結果がexpectedNumbersと等しくなっていることをチェックすれば、テストに合格したといえるでしょう。そのためのテストコードは、ざっと次のような形になります。

  int numbers[] = {56, 0, -34, 2, 976, 2, 5, 55, -20, 48, 16};
  size_t count = sizeof(numbers) / sizeof(int);
  SortNumbers(numbers, count);

  const int expectedNumbers[] = {-34, -20, 0, 2, 2, 5, 16, 48, 55, 56, 976};
  for (size_t i=0; i<count; i++) {
    if (numbers[i] == expectedNumbers[i]) {
      ; /* OK */
    } else {
      ; /* NG(期待どおりにソートできていない) */
    }
  }

このようなテストを実施すれば、関数がだいたい期待どおりに動作していることを確認できます。

さてさて。テストコードについて考えてみたわけだけど、何か感じたことはあったかしら?
はい。具体的なイメージはわきました!でも……
でも?
なんていうか、これでテスト足りてます?
足りてないわ。ソートする関数が入力できる配列のパターンは、もっとたくさんあるもの。

関数が受け付けられる配列のパターンは無数にあるにもかかわらず、上記のテストでは1通りのテストデータでしか動作を確認していません。かといって、テストデータをいくら増やしても、あらゆるパターンをテストするのは不可能です。テストには、限界があるのです。

そこで、どのようなパターンでテストすれば、自信をもって「正しく動作している」といえそうか考える必要があります。今回のテスト対象はソートする関数ですが、ここではデータ数が1のパターンと2のパターンが要注意だと分かるでしょうか。

  • データ数が2以上のときは、配列の中身がソートされる
  • データ数が1以下のときは、ソートしても配列の中身は変化しない

このように、データ数1と2の間に、関数の動作が変化する境目があるのです。こうした境目の値のことを「境界値」といいます。境界値の直前と直後の値で動作を確認すれば、より信頼性の高いテストになるでしょう。

また、さらに極端なケースとして、配列が空のパターンも考えられます。以下の動作についても、併せてチェックしておくとベターです。

  • たとえデータ数が0でも、問題なくソートできる(配列の中身は変化しない)
なるほど。どんなパターンでテストするかが重要なんですね!
そうね。テストのパターンは多いほうが安心できそうな気がするけど、闇雲に増やせばいいってものでもないのよ。
でも、よく考えて重要なパターンをテストできたら、それで十分なんじゃないですか?結局、デバッグコードは要らないような気がしてきましたけど……
じゃあ、関数のコメントをもう一度見てみましょうか。まだテストできていないところがあるのだけど、分かるかしら?
え?まだあるんですか?
えっと。コメントに書かれてたことは……
/* 整数の配列を小さい順にソートする。
 * - pNumbers: ソートする配列の先頭アドレス
 * - numberCount: ソートする要素数
 * pNumbersが0(NULL)の場合の動作は未定義。
 */
void SortNumbers(int *pNumbers, size_t numberCount) {

  /* ここに、ソートの実装 */

}
分かった!pNumbersに0(NULL)を指定したときの動作をテストしてないです!
そのとおり。でも、これはテストできないの。
そうなんですか?
テストは、プログラムの正常な動作を確認するものだったわよね。
はい、そうでした。
で、pNumbersを0(NULL)にして呼び出すことは禁止されているわけだから、正常な動作ではないわね。
そうですね……
つまり、そういう呼び出し方をしたらバグだということね。

繰り返しになりますが、テストとはプログラムが正常だと確認することです。「この場合は、このように振舞うべき」というような「正解」が決まっているパターンだけがテスト可能だといえます。

これに対して、バグはそもそも正常ではない動作です。どのような場合に、どう振舞うかを決めておけないため、テストのパターンとして表現することができません。この点も、テストの限界だといえるでしょう。

例えば今回の関数では、pNumbersに0(NULL)を指定して呼び出したとすると、それは呼び出し側のバグということになります。これをテストのパターンとするには、誤った呼び出し方をテストコードに加えるしかありません。また、その場合の正常な動作も不明です。結果として、ただテストにバグを埋め込んだだけになってしまうのです。

ここがポイント!
テストとは、プログラムの動作が正常だと確認することです。ただし、テスト可能なパターンには限界があるため、完璧を求めることはできません。
なるほどー。テストに限界があるのは理解できたんですけど、それだと、ちょっと不安が残りませんか?
そうよね。そこで使えるのが、デバッグコードのテクニックなのよ。
そうなんですね!わくわく。

デバッグコードはバグを検出するための仕組み

デバッグコードとは、バグを捕まえるためにプログラム中に仕込む「罠」のことでした。デバッグコードを書く動機は、テストとは反対のものだといえます。

  • テストを実施する動機:動作が正常であることを確認したい
  • デバッグコードを書く動機:バグを積極的に検出したい

動機が異なる以上、「テストとデバッグコードはどちらが優れているか」という話ではありません。これら2つを組み合わせることで、最終的にバグのないプログラムにできる可能性が高まると考えましょう。

テストだけでは限界だったところを、デバッグコードで補おうということよ。
ふむふむ。じゃあ、テストコードを書くみたいに、関数をチェックするコードを書くんですか?
ちょっと違うわ。デバッグコードはね、作っているプログラム自体に埋め込むのよ。

では、先ほどのソートする関数に、デバッグコードを追加してみましょう。ここでのポイントは、次の2つです。

  • 関数が予想外の使われ方をしていたら、呼び出し側のバグ
  • 関数の実行結果が期待と違ったら、この関数自体のバグ

関数の使われ方をチェック

関数が予想外の使われ方をしていないかどうか確認するには、引数のチェックが基本です。次のように、関数の入り口にチェックを入れるのがいいでしょう。

/* 整数の配列を小さい順にソートする。
 * - pNumbers: ソートする配列の先頭アドレス
 * - numberCount: ソートする要素数
 * pNumbersが0(NULL)の場合の動作は未定義。
 */
void SortNumbers(int *pNumbers, size_t numberCount) {

  /* 引数をチェック */
  if (pNumbers) {
    ; /* OK */
  } else {
    ; /* NG(pNumbersが0になっている) */
  }

  ……
}

ここではpNumbersに0(NULL)を指定する使い方はNGなので、それをif文でチェックしています。これで、この関数が呼び出されるときには常に、デバッグコードがはたらきます。

これでテストでは不可能だった、呼び出し側のバグも検出できるようになったわね。
なんだか、意外に簡単ですね。もっと難しいものを想像してましたけど……
簡単なほうがいいのよ。デバッグコードのデバッグをするのは嫌でしょう?
たしかにっ!

ここで、if文には「正常ならこう」という条件式が書かれているのが分かるでしょうか。バグを検出するといっても、実際にできるのは正常な動作の確認だけだという点は、テストと変わりがありません。

では、テストと何が違うのかといえば、チェックが関数の内部ではたらくという点です。これにより、呼び出し側の正常ではない振る舞いも、いとも簡単に検出できるのです。

関数の実行結果をチェック

次は実行結果をチェックしましょう。今回は次のように、配列をソートしたあと、関数を抜ける前にチェックを入れればいいでしょう。

/* 整数の配列を小さい順にソートする。
 * - pNumbers: ソートする配列の先頭アドレス
 * - numberCount: ソートする要素数
 * pNumbersが0(NULL)の場合の動作は未定義。
 */
void SortNumbers(int *pNumbers, size_t numberCount) {
  ……

  /* 結果をチェック(配列の要素が小さい順に並んでいるか確認する) */
  for (size_t i=1; i<numberCount; i++) {
    if (pNumbers[i - 1] <= pNumbers[i]) {
      ; /* OK */
    } else {
      ; /* NG(期待どおりにソートできていない) */
    }
  }

}

ここでは、ソート後の配列が、本当に昇順に並んでいるかどうかをチェックしています。

関数が受け付けられる配列のパターンは無数にあるため、テストだけではテストデータを用意するのも限界があったことを思い出しましょう。デバッグコードも、すべてのパターンをチェックできるわけではないのは同じです。しかし、デバッグコードは実際に関数が呼び出された際のデータすべてに対してはたらくので、より多くのパターンをチェックできます。

常に実データに対してチェックが働くところが、合理的よね。
ほんとですね。なんだか、今度はテストのほうが要らないんじゃないかって思えてきました。
その気持ちは理解できるわ。でも、やっぱりテストとデバッグコードは別物なのよ。
そうなんですか?
例えばね、今回のデバッグコードは、簡易的なチェックしかしていないのが分かるかしら。

上の例は、隣り合う要素の大小関係をチェックするだけの簡易的なチェックになっているのが分かるでしょうか。これでは、ソート後のデータを厳密に検証したことにはなりません。例えば、すべてのデータが同じ値に書き換えられてしまった場合でも、正しくソートできたとみなしてしまいます。

とはいえ、このチェックをすり抜けてしまうような「でたらめ」な状況には、デバッグコードがなくても気付けるでしょう。とくに、テストと併用しているなら確実です。

デバッグコードの狙いはバグの検出だから、「正常かどうか」を厳密にチェックしようとするのは力の入れ過ぎといえるわね。
なるほど。「正常かどうか」のチェックはテストでやろうってことですね。
うん。だいたいの場合は、そう考えるのが生産的だと思うわ。
ここがポイント!
バグを検出するためにデバッグコードを書き、動作が正常か確認するためにテストを実施しましょう。2つを組み合わせることで、バグのないプログラムにできる可能性が高まります!
でも、やっぱりデバッグコードで「正常かどうか」を厳密にチェックをしたほうが安心ってこともありません?
あるわね。ちょっと処理が複雑で、「ここはバグが集中しそうだなぁ」って感じるときとかね。
そう考えればいいんですね。えっと……僕にとっては今回のソートが、まさにそうなんですけど。
そっかそっか。その場合は、標準関数のqsortと比較するのがいいわ。
え?ソートする関数が最初からあるってことですか?
そう。汎用性の高い、C言語に標準搭載の関数よ。

ここで補足。もしかすると、「標準関数でソートできるのなら、ソートを自作する意味はないのでは」と思った人もいるかもしれません。

でも、ソートのアルゴリズムには、さまざまな種類があります。また、それぞれ特徴が異なります。

  • アルゴリズムがシンプルか
  • データの個数が増えたとき、ソートにかかる時間はどう変わるか
  • ソートするとき、どれくらいのメモリーを使用するか
  • などなど……

目的や状況に合わせて、アルゴリズムを選びたい場面もあるでしょう。そのため、標準関数を採用せずに、ソートする関数を自作するというのは実際にもあることです。

今回の例は、標準関数のqsortを採用せずに自作するケースになってたんですね。
うん。で、自分でソートした結果はqsortと同じになるのが正しいわけ。
ふむふむ。「qsortと比較する」っていうのは、両方の結果が同じかどうかチェックするっていうことなんですか?
そういうこと!

バグの発生箇所は、プログラム内の特定の範囲に集中しやすい傾向があるといわれています。「バグが集中しそう」と感じたときは、より厳密なチェックをするデバッグコードを書いておくと安心です。

そのためには、別のアルゴリズムを用いたときの結果と照合する方法が考えられます。今回はソートする関数なので、標準関数のqsortが使えます。自分でソートした結果が、qsortでソートした結果と完全に一致することを確認すればいいでしょう。

デバッグコードを実戦で使うには?

デバッグコードの基本的な考え方は分かったかしら。
はい!ていうか、もう具体的な書き方まで分かったと思いますけど……
あー、待って待って。実際の開発に使うテクニックとしては、もうちょっと押さえておきたいことがあるの。
そうなんですか?
そうなのよ。開発中は、デバッグコードでバグを早く発見できたほうが嬉しいわよね。
はい。
つまり、「バグがあったぞー!」っていうのが目立つようにしたいわけ。それが、押さえておきたい1つ目ね。
なるほど。もう1つあるんですか?
うん。その「バグがあったぞー!」は、リリース後には大きな声で言いたくないじゃない?それが2つ目。
ほんとだ!え……そんなことできます?
もちろん!というわけでね、次は実際に使えるテクニックとして、デバッグコードを練習しましょうか。
はーい!