プログラム開発とデバッグ

目次

プログラム開発の技法

「プログラミング」の最終目的は,あからじめ設定した所望の機能(要求仕様という)を実現するソフトウエアを完成させることである. これは「ソフトウエア開発」または「プログラム開発」と呼ばれる.

一般的な工業製品では,「設計」にあたる製図と,「生産」にあたる製造の過程を経るが,ソフトウエアの作成においては,ソースコードの記述である「コーディング」作業が「設計」に相当する.(製造はコンパイラによる実行ファイルの翻訳)
コーディング段階では,最終的にプログラムのソースコードに文法エラーおよび論理エラーなの間違い(バグ)が無い事が要求されるため,デバッグのための技法がいろいろと用意されている.

プログラミング作業を順序立ててシステマティックに行うために,以下のような様々な技法や便利なツールが開発されている.
ここでは,単体ソースファイルのデバッグ法について学ぶ.

ソフトウエアで高度な処理を行おうとすれば,ソースコードの行数や数も増え,その個々の動作チェックやバージョン管理が膨大な作業となる.
例えば,

など,プログラムの規模の増大により問題が劇的に増える.
そのため,ソースコード中に発生した問題箇所を特定し,また,その動作検証をしやすいように,「モジュール化」と呼ばれる手法が取られる.
これは,必要な処理を全部 main() 関数に押し込めて羅列するのではなく,処理をうまく部品(モジュール)に分割し,それぞれを単独で動作検証して,最後に組み合わせる方法である.
C言語では,関数や分割コンパイルなどの手法でこれを実現している.

デバッグ入門

まずは以下のCソースコードを例に,プログラムのデバッグ手法について考えよう.

注:ここでは,完全に修正法を発見できなくても良い.
ひとまずコンパイルエラーが出なくなるところまで,修正してみよう.
(以降,これを例題として,デバッグ法の解説を行う.)

練習問題

例題:1から10までの積(10の階乗, 10! )を計算したい.

以下のように書いた.これでOK? → どこが問題だろうか.
まずは,コンパイルエラーが出なくなるところまで,修正しよう.

#include <stdio.h>

int main(void)
{
    short  f=1;
    for(i = 0; i < 10; i++ ) {
        f *= i;
    }
    printf("Ans = %d\n", f);
    return 0;
}

コンパイラによるエラー出力例
(文法エラーとなる箇所は基本的に各コンパイラで共通である. ただし,コンパイラの種類によりメッセージは異なる)

作業のヒント:コンパイルエラーは,最初に表示される error に注目,その対処をする
修正したら保存・再コンパイルしよう

(gccの場合)

