第39回 プログラミングについて『 関数のプロトタイプとオーバーロード』

『 関数のプロトタイプとオーバーロード』
C言語は高級言語の中では小さい仕様の言語です。また初期のK&R(カーニハンおじさんとリッチーおじさんの略)のC言語は仕様に曖昧なところが多く、逆にこのおかげでプログラムが書きやすいこともありました。またUNIX自体が殆ど只同然で使用できたおかげでこのOSを記述したC言語は急速に普及していきました。さらに言語自体を習得するのが他のプログラミング言語に比べて容易なこと、メモリーサイズの小さかったパソコンにも移植され、インタープリタで動作していたBASICと比べて劇的に高速だったことから一時期にはC言語一色のときもありました。
まだC言語がマイナーな言語の頃にはお役所も野放しにしていたのですが、この言語が普及し始め、この存在を無視することができなくなったことで変な仕様を制定しました。これがANSI仕様のC言語です。
UNIXが使えるワークステーションやパソコン用のUNIXにはそれがごく当然のことのようにCコンパイラが付いています。VAX/VMSを最初に使っていた私にはこれは驚異的なことした。VMSには標準ではアッセンブラしか付いていないのです。
通常UNIXに標準で付いているコンパイラはK&R仕様のコンパイラですので、ANSI仕様のコンパイラを使いたいときには、GNUのコンパイラとかのコンパイラを使うか、メーカに高額な金を払って使うしかありません。しかしながら昨今のパソコン用のコンパイラはその殆どがANSI仕様に対応していますので、パソコンでプログラムを書いている人は気付かずに使っていることと思います。
ANSI仕様になって何がK&Rと違っているのかは、共立出版から出ている『プログラミング言語C』を読めば書いてあります。著者は生みの親のカーニハンおじさんとリッチーおじさんで、あまり喜んでいないのかANSI仕様に新たに加わった部分についてはかなり憮然と説明しています。
今回はこのANSI仕様の中で関数のプロトタイプの宣言について考えてみます。
K&RでもANSIでも次の様に記述してもコンパイラはにこにこしてコンパイルしてくれます。
main()
{
sub(1,10.0);
}
sub(i,j)
int i,j;
{
printf("%d %d\n",i,j);
}
これを実行してみると、
1 0
になりました(この結果は計算機やコンパイラの違いで異なることがあります)。呼び出し側では sub の第2引数は実数型の値を渡していますが、sub 本体では整数型です。しかし、どちらの仕様のコンパイラでもこのように書けば、引数の型や数にはお構いなしにコンパイルしてしまいます。値が 10.0 だから結果が間違っていることが分かるのですが、0.0 だったら合っているのか間違っていすのかは分からなくなってしまいます。
また次のようなことも起こります。
main()
{
sub1(10.0);
}
sub1(a)
float a;
{
sub2(&a);
}
sub2(a)
float *a;
{
printf("%f\n",*a);
}
この結果は 0.000000 となります。プログラムだけをみると 10.00000 となりそうですがそうはならないのです。C言語では実数値を引数にで渡すときには double 型として渡されます。ですから main が sub1 に 10.0 を渡すと、この値は double 型でスタックに積まれた後 sub1 に渡されます。sub1 では渡されたのは double型の実数値ですので、引数の型が float 型と定義してあっても double 型として扱います。更に sub1が sub2 を呼び出すときには double型のアドレスを渡しています。sub2 では渡されたのは float型のアドレスと思っていますので、printf 関数に引数を渡すときに、 a が示しているところにある float型の値を double型に変換するのです。 このようにして1つ1つ見てみると矛盾があることが分かると思います。
これらの例のように引数の型の違いはチェックされません。また引数の数も同じくチェックされません。C言語はこのようないい加減なところも魅力の1つなのですが、引数の型や数の違いを意識して利用することはあまり多くはありません。これを利用する例を1つだけ紹介します。
main()
{
char data[sizeof(int)];
int i,n;
i=12345;
move_int_to_char(&i,data);
for(n=0;n<sizeof(int);n++)printf("%x ",data[n]);
printf("\n");
}
move_int_to_char(i,c)
char *i,*c;
{
int n;
for(n=0;n<sizeof(int);n++)c[n]=i[n];
}
これは整数型の値を文字型の配列に代入するものです、単純に同じことを行なうときには関数を呼び出す代わりに、
*(int *)data=i;
とすれば実現できるのですが、リトルエンディアンの計算機からビッグエンディアンの計算機にバイナリファイルを渡すときにはこのように関数を作って、その関数の中でバイトの順序を変えて代入したりします。
話を戻しましょう。引数の型や数の違いがバグの原因になることは結構多いものです。
また以外とこのミスは発見しずらいものです。このようなミスを減らすためにANSI仕様では関数のプロトタイプの宣言ができるようになりました。関数のプロトタイプとは、関数の呼び出しに先立って、関数の型と引数の内容をコンパイラに教えるものです。コンパイラは関数のプロトタイプが宣言されていると、関数を呼び出すときにチェッします。関数や引数の型が違っているときにはできる限りプロトタイプの宣言に合うように自動的に型を変換します。しかしながら、ポインタを渡すところに値を渡したり、ポインタの型が違っていたり、引数の数が違っているときにはどうにもならないのでエラーを発生します。
このようにして極力プログラムのミスを減らそうというのが関数のプロトタイプ宣言なのです。プロトタイプの宣言を最初の例で考えてみると、
int sub(int,double); /* sub のプロトタイプの宣言 */
main()
{
sub(1,10.0);
}
sub(i,j)
int i,j;
{
printf("%d %d\n",i,j);
}
と書くことができます。コンパイラにとってはこのプロトタイプの宣言で十分なのですが人間にとっては最初の引数は何が int 型なのか、2番目の引数は何が double 型なのかが分かりずらいので、
int sub(int id,double value); /* sub のプロトタイプの宣言 */
と引数の型に合わせて引数の名称も記述することができます。この名称はコンパイラにとっては殆ど意味を持ちません。このプロトタイプ宣言を加えたプログラムをコンパイルしてみると、sub の本体のところで2番目の引数の型が違っていると叱られます。
それでは試しに呼び出し側の2番目の引数を整数値にし、sub の本体では2番目の引数を double 型にして(printfの内容も変えます)コンパイルしてみると、コンパイラは何も言わずにコンパイルしてくれます。実行結果も予想どおりになります。このときには 10 という整数値は sub のプロトタイプの宣言の2番目の引数が double型になっていますので、コンパイラは double型に自動的に変換してくれるのです。 プロトタイプの宣言がされていない関数は従来どおりの扱いになります。
プロトタイプを、
int sub(); /* sub のプロトタイプの宣言 */
とすると、引数については宣言されていないので関数の型のみが検査の対象となりますので結果的には従来と同じことになります。
ANSIの使用では関数の本体を定義するときに、引数の並びと同時に型も定義できるようになりました。
K&Rでは、
abc(i,j)
int i,j;
と引数の型は関数の定義の引数のカッコの外側で行なうことになっていますが、ANSIでは、
abc(int i,int j)
と記述することができるのです。この形式は上記のようにプロトタイプを宣言したときにはあまり意味を持ちませんが、次のように main と sub の順序を逆にして記述すると関数の本体の定義とプロトタイプの宣言が同時に行われることになるのです。
sub(int i,double j)
{
printf("%d %f\n",i,j);
}
main()
{
sub(1,10);
}
このときには、main が sub を呼び出すときに第2引数の 10 を 10.0 に変換するので結果は、
1 10.000000
となり希望どおりに動作してくれます。
main を先に書くか後に書くかの記述スタイルに好みが分かれるのは、この本体の定義とプロトタイプの宣言が同時に行なえることもその理由になっている訳です。
C++では関数のプロトタイプについてはもっと強烈で、C言語とは考え方に大きな違いがあります。C++では関数のオーバーロードが行なえるようになっています。オーバーロードというのは、多分FORTRAN77でプログラムを書かれた方は意識しないで使用していると思います。
FORTRAN77では、
A=ABS(B)
I=ABS(J)
と書くと、実数型の A には実数型 B の絶対値を実数型で代入し、整数型の I には整数型 J の絶対値を整数型で代入してくれます。しかしながら呼び出す関数は ABS()と同じものを使用しています。これが関数のオーバーロードという機能です。
これだけの説明ではわかりずらいと思いますのでもう少し詳しく説明しましょう。
関数 ABC()はFORTRAN77の組み込み関数という関数でFORTRAN77の言語仕様に含まれているもので、引数が実数型なら実数型の絶対値をとる ABC()が使用され、引数が整数値のときには整数値の絶対値をとる ABC()が使用されるようにコンパイラが使用する関数を自動的に区別してくれるのです。
C言語では組み込み関数などというものはない上、引数の型のみを検査するだけなので ABC()という関数は1つしかないものだとしてコンパイルするのです。ですから上記のFORTRAN77のようには記述できず、
double a,b;
int i,j;
:
:
a=fabs(b);
i=iabs(j);
などとして関数をちゃんと区別してあげなければなりません。ところが人間にしてみれば、型はどうであれ絶対値をとりたいと考えるので言ってみれば不自然な訳です。そこでC++では関数のオーバーロードの機能を追加しました。この機能はFORTRAN77では組み込み関数だけでしたが、ちゃんとプロトタイプの宣言を行なえばどの関数でも行なえるようになっています。
#include <stdio.h>
double abs(double);
int abs(int);
void main()
{
double a,b;
int i,j;
b= -1.5;
j= -1;
a=abs(b); /* 実数型の abs が呼び出される */
i=abs(j); /* 整数型の abs が呼び出される */
printf("%f %d\n",a,i);
}
double abs(double a)
{
if(a>=0.0)return a;
else return -a;
}
int abs(int i)
{
if(i>=0)return i;
else return -i;
}
このように記述すると、abs は引数の型に応じて適正なものが使用されるようになるのです。これも使い慣れると便利な機能で、C++でプログラムを書くときには結構使っています。
C++はC言語とは違い、関数を呼び出すときにはその関数のプロトタイプが宣言されていなければなりません。どうでもいい関数についてもプロトタイプの宣言をしなければならないので結構面倒なのですが、逆に考えれば安心してプログラムが書けるのが利点と言えるかも知れません。しかしながらC言語のいい加減さが減少してしまいますので不便なときもあります。
整数値を文字型の配列に代入した例では、
#include <stdio.h>
void move_int_to_char(int *i,char *data);
main()
{
char data[sizeof(int)];
int i,n;
i=12345;
move_int_to_char(&i,data);
for(n=0;n<sizeof(int);n++)printf("%x ",data[n]);
printf("\n");
}
move_int_to_char(int *i,char *c)
{
char *t;
int n;
t=(char *)i;
for(n=0;n<sizeof(int);n++)c[n]=t[n];
}
と関数の型をちゃんと合わせておかないとリンク時にそんな関数はないぞと叱られてしまいます。ですので、引数の型を合わせておいて関数の内部でポインタを別の型のポインタにキャストしてから処理をしなければならなくなってしまいます。
関数のプロトタイプ宣言はある程度以上の大きさのプログラムを書くときにはつまらないミスを防ぐという点では利用しない手はありません。ちょっと面倒臭いのですが少しずつ試してみて下さい。
それではまた次回。