07 月 03 日(金)1-2h

コンパイラとプリプロセッサ

前回に引き続き,コンパイルの仕組を知るとともに, より正確なコーディングを目指そう. また,プリプロセッサの便利な機能について理解しよう.


動くプログラムとの決別

プログラミング技術には3つのレベルがあるだろう:

良いプログラム ⊂ 正しいプログラム ⊂ 動くプログラム
なお,関係式「A ⊂ B 」は「集合 A は集合 B の部分集合」という意味. たとえば,B がプロ野球選手全員,A は一億円プレーヤー. とか,B が釧路高専の全学生,A は情報工学科の学生.

そして,これまでに作成してきたC言語プログラムは, 実は,動くプログラムのレベルにしか過ぎないものだった. 今後のこの授業では,情報工学科としての最低限の必要レベルとして, 「正しいプログラムを書ける」ことを目標とする.

さらに,授業課題だけで満足せず,それ以上のレベル, 「良いプログラムが書ける」ことを努力目標とすべき. 実社会で通用するような良いプログラムを書けるようになるには, かなりの修行やセンスが必要. 学校の授業だけでは不十分なので,自主的に努力しよう.

良いプログラムの基準は,あまり明確ではないが, 第一に,「ソースコード的に美しく自然であること」 (無駄な処理が無い,処理の意図がわかりやすい,等) だと思われる.

動くプログラムではあるが正しくないプログラムの例を List 1 に示す.

List 1. 動くが正しくないプログラムの例 hello-ng.c
sub()
{
	printf("Hello World.\n");
}

main()
{
	sub();
}

まず,前回勉強した通り, #include <stdio.h> が不足していることに気づくだろう.

「足りなくてもコンパイルできるし動く」ということも知っている. しかし,これは正しくないので,これからは NG.

実際には,その他にも不備がある. どこがダメなのか?調べるために, -Wall オプションを付けてコンパイルしてみよう:

$ cc  hello-ng.c  -o hello  -Wall
hello-ng.c:2: 警告: return type defaults to ‘int’
hello-ng.c: In function ‘sub’:
hello-ng.c:3: 警告: implicit declaration of function ‘printf’
hello-ng.c:3: 警告: incompatible implicit declaration of built-in function ‘printf’
hello-ng.c: トップレベル:
hello-ng.c:7: 警告: return type defaults to ‘int’
hello-ng.c: In function ‘main’:
hello-ng.c:9: 警告: 制御が非 void 関数の終りに到達しました
hello-ng.c: In function ‘sub’:
hello-ng.c:4: 警告: 制御が非 void 関数の終りに到達しました

たくさんの 警告メッセージ(warning)が表示された. これは,「ソースに不備があったけど,直しておいてやったゼ」 というコンパイラからの報告だ.

「警告」と「エラー」とは別物だ.

ところで,このような自動修正が嬉しい場合もあるが, いつでも期待通りの修正を行なってくれるとは限らない. 自動修正によって, かえって問題点が増えてしまう「余計なお世話」の場合もある. また,コンパイラの種類やバージョンによっては, まったく異なる動作をするかもしれない. したがって,本当にプロフェッショナルなプログラマには, 「常に正確なソースコードを書く」姿勢が求められる. 「とりあえず動けば十分だろ?」という甘い考えでは不十分.

いないとは思うが,もしかして... 「コンピュータは頭が良いので, 人間の間違いをすべて的確に自動修正してくれるハズだ」 という考えはまったく的外れだ. プログラムは思い通りには動かない. 書いた通りに動く.それだけだ.

そもそも,コンピュータが思い通りに動くならば, プログラミングの作業なんて必要ないハズだ. が,現実には,誰か人間がプログラミングしなきゃ動かないだろ?


正しいプログラムへの道

List 1 のコンパイルによって表示された警告の意味とその対策:

なお,関数 main( ) に対しても同様な警告が発生しているが, main( )void 型にしてはいけない. 実は,main( ) は必ず int 型でなければならない. (次のセクションで説明する.)

理解するには,ひとつずつ対策して,コンパイルを繰り返してみるとよい.

List 2 に,コンパイル時に警告の出ない完全なソースを示す.

List 2. 正しいプログラムの例 hello-ok.c
#include <stdio.h>

void sub()
{
	printf("Hello World.\n");
}

int main()
{
	sub();

	return (0);
}
List 2 は,あくまでも「正しい」ソースコードの一例に過ぎない. 「良い」コードかどうか?については, この例では,そもそも機能的にまったく無意味なプログラムなので, 気にしないこと.

再び,オプション -Wall 付きでコンパイルしよう. そして,警告が発生しないことを確認しよう:

$ cc  hello-ok.c  -o hello  -Wall

ちなみに,-Wall とは,「警告(warning)を すべて(all)表示してね」という意味.