test.cpp:6:9: error: use of undeclared identifier 'i'     <- この修正をしたら保存・コンパイル
    for(i=0; i<10; i++) {
        ^
test.cpp:6:14: error: use of undeclared identifier 'i'
    for(i=0; i<10; i++) {
             ^
test.cpp:6:20: error: use of undeclared identifier 'i'
    for(i=0; i<10; i++) {
                   ^
test.cpp:7:14: error: use of undeclared identifier 'i'
        f *= i;
             ^
4 errors generated.

注:ここでは,コンパイルに成功しても,まだ正しくに動作するプログラムにならなくても良い.次章以降に続く.

デバッグの手順

文法エラー

C言語では,自然言語と違ってその文法ルールが厳密に規格で決められている. そのルールを逸脱している記述に対して,コンパイラはエラーを出力する. これを,文法エラー(コンパイルエラー)という.
(具体例:セミコロンとコロン,カンマとピリオド,かっこ違い・過不足,単語のスペルミスなど)
特に,VSCodeなどの高機能エディタでは,コンパイルするまでもなく,エディタでの編集中に随時,エラー候補が指摘される.

コンパイラの出力するエラー・警告メッセージに対しては,次のように対応しよう.

論理エラー

文法エラーが解消され,コードが(少なくとも)C言語の文法規則に適合しているとして, 次のステップでは,意図したとおりの正しい結果が得られるかどうかを吟味する必要がある.
というのも,コンパイラはプログラムを作成した人の意図まではわからないからである.
例えば,足し算をしようとするプログラムで,引き算をしていても,文法上問題がなければコンパイルエラーは出ない.

格言:プログラムは意図した通りではなく,書かれている通りに動作する.

従って,ループや条件分岐など,プログラムが意図した通り正しく動作しているかを,何らかの方法でモニタリングできると良い.

printfデバック

コンパイルは問題なく終了するのに,実行結果が何かおかしい場合は,ソースコード内の記述が不適切か,あるいは処理のロジックそのものを間違えている可能性がある.
このような場合は,最終的なプログラムの実行「結果」ではなく,まさに現在進行中のプログラム内の変数などが,どのように変化しているかを逐次把握することで, その動作が期待した通りか否かをチェックすることができる.

このようなプログラムの動作チェックには,「デバッガ」と呼ばれる特別なソフトウエアが用いられるが, もっと簡単に printf() を用いて,計算中の変数値などを監視することもできる.
このような簡易的な手法を,printfデバッグと呼ぶ.

さきほどのプログラムのループ中に,変数監視用に

// printfデバッグの例
// 階乗の計算

#include <stdio.h>

int main(void)
{
    short f=1;
    for(short i=0; i<10; i++) {
        f *= i;
        printf("debug: i = %d, f = %d\n", i, f);    // デバッグ用の途中経過表示 printf
    }

    printf("answer = %d\n", f);

    return 0;
}

練習問題

上記の printf の出力を参考に,何が起きているかを把握しよう.
次に,原因となる箇所を修正し,正しく動作するようにプログラムを修正してみよう.

プリプロセッサを用いたデバッグコードのON/OFF

printf デバッグでは,本来の処理結果の表示に必要でない箇所にprintf()関数を挿入する.
したがって,プログラムの動作検証が終わったら,検証のために挿入した printf() を削除する必要がある. (または,コメントアウトする).

しかし,この削除作業は面倒であるだけでなく,誤って本来の処理に必要な printf() まで削除してしまう可能性がある.
また,デバッグの意図として「このような処理で動作テストしました」という記録をソースコード中に残しておきたい場合もある.

そこで,「プリプロセッサ」と呼ばれる機能を用いて,デバッグ用の処理と,本番用(リリース用という)の処理を一括して切り替える方法が用いられる.
ここでは,#define, #ifdef(または#if defined ), #endif を用いる方法を学ぶ.
(実は,#includeもプリプロセッサの一種である.)

参考:プリプロセッサについて このページの説明では,ソースコードから実行ファイルを生成する作業を「コンパイル」と呼んでいるが,実際の実行ファイル生成は
  1. プリプロセス:#define の処理や #include の展開
  2. コンパイル :ソースコードの翻訳作業..cpp ファイルごとに中間ファイル(.obj, .o) ファイルを生成
  3. リンク   :複数の中間ファイル(.obj, .o)ファイルと,ライブラリファイル(.lib, .a) を結合し,実行ファイル(.exe, a.out)を生成
の過程を経ている. プリ・プロセスとは,コンパイルに先立って行われる「前処理」のことを指す.
#include <stdio.h>

int main(void)
{
    short f=1;
    for(short i=0; i<10; i++) {
        f *= i;

#ifdef _DEBUG
        printf("debug: i = %d, f = %d\n", i, f);
#endif

	}
    printf("ans = %d\n", f);
    return 0;
}

練習問題

  1. 上記プログラムの実行結果を確認しよう.ループ内のdebug:から始まる行が表示されるか?
  2. 次に,下記に示した2種類のデバッグオプションの有無による実行結果の差を確認しよう.

ソースコード中に書かれている #ifdef _DEBUG#endif の区間は,「_DEBUG」という文字が定義されている(=defined)場合に限り有効化される.

ここで「定義されている」とは,以下の2種類のいずれかの場合を指す.

  1. ソースコード先頭に#define _DEBUG と書く (_DEBUG という単語を定義 (define) する.)
    #define _DEBUG
  2. コンパイル時に,コンパイラに -D オプションを追加指示する.
    c++ -D_DEBUG test.cpp   ( gcc の場合)
    

これらのいずれかを用いることで,1つのオプションの操作だけで,ソースコード全体の,#ifdef _DEBUG#endif を制御することができる.

1. のやり方は,開発中のソースコードの先頭に「このソースはまだ開発中」という印として,define文を記録できるメリットがある.
2. の方法は,コンパイラに与えるオプションの有無で,デバック用の処理部分のON/OFFを切り替えられる.ソースコードをいちいち変更しなくてよいメリットがある.

いずれの場合も,「_DEBUG」 を定義しなければ当該部分のソースコードはコンパイル前のプリプロセスで削除される.
即ち,デバッグ用の処理は初めから無かったものとしてコンパイルされるため,実行ファイルリリース時の動作には影響を与えなくなる.
また,defineで定義する文字は「_DEBUG」である必要はなく,重複しなければどんな文字でもよい. 例えば,「ABC」や「_DEBUG_PRINT」や,「_DEBUG_0001」などでもよいが,一般的には例のように大文字で DEBUG,_DEBUG という単語を入れる場合が多い.

プリプロセッサによるコメントアウト

プリプロセッサの別の使い方として,#if 0#endif で,任意の行数を一度にコメントアウトできる.

#include <stdio.h>

int main(void)
{
    // 何か作りかけのコード
    ...
    return 0;
}


// 以下のコードは,当面使わないけど,残しておきたい.

#if 0
int main(void)
{
    int f=1;
    for(int i=0; i<20; i++) {
        f *= i;
    }
    printf("answer = %d\n", f);

    return 0;
}
#endif

この場合,#if 0 から #endif の区間はコンパイルの前のプリプロセスの段階で削除され,ソースコードから削除された後,コンパイルされる.
#if 0 の部分を #if 1 に書き換えれば,再びこの部分を有効化できる.

テキストエディタの種類によっては,#if 0#endif の区間の色を薄くしたり,折りたたんだりして自動的に目立たなくするものもある.

練習問題

上記プログラムの実行結果を確認しよう.
次に,#if 0#if 1 に書き換えてみよう.

アサーション

プログラム実行中の変数の値や,その他の状態などを実行時にチェックする手法として,アサーションと呼ばれる手法がある.
多くのC言語コンパイラでは,assert() と呼ばれる関数が用意されている.

assert()関数は,条件式を引数として渡すと,その表現が「真」である場合は何も起きないが, 「偽」になった場合にはプログラムの実行がそこで強制終了し,エラーの出た行数などが表示される.

また,assert() 関数を書いておくことによって,ソースコード中の特定の位置において「ある変数の値は常に○○以下」や,「この変数は絶対に素数」など,プログラマの意図をソースコード中に表明することができる.

// 例:無限ループのループ回数の上限チェック
#include <stdio.h>
#include <assert.h>

int main(void)
{
    int count = 0; // 反復回数をカウント

    //  必ず10回以内の反復で終わるはずの処理.
    while(1) {

        printf("count = %d\n", count);
        // if(...)
        //     break;

        count++;
        
        // 10回以内に終わるはず!ということで,
        // 変数countが10以下の時は真
        // 変数countが10を超えるとアサーションが発生!プログラムが強制終了
        assert(count <= 10);
    }
    return 0;
}

練習問題

assert の例題を実行して,どのような結果になるか確認しよう.
OS, コンパイラにより,出力結果は異なる可能性がある.

この例では,あらかじめループ回数の範囲が予測できる場合に,何かの設計ミスでループ回数上限を超えた場合に,ループ中の assert が偽になるため,プログラムを強制終了させた旨の通知が表示される.
これにより,コーディング中の間違いや,想定から外れた値があることを発見でき,指摘された箇所を重点的にデバッグすることができる.

assert と if文 ifassert は,以下のように使い分ける.
if
ユーザーの入力エラーなど,通常の処理で発生しうる想定内の分岐処理に使用.
if文が偽になる=想定の範囲内なので,通常の処理を続行.
assert
想定内の処理時には起こりえない,予定外の状態を検出したい時に使用.
assertが偽になるということは,プログラムの不具合や,設計とは異なる異常な状態(論理エラー)になったことを意味する.
これにより,バグを検出,特定することができる.