ホーム < ゲームつくろー! < デバッグ技術編

その4 デバッグは「問題を簡単にして境界線を出す」のが大基本!



 マルペケの質問箱に頂いている質問の中で、たまに問題を難しいまま解こうとして難儀されている方がいらっしゃいます。これはデバッグの基本である「問題を簡単にして境界を出す」ができていません。問題を簡単にするほどデバッグ作業で間違いを見つける速度は格段に向上します。いたずらに数値を追う本丸な攻めをする前に、外堀を埋めてバグを弱体化するのです。



@ 問題を簡単にするとは?

 例えば、次のようなシーザー暗号を作成したり戻したりする関数を作ったとしましょう:

void convertStr(char* ioStr) {
    // 入力文字列の文字を3だけ進める
    int len = strlen(ioStr);
    for (int i = 0; i <= len; i++) {
        ioStr[i] += 3;
    }
}

void decryptStr(char* ioStr) {
    // 入力文字列の文字を3だけ戻す
    int len = strlen(ioStr);
    for (int i = 0; i < len; i++) {
        ioStr[i] -= 3;
    }
}

これら関数に文字列を渡せば簡単な暗号・復号ができます。試しに次の英文を暗号化し、元に復号してみました:

int main()
{
    char str[] = "the information, skills, and understanding that you have gained through learning or experience";
    convertStr(str);
    decryptStr(str);
    return 0;
}

さて、これをDebug版で実行するとどうなるか?実はメイン関数が終了する間際にこんなエラーが出ます:

「え〜、何だこりゃ!」とびっくりするわけです。「スタック周りの変数'str'が破損してるよ」と言われています。スタックの意味合いを知っていれば何となく察しが付くのですが、そうでないと何がなにやらです。バグなのは間違いありませんが、ではどうやってバグを見つけるか?ここで考えるのが「問題を簡単にする」です。

 問題を簡単にする方法には色々あります。例えば上の例ですと渡している文字列が長いですよね。このままだとデバッガで追うのも大変ですし、第一暗号化した答えが正しいか確かめるのも一苦労です。問題を簡単にする方法その1は「入力データを把握できるくらい小さくする」です。

 str変数を思い切って「"a"」だけにしてしまいます。これだとシーザー暗号をかければ「"d"」になる事が頭でわかります:

int main()
{
//   char str[] = "the information, skills, and understanding that you have gained through learning or experience";
    char str[] = "a";   // 入力データを把握できるサイズにする。暗号化すれば"d"になるのがわかりますね。
    convertStr(str);
    decryptStr(str);
    return 0;
}

ではこれで実行です…あっと、残念!やはり同じエラーが出てしまいます。どうやら入力に入れた文字列そのものに原因があるのではなく、何か関数内のロジックにおかしな部分があるようです。ここで大切なのは「文字列部分は正しそうだ」という事を突き止めた事です。なぜって、「コード内にある正しい部分以外に間違いがある!」んです。上の例ではstr変数は問題無いと考えられたため、convertStr関数かdecryptStr関数に間違いがありそうだとあたりを付けられたわけです。

 問題を簡単にする方法その2は「正しい部分を増やす」です。今str変数は正しそうである事がすでにわかっています。ではconvertStr関数とdecryptStr関数は正しいのか?それを検証するために、まずconvertStr関数だけ通してみます。

int main()
{
//   char str[] = "the information, skills, and understanding that you have gained through learning or experience";
    char str[] = "a";   // 入力データを把握できるサイズにする。暗号化すれば"d"になるのがわかりますね。
    convertStr(str);
//    decryptStr(str);  // ←とりあえずコメントアウト
    return 0;
}

さて、これでどうでしょう…おっ、今度はエラーが出ません。convertStr関数は少なくともエラーを引き起こしてはいないようです。という事は…よし、今度はdecryptStr関数のみを通します:

int main()
{
//   char str[] = "the information, skills, and understanding that you have gained through learning or experience";
    char str[] = "a";   // 入力データを把握できるサイズにする。暗号化すれば"d"になるのがわかりますね。
//    convertStr(str);  // ←今度はこっちをコメントアウト
    decryptStr(str);
    return 0;
}

さぁ、エラーが…で、出ません。もちろん、コメントアウトを外すとエラーが出ます。「あれ?わ、わかんない!?」ではないんです。ここは、「両関数とも単独ではエラーにならないようだ」と考えます。さて、ではどうするか…です。ここで初めて関数内の挙動に疑いをかけます。

 convertStr関数に渡した文字列はちゃんと3文字先に進んでいるのでしょうか?もう少し正しく捉えると、