この授業では,今後,ソースコードの「正しさ」の目安として, この機能を利用する.

さらに厳密にチェックするためのオプション -pedantic もある.

メイン関数の戻り値

List 2 の関数 main( ) の戻り値は, 一見,無意味であるように思える. しかし,これはプログラムの戻り値として利用され, そのプログラムを他のプログラムから呼び出す場合に, 正常に実行できたかどうかを判断するための材料として意義がある.

Unix では,コマンドが正常に終了した場合にはゼロ, 異常終了の場合にはゼロ以外の値を返すことが慣例となっている. この授業で利用しているシェル bash の場合, プログラムの終了状態main( ) の戻り値)は, シェル変数 $? にセットされる.

シェルとは,文字端末のウィンドウ内で自動的に起動されている コマンドを受け付けるプログラムだ. シェルについての課題は,後期の「情報工学実験I」で登場予定.

では,この終了状態について,実際の操作を通じて理解しよう:

$ ./hello
Hello World.

$ echo  $?
0		# 直前の hello プログラム の return 値

$ ls
hello	hello.c

$ echo  $?
0		# 直前の ls コマンド の return 値(正常終了だったので 0)

$ ls  hoge	# 存在しないファイルを指定してみる
ls: hoge: No such file or directory	# 訳:そんなファイルは無いぜ

$ echo  $?
2		# 直前の ls コマンド の return 値(異常終了だったので 0 以外)

$ echo  $?
0		# これは直前の echo コマンド の return 値

なお,プログラムの終了状態を設定するには,大抵の場合, 次のようなパターンのソースコードを記述することになる:

int main()
{
	...

	if ( 異常発生? ) return (1);	// 異常終了
	...

	return (0);	// 正常終了
}

プリプロセッサ

Cコンパイラ cc を実行すると, コンパイル作業(ソースからオブジェクトへの変換)の前に, Cプリプロセッサcpp) が自動的に実行されるようになっている.

たとえば,前回学んだ #include は, 実は,コンパイラ自身ではなく,プリプロセッサによって処理される. プリプロセッサは,Fig.1 のように, ソースファイルの #include の行を ヘッダファイルの内容に置き換えてくれる. そして,この出力ファイルがコンパイラへ引き渡されることになる.

ソースファイル sin2x.c
#include <math.h>

int main()
{
	...
	y = 2.0*sin(x)*cos(x);
	...
}
ヘッダファイル math.h
...
extern double sin(double);
extern double cos(double);
...
cpp
プリプロセッサの出力ファイル
...
extern double sin(double);
extern double cos(double);
...

int main()
{
	...
	y = 2.0*sin(x)*cos(x);
	...
}
Fig.1. プリプロセッサの動作例

なお,プロトタイプ宣言にある extern は, その関数が外部的(external)に, つまり,他のファイルの中で定義されていることを表わしている. 他のファイルとは,前回学んだライブラリオブジェクト等だ.

この辺りについては,後期の情報工学実験I で取り上げる予定なので, 今は,気にしなくてよい.

プリプロセッサ全般について,教科書 pp.107-111 にも説明がある.


マクロ

プリプロセッサには, #include 以外にも便利な機能がある. ここでは,#define を紹介する.

たとえば,ヘッダファイル /usr/include/math.h では, 次のような記述(マクロ定義)がある:

#define M_PI	3.14159265358979323846

このため,ソースコードの中で円周率πを使いたい場合には, その正確な値 3.1415... をおぼえていなくても, 定数名 M_PI の記述(マクロ呼び出し) だけで済むことになる:

#include <math.h>
...
int main()
{
	...
	c = 2.0*M_PI*r;
		// プログラマが書いたこのコードは...
	...
}

このソースをコンパイルする際, Cプリプロセッサが M_PI から 3.1415... への 置き換え(マクロ展開)を自動的に実行してくれる.

つまり,プログラミングの際に, 値を間違える危険性(リスク)や調べ直す手間(コスト)を削減できる.

その結果,次のようなソースコードがコンパイラへ引き渡される:

int main()
{
	...
	c = 2.0*3.14159265358979323846*r;
		// ...プリプロセッサがこのように書き換える
	...
}
他にも多くの便利な定数名が様々なヘッダファイルの中で定義されている.

定数の意味を明示し,プログラムを解読しやすくすることも定数名の役割だ. たとえば,次のソースコードでは, 100 という即値が2ヶ所に書かれている:

int data[100];
...
for (i = 0; i < 100; i++) {
	...
}

配列の要素数の 100 と反復回数の 100 との関連性は? 同じ意味をもつものなのか?それとも単純に偶然に値が等しいだけなのか?

一方,次のソースコードであれば, 配列 data[ ] に関する繰り返し処理であることが 容易に予想できるだろう:


#define N_DATA 100
...
int data[N_DATA];
...
for (i = 0; i < N_DATA; i++) {
	...
}
ちなみに,N_DATA は number of data(データの個数)の略だ.

