デバッグに便利なassertとは

さてと。ここまではデバッグコードでバグを検出する練習をしてきたわけだけれど……
はい。
デバッグコードの書き方には、だいたいのパターンがあることが分かったんじゃないかしら。
ざっと抜き出すと、こんなパターンになってるはず。
#ifndef NDEBUG
if (満たすべき条件) {
  ; /* OK */
} else {
  printf("NG:%s (%d)\n", __FILE__, __LINE__);
  abort();
}
#endif
そうですね。けっこう同じような形に収まるんだなって分かりました。
デバッグコードごとに大きく変わってくるのは、if文の条件式の部分ね。
そうみたいですね。
ここまでパターンが決まってくると、いつも同じように書くのはメンドウだと思わない?
実はそれ、ちょっと思ってました。
というわけで、assertの出番よ。assertを使えば、同じ目的のデバッグコードをもっと短く書けるの。
こんな感じで。
assert(満たすべき条件);
なんとっ!1行にできちゃうんですか?
そうなの。デバッグコードごとに変化する、条件式の部分に注目した書き方ができるってわけ。
いつも同じになっちゃう部分は、これで省略できるってことですかね。
うん。これならデバッグコードをどんどん増やしても、プログラムを見やすい状態に保てそうでしょう?
そうですねー。すごくいいかも!

デバッグコードはassertでコンパクトになる

assertは、「明言する」や「主張する」などの意味をもつ言葉です。プログラミングでは条件式を明記して、「ここでは、この条件が成立するのだ」と主張します。もし、条件に合わない状況に遭遇したら、それはバグだというわけです。

C言語では、次のようにインクルードを1つ書くだけでassertを使えるようになります。

#include <assert.h>

ここからは、デバッグコードの練習に使ったバブルソートの関数を題材にして、引き続きassertの練習をしていきましょう。関数は次のようなものでした。

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

  /* 引数をチェック */
  #ifndef NDEBUG
  if (pNumbers) {
    ; /* OK */
  } else {
    printf("NG:%s (%d)\n", __FILE__, __LINE__);
    abort();
  }
  #endif

  /* バブルソート */
  int moves = -1;
  while (moves != 0) {
    moves = 0;

    for (size_t i=1; i<numberCount; i++) {
      if (pNumbers[i - 1] > pNumbers[i]) {
        int temp = pNumbers[i - 1];
        pNumbers[i - 1] = pNumbers[i];
        pNumbers[i] = temp;

        moves += 1;
      }
    }
  }

  /* 結果をチェック(配列の要素が小さい順に並んでいるか確認する) */
  #ifndef NDEBUG
  for (size_t i=1; i<numberCount; i++) {
    if (pNumbers[i - 1] <= pNumbers[i]) {
      ; /* OK */
    } else {
      printf("NG:%s (%d)\n", __FILE__, __LINE__);
      abort();
    }
  }
  #endif
}
それじゃあ、ここまでに書いたデバッグコードをassertに置き換えてみましょうか。
はい。この関数内にある、2つのデバッグコードですね。
1つ目は、引数のチェックをしているところね。
えっと、ここだな……
  /* 引数をチェック */
  #ifndef NDEBUG
  if (pNumbers) {
    ; /* OK */
  } else {
    printf("NG:%s (%d)\n", __FILE__, __LINE__);
    abort();
  }
  #endif
たしかに、完全にパターンどおりですね。
そうね。条件式に注目してassertで書くと……
あ、ちょっと待ってくださいね。
ええと、こうかなぁ……
  /* 引数をチェック */
  #ifndef NDEBUG
  assert(pNumbers);
  #endif
おしい!#ifndefも要らなくなるのよ。
ええっ?それじゃあ、これでいいの……かな?
  /* 引数をチェック */
  assert(pNumbers);
うん、正解!じゃ、2つ目はどうかしら?ソートの結果をチェックしている場所よ。
結果のチェック……と。
  /* 結果をチェック(配列の要素が小さい順に並んでいるか確認する) */
  #ifndef NDEBUG
  for (size_t i=1; i<numberCount; i++) {
    if (pNumbers[i - 1] <= pNumbers[i]) {
      ; /* OK */
    } else {
      printf("NG:%s (%d)\n", __FILE__, __LINE__);
      abort();
    }
  }
  #endif
こっちは、ちょっとパターンと違いますね。なんとなくでよければ、assertにできそうな気がしますけど。
あはは。なんとなくじゃなくて、内側にも#ifndef NDEBUGがあると考えてみて。
つまり、こんな感じ。
  /* 結果をチェック(配列の要素が小さい順に並んでいるか確認する) */
  #ifndef NDEBUG
  for (size_t i=1; i<numberCount; i++) {

    #ifndef NDEBUG
    if (pNumbers[i - 1] <= pNumbers[i]) {
      ; /* OK */
    } else {
      printf("NG:%s (%d)\n", __FILE__, __LINE__);
      abort();
    }
    #endif

  }
  #endif
