プロトタイプ宣言の食い違いをなくすには

ここまで、プログラムのバグを減らす方法について、いろいろと教えてもらいましたよね。
そうね。役に立ってるかしら?
もちろん!どれも意味を理解するのは簡単じゃなかったですけど……
うんうん。
実際にやることは、そんなに難しくないのが多いですよね。
ふふふ。そこが重要なのよ!
難しくないことが重要なんですか?
だって、つらい方法ばかりじゃ長続きしないでしょう?
そうですねぇ。
バグには「仕組み」で対処しようと考えるのが、正しい努力のしかたなのよ。
そっか。「仕組み」が大事なんでしたね!
それじゃあ、次はC言語でリンクのミスを防ぐ方法について考えてみましょうか。
リンクのミスですか?
そう。プログラムが複数のモジュールでできている場合に起こるかもしれない問題ね。

分割コンパイルとリンク

ある程度の大きさがあるプログラムは、複数のモジュールに分けて作るのが通常です。その場合は分割コンパイル(各モジュールのソースファイルを別々にコンパイル)したあとで、最終的な実行ファイルにリンク(コンパイル結果を1つに結合)します。

例えば、「メイン」と「サブ」の2つのモジュールがあったとしましょう。「サブ」のソースファイル「sub.c」は、次のようになっています。

sub.c
void Example(int n) { /* 関数の定義 */
  ……
}

Example()という名前の関数が「定義」されていることが分かります。これを「メイン」から呼び出すことを考えてみましょう。こちらのソースファイルは「main.c」です。

main.c
#include <stdlib.h>

int main(void) {
  Example(12345); /* エラー:いきなり「サブ」モジュールにある関数は呼び出せない! */

  return EXIT_SUCCESS;
}

このように「サブ」にある関数をいきなり使おうとしても、コンパイルエラーになってしまいます。これは、関数がまだ「宣言」されていないためです。

関数の「宣言」というのは、「この関数が、どこかにあるよ」っていうのをコンパイラに知らせることね。
関数の「定義」とは違うんですか?
「定義」は関数の実体。「この関数は、こういう処理をするよ」っていう、関数の中身まで書いたもののことね。だから関数ごとに1つしかないの。
なるほど。関数の処理を書くっていうのは、「定義」をしているわけですね。
そういうことね。で、「宣言」のほうは、これから使う関数の形を表しているだけなのね。だから何回書いてもいいのだけど……
ふむふむ。
やっぱり1つしか書かないことが多いわね。そのためにヘッダーファイルを使うのよ。

宣言は、モジュールごとのヘッダーファイルに書くのが一般的です。「サブ」のヘッダーファイル「sub.h」は、次のようになっています。

sub.h
#ifndef sub_h
#define sub_h

void Example(int n); /* 関数の宣言 */

#endif /* sub_h */

Example()がどのような形の関数なのかが書かれていますね。これが宣言、もう少し詳しくいえば「プロトタイプ宣言」です。プロトタイプ宣言を見れば、引数の個数や型、戻り値の有無などが分かります。

では、「メイン」を修正してみましょう。「main.c」で次のようにヘッダーファイルをインクルードすれば、コンパイルエラーは発生しなくなります。

main.c
#include <stdlib.h>
#include "sub.h" /* 「サブ」モジュールから宣言を取り込む */

int main(void) {
  Example(12345); /* 宣言があれば、「サブ」モジュールの関数でも呼び出せる */

  return EXIT_SUCCESS;
}
ヘッダーファイルがモジュール同士の間を取り持ってくれる感じですかね。
そうね。ここではインクルードした「宣言」が、「サブ」の関数を使うためのインターフェースになっているということよ。

これで分割コンパイルができるようになりました。ソースファイル「main.c」と「sub.c」をそれぞれコンパイルして、その結果をリンクすれば実行可能なファイルになるはずです。

プロトタイプ宣言の食い違い

ここで、今の状況を詳しく確認してみましょう。まずは「メイン」について。

  • 「main.c」で関数Example()を使うために、「sub.h」をインクルードしています
  • 「sub.h」には、関数Example()のプロトタイプ宣言があります