また,後々に配列のサイズを変更したくなっても, マクロ定義の部分の数値を1ヶ所だけ書き換えれば済むので, 楽だし,間違いも少なくできるだろう.

「即値(定数の値そのまま)がすべて悪い」と言っている訳ではない. 無駄な作業が発生しないのであれば,即値でも構わない.

なお,マクロと変数とを混同しないこと! 変数のように,プログラムの実行中に値を変えることはできない.

#define  X  0		// グローバル変数 int X = 0; と同じに見えるが...

void sub(int X)		// 間違い!!変数ではないので,仮引数にはできない(int 0 に展開される)
{
	...
}

int main()
{
	...
	sub(X);		// 実引数なら無問題
	...
	X = X + 1;	// 間違い!!変数ではないので,代入式の左辺には使えない(0 = 0 + 1 に展開される)
	...
}

「マクロはコンパイルの前段階(実行よりもコンパイルよりも前) に展開されることに注意せよ. 実行時(コンパイルの後)に展開されるわけではない. そして,どのようなコードに展開されるのか?想像しよう.

なお,マクロと変数との無用な混乱を避けるため, マクロ名は大文字変数名は小文字に, 書き分けるのが慣例となっている. 「理不尽な因習」に囚われてはならないが, 「有意義な慣例」には従おう. 従わないと,上記のような間違いがなくならない.


引数付きマクロ

マクロには,関数と同様に,引数を与えることもできる.

次のソースコードは2乗を計算する引数付きマクロ sqr( ) の例だ:

#define sqr(x) x*x		// マクロ定義
...
int a;
a = sqr(5);		// マクロ呼び出し
マクロ名と括弧の間には空白を入れないこと.

このコードは,プリプロセッサによって,次のように展開される:

...
int a;
a = 5*5;		// マクロ展開

ところで,外見的には, 引数付きマクロの呼び出しと関数の呼び出しとは区別がつかない. たとえば,上のマクロ定義を次の関数定義に置き換えても,実行結果は同じである:

int sqr(int x)			// 関数定義
{
	return (x*x);
}

しかし,仕組的には,両者はまったく異なっている. 関数では,実行時(コンパイル後)に, データ(引数や戻り値)が書き換えられる. また,引数等のためにメモリ使用量も増える.

一方,引数付きマクロでは,実行前(コンパイル時)に, ソースコード(計算式)が書き換えられる. 実行時に余計なメモリを使用することはない.

このような仕組の違いから,引数付きマクロは, 関数よりも実行速度・メモリ使用量の点で有利になる. しかし,その利用にあたっては細心の注意が必要である. たとえば,a + b の2乗を計算したい場合, 次のようなマクロでは期待した結果を得られない:(ダメな例

#define sqr(x) x*x
...
c = sqr(a + b);

なぜなら,これは,次のように展開されてしまうためだ:

c = a + b*a + b;

期待される計算式 c = (a + b)*(a + b) を得るためには, 次のようにマクロ定義する必要があるだろう:(マシな例だが,まだ不完全)

#define sqr(x) (x)*(x)

実は,これでもまだ,万全ではない. 呼び出し方や引数の与え方によっては, 怪しい結果となってしまう場合がある.

たとえば,1/sqr(2) がどのように展開されるか? 期待される計算式と展開される計算式とを比較してみよう. そして,対策を考えよう.

同じように見えるが「マクロと関数は別物」 ということを十分理解して利用する必要がある.

ミス防止のため,引数マクロ化するのは非常に単純な処理 (しかも,頻繁に利用する予定のもの) だけにとどめておくのが無難. ある程度複雑な処理の場合には,関数化がよい. 適切に使い分けよう.

本日の課題

  1. 逆数を求めるためのマクロとして,次のように定義した.

    #define inv(x) 1.0/x
    

    この定義の問題点を指摘し,改善策を提案せよ.

    ヒント:
    • たとえば,inv(a + b)b/inv(a)inv(inv(a)), 等々を計算する場合を考えよう.
    • 元のマクロがどのように期待はずれに展開されるのか?説明しよう.
    • そして,あらゆる場合に通用するようなマクロ定義の改良版を1個だけ提案しよう.
    • 実験用ソースコードとしては,たとえば, printf("%f\n", inv(2.0) ); のようにすればよい.

  2. 二進数を十進数に変換するプログラムbin2dec.c のソースコードを「正しいプログラム」に書き直せ.
  3. cc -Wall で警告がまったく出ないようにすること. また,適切にマクロを活用すること. さらに,終了状態を適切に設定すること.

    そして,「どこを」「どのように」「何のために」直したのか説明すること.

レポート提出 注意事項: 以下の点についても厳しくチェックする:


(c) 2015, yanagawa@kushiro-ct.ac.jp