プロトタイプ宣言が必要だ!

結論を言うならば、C言語で別のファイルで定義した関数を呼び出すならば、関数のプロトタイプ宣言を書かなくてはならない。なぜならば、プロトタイプ宣言抜きでは、引数と返り値の異常を防ぎ切ることが困難だからである。

ここでは、プロトタイプ宣言の必要性と関連する問題について、C言語の「仕様」、問題箇所の「検出」、そして、不具合の「予防」の観点から説明したい。だが、その前にプロトタイプ宣言がないために起きる異常の実例を見よう。

返り値の異常

まず、プロトタイプ宣言がないために返り値が正常に処理されない場合である。

double func1(void)
{
    double var;
    var = 0.1;
    return var;
}

以上のように定義されている関数を別のCファイルから以下のように呼び出す。

double dbl_var;
dbl_var = func1();
printf("func1: %lf\n", dbl_var);

プロトタイプ宣言が無い場合の結果は以下の通りである。明らかに0.1ではない。1

func1: -1717986918.000000

ヘッダファイルを用意して以下のプロトタイプ宣言を記述し、関数を定義しているCファイルと呼び出しているCファイルの両方からincludeをすると、

double func1(void);

以下のように期待した通りの結果となる。

func1: 0.100000

引数の異常

次は、プロトタイプ宣言がないために引数が正常に処理されない場合である。

void func2(float arg)
{
    printf("func2: %f\n", arg);
    return;
}

以上のように定義されている関数を別のCファイルから以下のように呼び出すとする。

float flt_var;
flt_var = 1.0;
func2(flt_var);

プロトタイプ宣言が無い場合の結果は以下の通り、1.0とはならない。

func2: 0.000000

横着をして以下のような引数の情報を含まない宣言を書いても、出力結果は変わらず、正しい動作にはならない。

void func2();

引数の情報を含む以下のプロトタイプ宣言をヘッダファイルに記述して、関数を定義しているCファイルと呼び出しているCファイルの両方からincludeをすると、期待した結果となる。

void func2(float arg);
func2: 1.000000

整数型の異常

これまでの例は浮動小数点型に関する物であったので、整数型で異常が起きる例を示す。

void func3(long arg1, long arg2, long arg3, long arg4,
           long arg5, long arg6, long arg7, long arg8)
{
    printf("func3a: %ld, %ld, %ld, %ld\n", arg1, arg2, arg3, arg4);
    printf("func3b: %ld, %ld, %ld, %ld\n", arg5, arg6, arg7, arg8);
    return;
}

以上のように定義されている関数を別のCファイルから以下のように呼び出すとする。

func3(1, 2, 3, 4, 5, 6, 7, 8);

以下のような完全なプロトタイプ宣言が関数定義と関数呼び出しの両方から参照されていれば、

void func3(long arg1, long arg2, long arg3, long arg4,
           long arg5, long arg6, long arg7, long arg8);

以下のような当然の出力結果が得られる。

func3a: 1, 2, 3, 4
func3b: 5, 6, 7, 8

では、プロトタイプ宣言が無いとどうなるかというと、以下がその結果だ。

func3a: 1, 2, 3, 4
func3b: 5, 6, 7, 4575657221408423944

以下のようにプロトタイプ宣言から引数の情報を除いてしまうと、最後の引数の結果はやはりおかしくなる。

void func3();

なお、これは必ず最後の引数のみがおかしくなるという現象ではない。 興味があれば、引数を増やしたり、複数のコンパイラやOSで試したりすると良いかも知れない。 だが、問題が起きるケースが1つでも見つかっている以上、他を試す必要はないだろう。
つまるところ、適切なプロトタイプ宣言が必要なのだ。プロトタイプ宣言を書けば、よくわからないバグの原因を確実に1つ除去できるのである。

仕様

1990年に発行されたISOのC90規格(以下C90)では、プロトタイプ宣言が無かった場合の規定がある。

返り値

関数呼び出しにおいて、プロトタイプ宣言が無かった場合、返り値はint扱いになる。実際の関数定義が別の型を返すようになっていようが、返り値の代入先がintでなかろうが、C90モードならコンパイラは関数がint型を返したと仮定して実行ファイルを生成しなくてはならない。2

例えば32bit環境ならば、int、つまり、32bitの符号付き整数を、返り値としてどこかのCPUレジスタかメモリに関数が置いたとコンパイラは仮定する。 本当にプログラムが実行されたときに、関数が返り値として本当は8bitの値を置いていようと、浮動小数点型の値を置いていようと、関数を呼んだ側は返り値として32bitを読んで符号付き整数として処理するのだ。 8bitしかないものを、32bit分読んだ結果として何が起こるかなど、当然わからない。よって、C90では、こういうことをすると「未定義の動作」が起こるとしている。