a \0
d \0(ナル文字はそのまま)

となっているのでしょうか?この表、実は大切な事を訴えかけています。例えばこれが、

t h e i n f o m a t i o n \0
d k h # l q i r p d w l r q # \0(ナル文字はそのまま)

という検証だったらうんざりしますよね。答えが複雑過ぎるんです。自分が把握できるレベルにまで答えを簡単にすれば、検証は非常に簡単になるんです!

 ここでstr変数を格納しているメモリ領域をメモリウィンドウで観察してデバッグしてみます。入力前は、

と正しく「a、\0」が入っています。ここが「64, 00」になれば正解です。では、convertStr関数を通してみましょう:

あれ!!64は良いのですが、ナル文字\0(00)が03になっています。ナル文字も3文字進められているのがわかります。これは想定した答えと違います!そこで、convertStr関数を見てみると、

void convertStr(char* ioStr) {
    // 入力文字列の文字を3だけ進める
    int len = strlen(ioStr);
    for (int i = 0; i <= len; i++) {   // ←「<=」!!
        ioStr[i] += 3;
    }
}

あ!forループの条件文が「<」ではなくて「<=」になっています!これだと例えば「"a"」という文字列を渡すとlenが1になり、ループが2回まわってしまいます。つまり、終端文字がこの段階で消えてしまいます。ちなみに、これ自体は極めて危ないのですが配列内であるためエラーにはなりません。上のバグを修正します:

void convertStr(char* ioStr) {
    // 入力文字列の文字を3だけ進める
    int len = strlen(ioStr);
    for (int i = 0; i < len; i++) {   // ← ○
        ioStr[i] += 3;
    }
}

もう一度メモリウィンドウを見ると:

はい、想定した答え通りになりました。「答えがわかっているとデバッグ作業は極めて簡単になる!」んです。

 一応このままdecryptStr関数も通してみましょう:

大丈夫ですね(^-^)。これで、すべて想定した動きになってくれました。



A 知ってる答えになるか検証する方が楽

 上の例で肝になっているのは「答えが既知である」という事です。これ、ひじょ〜〜〜に大切なデバッグの考え方です。

 例えば、上の例で「文字列を色々と入れて動くかどうかテストする」という方法も取れます。こういうのは「パターン解析」の一種と言えます。色々なパターンを試してみて傾向を探るというデバッグ方法です。もちろんこういう方法も必要ですが、これは確率的な判断になってしまいます。そうではなくて、もし正しい答えを知っているならば、コードを狭めて正しくならなかった一点を突き詰めれば、あっという間にバグを突き止められます。つまり、「バグの境界線」を高速に割り出せるんです。

 上の例では、まず冗長だった入力文字列を小さくして答えを知りました。次に、両方の関数のいずれかをコメントアウトしてコードを狭めました。さらに、関数の入力と出力を見比べて「正しいかどうか」をチェックしました。これは答えを知っているからこそできるデバッグ方法です。結果として、convertStr関数が間違った挙動をしていたのを突き止めました。


 こういうのは、プログラムのあらゆる所で応用できます。例えば、マルペケの質問箱に非常に多く頂いているのが「スキンメッシュアニメーションがうまくいかない」という質問です。スキンメッシュアニメーションは非常に沢山の情報を複雑に組み合わせて実現します。バグの大好きな環境と言えます。案の定うまく出ないんですね。そこで「うまく出ない〜〜!」と困るのではなくて、問題を簡単にして境界線を出すんです。

 スキンメッシュの出力結果を自分の知る値になるように、メッシュの情報を可能な限り小さく小さくします。それこそ三角ポリゴン2枚を貼り合わせるくらいに簡略化して、ボーンも2本のみにしてしまいます。こうすれば、スキンメッシュに使用される情報の殆どを既知の値として認識出来るようになります。もちろん、答えも難しくなく求められるはずです。それが出来ないというのは、スキンメッシュの仕組みそのものを理解していない事になりますので、まずはそこから勉強しないとバグは取れません。仕組みが理解できれば具体的な答えを出せますから、先の例のように「よし、ここまでのコードは正しい値を出しているな」と、正しく動いている範囲をどんどん広げていく、逆に言えばバグの境界線にどんどん迫る事ができるわけです。そのうちきっと「あ!」という瞬間に出会えます。

 これを発展させていくと「テスト駆動型開発」という開発手法にたどり着きます。それについて詳しくは「バグがないプログラムのつくり方 JavaとEclipseで学ぶTDDテスト駆動開発」が読みやすいかなと思います。

 難しい問題を難しい状態で考える必要はありません。どんどん簡単にして、バグをあぶり出してしまいましょう。