前回に引き続き,コンパイルの仕組を知るとともに, より正確なコーディングを目指そう. また,プリプロセッサの便利な機能について理解しよう.
プログラミング技術には3つのレベルがあるだろう:
良いプログラム ⊂ 正しいプログラム ⊂ 動くプログラム
そして,これまでに作成してきたC言語プログラムは, 実は,動くプログラムのレベルにしか過ぎないものだった. 今後のこの授業では,情報工学科としての最低限の必要レベルとして, 「正しいプログラムを書ける」ことを目標とする.
さらに,授業課題だけで満足せず,それ以上のレベル, 「良いプログラムが書ける」ことを努力目標とすべき. 実社会で通用するような良いプログラムを書けるようになるには, かなりの修行やセンスが必要. 学校の授業だけでは不十分なので,自主的に努力しよう.
良いプログラムの基準は,あまり明確ではないが, 第一に,「ソースコード的に美しく自然であること」 (無駄な処理が無い,処理の意図がわかりやすい,等) だと思われる.
動くプログラムではあるが正しくないプログラムの例を List 1 に示す.
sub() { printf("Hello World.\n"); } main() { sub(); }
まず,前回勉強した通り, #include <stdio.h> が不足していることに気づくだろう.
実際には,その他にも不備がある. どこがダメなのか?徹底的に調べるために, -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 に,コンパイル時に警告の出ない完全なソースを示す.
#include <stdio.h> void sub() { printf("Hello World.\n"); } int main() { sub(); return (0); }
再び,オプション -Wall 付きでコンパイルしよう. そして,警告が発生しないことを確認しよう:
$ cc hello-ok.c -o hello -Wall
この授業では,今後,ソースコードの「正しさ」の目安として, この機能を利用する.
List 2 の関数 main( ) の戻り値は, 一見,無意味であるように思える. しかし,これはプログラムの戻り値として利用され, そのプログラムを他のプログラムから呼び出す場合に, 正常に実行できたかどうかを判断するための材料として意義がある.
Unix では,コマンドが正常に終了した場合にはゼロ, 異常終了の場合にはゼロ以外の値を返すことが慣例となっている. この授業で利用しているシェル bash の場合, プログラムの終了状態(main( ) の戻り値)は, シェル変数 $? にセットされる.
では,この終了状態について,実際の操作を通じて理解しよう:
$ ./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); ... } |
なお,プロトタイプ宣言にある 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++) { ... }
また,後々に配列のサイズを変更したくなっても, マクロ定義の部分の数値を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)
実は,これでもまだ,万全ではない. 呼び出し方や引数の与え方によっては, 怪しい結果となってしまう場合がある.
同じように見えるが「マクロと関数は別物」 ということを十分理解して利用する必要がある.
#define inv(x) 1.0/x
この定義の問題点を指摘し,改善策を提案せよ.
cc -Wall で警告がまったく出ないようにすること. また,適切にマクロを活用すること. さらに,終了状態を適切に設定すること.
そして,「どこを」「どのように」「何のために」直したのか説明すること.