なるほど!そう考えればパターンどおりなんですね。
じゃあ、さっきと同じようにassertに置き換えられるから……
  /* 結果をチェック(配列の要素が小さい順に並んでいるか確認する) */
  #ifndef NDEBUG
  for (size_t i=1; i<numberCount; i++) {
    assert(pNumbers[i - 1] <= pNumbers[i]);
  }
  #endif
うん、正解よ!
やったー!でも、全部のデバッグコードがassertで1行にまとまるわけじゃないんですね。
そうね。ここでは配列の各要素をチェックしたいから、forループは残しておかないとね。
ふむふむ。
で、このループは開発中にだけ使うものだから、#ifndef NDEBUGもそのまま残すわけ。
デバッグコードは、リリース後には必要ないってことですね。
そういうことね!これは、デバッグコードの基本が分かっているからこそ、できる書き方なのよ。

ここまでで、2つのデバッグコードがコンパクトになりました。関数がどうなったか、全体を確認してみましょう。なお、1つ目のassertの前にあった「引数をチェック」というコメントは、見れば分かるので削除しました。

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

  /* バブルソート */
  int moves = -1;
  while (moves != 0) {
    moves = 0;

    for (size_t i=1; i<numberCount; i++) {
      if (pNumbers[i - 1] > pNumbers[i]) {
        int temp = pNumbers[i - 1];
        pNumbers[i - 1] = pNumbers[i];
        pNumbers[i] = temp;

        moves += 1;
      }
    }
  }

  /* 結果をチェック(配列の要素が小さい順に並んでいるか確認する) */
  #ifndef NDEBUG
  for (size_t i=1; i<numberCount; i++) {
    assert(pNumbers[i - 1] <= pNumbers[i]);
  }
  #endif
}

全体的に、見通しのよいソースコードになったのではないでしょうか。このように、assertを使うとデバッグコードが書きやすくなり、また読みやすくもなります。

ところでassertの正体とは?

あの、assertの使い方は分かったんですけど……
疑問があるのね?
はい。assertって、どういう仕組みになってるんですか?
あー、そっかそっか。たしかに、ちょっと得体が知れないところがあるわよね。

assertには、以下の性質があります。

  • 条件が成立しないときは、ソースファイル名と行番号を表示して異常終了する
  • ただし開発中にだけ有効で、リリース後は無視される

この性質は、次のように書いたassertでも……

assert(満たすべき条件);

または、次のようなassertに置き換える前のデバッグコードでも同じです。

#ifndef NDEBUG
if (満たすべき条件) {
  ; /* OK */
} else {
  printf("NG:%s (%d)\n", __FILE__, __LINE__);
  abort();
}
#endif

では、assertの中身は、具体的にどのような仕組みになっているのでしょうか?これは、標準ライブラリの開発元それぞれがassertをどのように実装したかという話なので、決まった答えはありません。

でも、assertと同じものを自作することはできます。ここでは、自分でassertを定義する過程を通して、C言語が標準で備えているassertの仕組みを理解していくことにしましょう。

まず、assertに置き換える前のデバッグコードはお決まりのパターンなので、中身を関数に置き換えることにします。

#ifndef NDEBUG
DoAssert(満たすべき条件, __FILE__, __LINE__);
#endif

ここで、DoAssert()は自分で次のように作った関数です。呼び出す必要があるのは開発中のみなので、こちらも#ifndef NDEBUG#endifで囲みました。

#ifndef NDEBUG
void DoAssert(bool bCondition, const char *pFile, int line ) {
  if (bCondition) {
    ;
  }
  else {
    printf("NG:%s (%d)\n", pFile, line);
    abort();
  }
}
#endif

次に、デバッグコードを次のように変形させることを考えましょう。

#ifndef NDEBUG
assert(満たすべき条件);
#endif

これは、関数DoAssert()の呼び出しに置き換わる、次のようなassert()マクロを定義すれば可能です。

#ifndef NDEBUG
  #define assert(C) DoAssert((C),__FILE__,__LINE__)
#endif

最後に、デバッグコードで#ifndef NDEBUGを省略できるようにします。

assert(満たすべき条件);

これは、次のようにassert()マクロを改良すればできますね。開発中はDoAssert()を呼び出しますが、リリース後は無視されるようにしました。

#ifndef NDEBUG
  #define assert(C) DoAssert((C),__FILE__,__LINE__)
#else
  #define assert(C)
#endif

というように、assertはマクロとして自作することができます。標準ライブラリに含まれるassertも、おおよそ同じような仕組みだと考えていいでしょう。もちろん、具体的な実装はこれとは異なりますが、マクロでできているという点は同じです。

えっと。つまり……assertの正体はマクロだったんですね。
そのとおり!NDEBUGが未定義のとき、つまり開発中は、assertの部分が有効なデバッグコードに展開されるわけね。
ふむふむ。
で、反対にNDEBUGが定義されているとき、つまりリリース用にコンパイルしたときは……
assertの部分は「なかったこと」になると。
そういうこと。assertは、よくあるデバッグコードを短く書けるようにしたマクロだってことが分かったんじゃないかしら?
なるほどー。そういう仕組みだったんですね!
ここがポイント!
assertは、よくあるデバッグコードを書きやすくしてくれる仕組みです。

