「プログラミング」の最終目的は,あからじめ設定した所望の機能(要求仕様という)を実現するソフトウエアを完成させることである.
これは「ソフトウエア開発」または「プログラム開発」と呼ばれる.
一般的な工業製品では,「設計」にあたる製図と,「生産」にあたる製造の過程を経るが,ソフトウエアの作成においては,ソースコードの記述である「コーディング」作業が「設計」に相当する.(製造はコンパイラによる実行ファイルの翻訳)
コーディング段階では,最終的にプログラムのソースコードに文法エラーおよび論理エラーなの間違い(バグ)が無い事が要求されるため,デバッグのための技法がいろいろと用意されている.
プログラミング作業を順序立ててシステマティックに行うために,以下のような様々な技法や便利なツールが開発されている.
ここでは,単体ソースファイルのデバッグ法について学ぶ.
ソフトウエアで高度な処理を行おうとすれば,ソースコードの行数や数も増え,その個々の動作チェックやバージョン管理が膨大な作業となる.
例えば,
など,プログラムの規模の増大により問題が劇的に増える.
そのため,ソースコード中に発生した問題箇所を特定し,また,その動作検証をしやすいように,「モジュール化」と呼ばれる手法が取られる.
これは,必要な処理を全部 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デバッグの例
// 階乗の計算
#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
の出力を参考に,何が起きているかを把握しよう.
次に,原因となる箇所を修正し,正しく動作するようにプログラムを修正してみよう.
printf
デバッグでは,本来の処理結果の表示に必要でない箇所にprintf()
関数を挿入する.
したがって,プログラムの動作検証が終わったら,検証のために挿入した printf()
を削除する必要がある.
(または,コメントアウトする).
しかし,この削除作業は面倒であるだけでなく,誤って本来の処理に必要な printf()
まで削除してしまう可能性がある.
また,デバッグの意図として「このような処理で動作テストしました」という記録をソースコード中に残しておきたい場合もある.
そこで,「プリプロセッサ」と呼ばれる機能を用いて,デバッグ用の処理と,本番用(リリース用という)の処理を一括して切り替える方法が用いられる.
ここでは,#define
, #ifdef
(または#if defined
), #endif
を用いる方法を学ぶ.
(実は,#includeもプリプロセッサの一種である.)
#define
の処理や #include
の展開#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;
}
ソースコード中に書かれている #ifdef _DEBUG
〜#endif
の区間は,「_DEBUG」という文字が定義されている(=defined)場合に限り有効化される.
ここで「定義されている」とは,以下の2種類のいずれかの場合を指す.
#define _DEBUG
と書く
(_DEBUG という単語を定義 (define) する.)
#define _DEBUG
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
が偽になるため,プログラムを強制終了させた旨の通知が表示される.
これにより,コーディング中の間違いや,想定から外れた値があることを発見でき,指摘された箇所を重点的にデバッグすることができる.
if
と assert
は,以下のように使い分ける.
if
assert