次に「サブ」について。

  • 「sub.c」に関数Example()の実体が定義されています
  • 「sub.c」では「sub.h」を使用していません
さてさて。今の状況が、けっこう危なっかしいっていうのが分かるかしら?
え?どこがです?
それはね……「main.c」では、Example()がどういう形の関数なのか「sub.h」を見て判断しているでしょう?
はい。「sub.h」のプロトタイプ宣言を見てるってことですね。
じゃあ「sub.c」のほうはどうなっているかというと、「sub.h」を見ていないのよ。
「sub.c」にはExample()の定義(=実体)があるわけだから、宣言を見てもしょうがないですもんね。
うん、そういうことなんだけどね。もし宣言と定義が食い違っていたら、どうなると思う?

関数Example()は、「sub.h」で次のように宣言されています。

void Example(int n); /* 関数の宣言(引数がint型) */

もし、「sub.c」での定義が次のようになっていたら何が起こるでしょうか?

void Example(char n) { /* 関数の定義(引数がchar型) */
  ……
}

引数の型がintcharで食い違っていますね。ところが、これでも分割コンパイルは可能です。「メイン」をコンパイルするときは「sub.h」をインクルードするだけなので「sub.c」を使わず、「サブ」をコンパイルするときは「sub.c」があれば十分なので「sub.h」を使いません。「sub.h」の宣言と「sub.c」の定義が、照合される機会がないのです。

さらに、このような状況でもC言語ではリンクまでできてしまいます。宣言と定義が食い違っているにもかかわらず、実行可能なファイルを作れるのです。

それって、実行したらどうなるんですか?
そこは環境にもよるし、なんとも言えないの。ただ、正常に動かないってことだけは間違いないわね。
うーん……分割コンパイルに、そんな問題があったとは。
ちなみに、C++の場合はリンクしようとするとエラーになるから、問題があれば気付けるわ。
じゃあ、もう全部C++にしちゃいますかねぇ。
あはは、大丈夫よ。簡単な「仕組み」で対処できる方法が、ちゃんとあるからね。
そうなんですね!よかったぁ。

プロトタイプ宣言を定義と突き合わせる

宣言と定義の食い違いは、プログラムを動作させてみて「正常に動いてないぞ」って分かるまで気付けないところが問題ね。
えっと、C言語の場合……ですよね。
そうそう。C++ではリンクエラーが出るからね。
ふむふむ。つまり、C言語でも確実にミスに気付けないのかと……
そういうこと。でね、宣言と定義が食い違っていたら、コンパイルの時点で分かる方法があるの。

リンクのミスは、コンパイルの時点で宣言と定義の食い違いを見過ごしてしまっているために起こります。コンパイルするときに両者を突き合わせれば、もっと確実に問題に気付くことができるでしょう。

そのためには、各モジュールから自分自身のヘッダーファイルをインクルードするのが簡単です。具体的には、「sub.c」から「sub.h」をインクルードするということです。すると、コンパイル時に宣言と定義が照合されて、正確に一致していない限りコンパイルエラーが発生するようになります。

sub.h
#include "sub.h" /* 宣言を取り込んで突き合わせる */

void Example(int n) { /* 関数の定義 */
  ……
}
え?それだけですか?
うん。それだけ。
わぁ、簡単っ!
でしょう?これで、宣言と定義に食い違いがあれば、コンパイルエラーで分かるわね。
そうですねー。
あとは……例えば定義のほうで引数の型を変更したときに、宣言の側を直し忘れてしまったとしても……
そのあとコンパイルするはずだから、ミスしたことがすぐに分かりそうですね。
そういうこと!いつもの作業のなかで、ほぼ自動的にミスを検出できるところがポイントよ。
なるほど……そういえば、この「自分自身のヘッダーファイルをインクルードする」っていうやり方は、今までも見たことがあるかも……
けっこう定番になってる手法だから、そうかもね。たしか、ファイルスコープの話をしたときのプログラムでも、同じことをしていたと思うわ。
ほんとですか?(思い出してみよう……)
ここがポイント!
分割コンパイルをするときは、各モジュールから自分自身のヘッダーファイルをインクルードしておきましょう。宣言と定義に食い違いがあった場合に、確実に検出できるようになります。