assertの力を借りて実装を置き換えてみよう

では、ソートする関数の例に戻りましょう。ここまでで、次の2カ所にデバッグコードを挿入しました。

  • 関数が呼ばれた直後:引数をチェックしている
  • 関数が終了する直前:ソートの実行結果をチェックしている

つまり、関数の入り口と出口でバグを検出しています。ということは、その間の実装を手直ししたとき、何か間違えてもすぐに気付ける可能性が高いといえます。

ここ重要なとこなんだけど、分かるかしら?
えっと……なんか重要そうだなぁっていう雰囲気は分かります!
あはは。実装を改良するときに、安心して作業できるっていうことよ。
改良って、ソートのやり方を直すってことですか?
そうね。例えば、今のバブルソートの実装は、チューニングすればもう少し高速になるはずよ。
そっか!そのチューニングで何か間違えたときは、プログラムを動かしてみればassertに引っかかるってことですね。
その可能性が高いわね。だから、デバッグコードがない場合に比べると、かなり安心感があるわけ。
安心できるって、いいですねぇ。
でしょー。さらに、ソートのアルゴリズムを別のものに置き換える作業も、比較的安全にできるわね。
えっ!バブルソートを別のソートに変えるってことですか?
そうよ。例えば、もっと高速な「マージソート」や「クイックソート」に作り直すとかね。
な、なるほど……
ふふふ。バグに対処するには「仕組み」が大事っていう話も、そろそろリアルになってきたんじゃないかしら。
はい。だいぶ分かってきた気がします!
じゃあ、あとは慣れるだけね。どんどん練習してみるのがいいと思うわ。

では、練習問題をやってみましょう!

今、ソートする関数の中身はバブルソートで実装されています。これを、何か別のアルゴリズムに置き換えてみてください。目標は、次の2点です。

  • 今よりも高速なソートにすること
  • ソートの結果が、今の実装から変わらないこと

参考として、「マージソート」に置き換える場合の解答例を以下に掲載しておきます。マージソートとは、配列を左右半分ずつに分けてそれぞれをソートしたあと、再び1つの配列に融合(マージ)させる手法です。

main.c
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

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

  /* マージソート */
  if (numberCount >= 2) {

    /* 左半分をソート */
    size_t leftNumberCount = numberCount / 2;
    SortNumbers(pNumbers, leftNumberCount);

    /* 右半分をソート */
    size_t righNumberCount = numberCount - leftNumberCount;
    SortNumbers(pNumbers + leftNumberCount, righNumberCount);

    /* 左右をマージ */
    size_t sortedNumberCount = 0;
    while ((leftNumberCount > 0) && (righNumberCount > 0)) {
      if (pNumbers[sortedNumberCount] <= pNumbers[sortedNumberCount + leftNumberCount]) {
        leftNumberCount -= 1;
      } else {
        int headNumber = pNumbers[sortedNumberCount + leftNumberCount];
        for (size_t i=leftNumberCount; i>0; i--) {
          pNumbers[sortedNumberCount + i] = pNumbers[sortedNumberCount + i - 1];
        }
        pNumbers[sortedNumberCount] = headNumber;

        righNumberCount -= 1;
      }

      sortedNumberCount += 1;
    }
  }

  /* 結果をチェック(配列の要素が小さい順に並んでいるか確認する) */
  #ifndef NDEBUG
  for (size_t i=1; i<numberCount; i++) {
    assert(pNumbers[i - 1] <= pNumbers[i]);
  }
  #endif
}

int main(void) {

  /* ソートを実行 */
  int numbers[] = {56, 0, -34, 2, 976, 2, 5, 55, -20, 48, 16};
  size_t count = sizeof(numbers) / sizeof(int);
  SortNumbers(numbers, count);

  return EXIT_SUCCESS;
}

assertでデバッグの「苦しいところ」を減らそう

ふぅ……ソートって難しいなぁ。
練習問題をやってみてるのね。調子はどうかしら?
はい。難しいですけど……assertがあるおかげで、なんとかなりそうです!
うんうん。慣れるまでは、しょっちゅうassertにひっかかってしまうと思うけど……
あ、今そんな感じです!
そうよね。でも、悩む時間は減ったんじゃないかしら?
そうなんですよ!たぶん、どこで間違えたのかを調べやすいからだと思います。
すばらしいわね!assertに慣れたら、プログラミングがもっと上達すると思うわよ。
ほんとですか!がんばろー。

assertは、どんどん使ってみるのがおすすめです。はじめのうちは、自分で書いたassertでプログラムが止まってしまうことも多くなるかもしれません。でも、それはプログラムが想定と違う動きをしたということです。つまり、バグが検出されたわけですね。

このとき、「どこから調べればいいのか見当が付かない」ということが起こらないのがassertの特徴です。デバッグのための、あの苦しい時間を減らせるメリットは大きいのではないでしょうか。assertを使い慣れるほど、プログラミングが楽しくなっていくかもしれません!

ここがポイント!
assertを使いこなせば、プログラミングで悩む時間を減らせます。どんどん使って、慣れていきましょう!