「未定義の動作」を一言で言えば「タチの悪い本質的エラー」である。 どのぐらいタチが悪いかというと、
コンパイラエラーになる「かもしれない」し、
実行時エラーになる「かもしれない」し、
メチャクチャな変数値で実行が続く「かもしれない」
というぐらいである。

1つ目の例(返り値の異常)では、プロトタイプ宣言が無いと、func1()の呼び出しは、func1()からint型の値が返されると仮定してコンパイルが行われる。 実際には、関数定義によってfunc1()はdoubleを返すのだから、返り値を渡す側と受け取る側で食い違いが起きており、規格上「未定義の動作」を引き起こす。 今回こちらで試した環境では、「未定義の動作」が引き起こされた結果として、メチャクチャな変数値で実行が続いた。 まず、intのビット幅分だけ読んで、次に、double型の変数dbl_varに代入するために、それをdoubleへキャストするような処理がたぶん行われているのではないかと思うが、詳細を検討する意義はほとんどない。 なぜなら、そもそも関数定義の型と関数呼び出しの型の食い違いは、「未定義の動作」という本質的エラーを引き起こすバグであり、 コンパイルオプションやソースコードを少しいじるだけで起きることは容易に変わりうる。 何より、国際規格で、C言語でそういう記述をしたら、何が起きるか分からないと規定されているのに、何が起きているか議論を続行する必要があるだろうか。 プロトタイプ宣言を正しく記述して、「未定義の動作」が起きないようにすればいいだけの話である。

引数

関数呼び出しにおいて、プロトタイプ宣言が無かった場合、関数呼び出しの引数は、その型によっておおよそ以下のように処理される。

(正確には、整数型の引数に対しては汎整数拡張が行われる。 正確な引数の処理内容はC90規格書の「6.3.2.2 関数呼び出し」を、汎整数拡張については「6.2.1.1 文字型と整数型」を参照のこと。)

重要な点は、プロトタイプ宣言が無いと、引数に暗黙的な変換がまず行われ、そして、関数が呼び出されるということである。 そして、この暗黙的に変換された後の型が、関数定義で指定されている引数の型に適合しなければ、「未定義の動作」が引き起こされる。

2つめの例(引数の異常)では、関数定義によってfunc2()の引数はfloat型として定義されている。しかし、プロトタイプ宣言無しでは、この引数の情報がないため、コンパイラは規格に従ってfloat型の変数をdobleに暗黙的に変換し、これを引数にfunc2()を呼び出す処理を生成する。呼ばれた側のfunc2()は、double型が渡されているにも関わらず、float型が渡されていることを前提に処理を行うため、おかしな出力結果となっている。

3つめの例(整数型の異常)では、関数定義によってfunc3()の引数は全てlong型として定義されている。しかし、プロトタイプ宣言無しでは、このlong型を渡す必要があるという情報がないため、関数呼び出しでは1から8までの数値は変換されず全てint型のままである。引数としてlong型を受け取るつもりのfunc3()に、int型が渡されているため、最後の引数でおかしな出力結果となっている。

検出

プロトタイプ宣言の不備は、基本的にはコンパイラによって検出できる。

GCCの場合、対応する警告はimplicit-function-declarationである。

この警告を選択的に表示したいならば、以下のように-Wを頭に付けたものをコンパイルオプションとして指定すれば良い。

gcc -Wimplicit-function-declaration -c -o foo.o foo.c

この警告をエラー扱いしてビルドを停止させたい場合は、以下のように-Werror=を頭に付けて指定する。

gcc -Werror=implicit-function-declaration -c -o foo.o foo.c

予防

「コーディングルール.doc」みたいなファイルの中に、「プロトタイプ宣言は必ず記述すること」と追記するだけというのは、これまで説明してきた問題を防ぐ手段として、多くの場合不十分である。 信用されず、そして、それ故に読まれないドキュメントの類いの一つや二つ、企業プログラマなら心当たりがあるのではないだろうか。 守られていないルールは、害にすらなる。

以下に、実用的と思われるルールと、その運用方法の例を示す。 実用的とはいっても、開発現場は多様であるため、この例が実際の現場にそのまま適用可能だとは考えていない。 自分の現場との比較など、あくまで参考資料として利用してもらえるとうれしい。

ルール

関数は,常にプロトタイプ宣言をもち、プロトタイプ宣言は,
関数定義及び関数呼び出しの両方から参照されなければならない.

これは「組込み開発者におくるMISRA-C:2004 - C言語利用の高信頼化ガイド」の96ページからの引用である。 簡潔かつ十分で、解釈の誤りも起こさないルールの文を作るのは、簡単なことではない。 適切なルールの文言が既に存在するならば、それを利用したい。

ルールの解説

ルールが必要な理由については、
「組込み開発者におくるMISRA-C:2004 - C言語利用の高信頼化ガイド」の96ページを参照のこと。

この文章を、ここまで読んでいる方に、このルールが必要な理由を再度説明する必要はないと思うが、 新しく現場に加わった人に、どうやってルールを理解し、守ってもらうかは重要だ。 それから、時にはルールの改定を議論する必要があるかもしれない。 そういったときには、背景にある問題や関連する情報を記したルールの解説文書が重要な役割を果たすだろう。 なお、ルールの解説についても、既に適切なものが存在するならば、それを利用したい。

ルールの例外

以下に示すファイルのソースコードは、暫定的に本ルールの適用対象外とする。

(以下、具体的なファイルのリスト)

既存のソースコードに変更を加えるとき、特に出荷済みの製品のコードを変更するときは、それ相応のテストを実施するというのは、多くの開発現場で一般的なことだと思う。 ルールの適用を始めるときに、既存のソースコードに追加するプロトタイプ宣言の数がわずかであれば良いのだが、 それが大量であった場合、テストの量も多くなるため、例外として暫定的にルールの適用外とするファイルが出てくるだろう。

ルールに例外があるときは、トラブルを避けるために、例外の範囲を明記しておいた方が良いだろう。 例外なのか、単に守られていないだけなのかが不明瞭なルールは、守られなくなっていく傾向がある。 また、ルール遵守を錦の御旗にして、プロトタイプ宣言をテストのことを考えずに追加して回るという行動を抑止するためにも、どのファイルが例外になっているかを示しておくと良いだろう。

ルールの運用方針

一つ目の方針についてのコメントは単純だ。私の知る限り、新しくソースコードを書き起こすときに、プロトタイプ宣言を書かない理由はない。 適切なヘッダファイルを用意して、別のファイルから呼び出される関数のプロトタイプ宣言をそこに記述するべきである。

二つ目については、既存のソースコードに対する変更と、それに対応するテストは、きちんと考える必要があるということである。 バグを修正したら、別のバグが見つかったという経験はないだろうか。 単にプロトタイプ宣言を追加するだけだとしても、商用ソフトウェアの開発では、全くテストをしないというわけにはいかないだろう。 テストを考慮してのルール適用の適切な時期の一つは、新機種や新バージョンの開発に入るときである。新バージョンに対しては出荷されるまでに多くのテストが実施されるであろうから、もしもプロトタイプ宣言の追加が何らかの問題をあぶり出したとしても、その問題は出荷前に見つかることが期待できる。 他のやり方としては、例外となっているファイルに対して、機能改良や不具合修正を行うときに、宣言の追加も行って、順次例外リストからファイルを外していくというやり方もあるだろう。 どういう戦略をとるにしても、宣言の追加とは別の理由でテストが行われる時期をとらえて、テストにかかるコストを低減したいところだ。

次に三つ目だが、コンパイラによるチェックは、人手によるチェックに比べ、より早く、より確実であり、さらに、人間のような技量による差も無い。 人手で、他人の書いたソースコードから、引数の数、引数の型、返り値の型などを関数一つ一つに対して確認する作業を想像して欲しい。 関数宣言、関数定義、関数呼び出しの間で引数と返り値の整合性がとれているかは、コンパイラに任せてしまって良いだろう。

一方で、四つ目の方針は、コンパイラをだますような記述を、人間が見つけるためにある。 例えば、コンパイル単位によって異なるプロトタイプ宣言を参照しているような問題は、コンパイラが検出することは難しい。 よって、おかしなことがないか、例えば、同じ関数のプロトタイプ宣言が、複数のファイルにあるとか、ifdefで切り替わっているとか、そういうことがないかは、人間が確認すると良いだろう。

まとめ

戻る


  1. 出力結果はx86_64の"Ubuntu 14.04.4 LTS"と"gcc 4.8.4"を使い、最適化無しでコンパイルして確認している。

  2. 今後詳しく説明するが、C99では仕様が変更されている。