07 月 01 日(水)

コンパイルの仕組

これまでに作ってきたプログラムは, コンパイルの結果,問題なく動作していた(ハズだ)が, コンパイル時に多数の警告warning) が発生していた. つまり,「動くプログラム」ではあったが, 実は,「正しいプログラム」とは言えないものだった.

今回は,Cコンパイラの動作について理解し, 正しいプログラムへ近付いて行こう.


コンパイラの3手順

Fig.1 は,これまでのコンパイル作業のイメージ図である. コンパイラ cc を実行すると, ソースファイル1個から プログラムファイル1個が生成されていた.

専門用語の確認:たとえば,sum.c とかがソースファイルsuma.out とかがプログラムファイル
Fig.1. コンパイラの動作の概要

しかし,実際のコンパイラの動作は,もう少し複雑であり, これからは Fig.2 のようにイメージしよう. 実は,複数のファイル(ソースファイル等)から1個のプログラムを生成している.

Fig.2. コンパイラの動作の詳細

コンパイラ cc を実行すると,内部的には, 次の3段階の処理が実行される:

  1. インクルード: 複数のソースファイルのそれぞれに, ライブラリのヘッダファイルを取り込む.
  2. なお,ヘッダファイルには, ライブラリ関数のプロトタイプ宣言などが記述されている.
  3. コンパイル: インクルードした各ソースファイルをそれぞれ, オブジェクトファイルへ変換する.
  4. リンク: コンパイルした全オブジェクトファイルと ライブラリのオブジェクトファイルとを連結し, 1 個のプログラムファイルを生成する.
  5. なお,ライブラリオブジェクトとは, コンパイル済みの関数の集合体だ.

以下,それぞれの構成要素について,詳しく見て行こう.


関数プロトタイプ

変数を使う場合には,変数の型を事前に宣言しなければならなかった. たとえば:

... {
	int  i;			// 変数 i を宣言「このブロック内で int 型の変数 i を使うよ」

	for (i = 0; ... ) {	// 変数 i を実際に使用
		...
	}
	...
}

変数と同様に,関数を使う場合にも, これまでは省略してきたが, 実は,関数の型を宣言しなければならない.

これでは「正しいプログラム」ではなかったので, コンパイル時にたくさんの警告を受けていた,という訳だ.

また,関数の場合には,関数の型(戻り値の型)だけでなく, 引数の型の宣言も必要である. これらは,関数のプロトタイプ宣言と呼ばれている. プロトタイプ宣言を含むソースファイルの構成例を List 1 に示す.

List 1. プロトタイプ宣言の必要な例 inv-1.c
double inv(double x);	// 関数のプロトタイプ宣言(呼び出しより前に必要)

main()
{
	double  x, y;

	printf("実数 > ");
	scanf("%lf", &x);	// 実数の入力

	y = inv(x);	// 関数の呼び出し

	printf("逆数:%f\n", y);
}

/* x の逆数 */
double inv(double x)	// 関数の定義
{
	return (1.0/x);
}
注意:scanf( ) での実数の入力には 変換指定子「%lf(エルエフ)」を使うこと. 一方,printf( ) での実数の出力には 「%f(エフだけ,エルなし)」だ. 間違い易い.

まず,このままコンパイルして,実行できることを確認しよう.

その後,プロトタイプ宣言の行を削除 or コメント化してから, 再度,コンパイルしてみよう. コンパイルエラーとなるハズだ.

次に,List 2 のように書き換えてから,またコンパイルしてみよう. 今度はうまく行くハズだ.

List 2 プロトタイプ宣言の不要な例 inv-2.c
// double inv(double x);	// 関数のプロトタイプ宣言

/* x の逆数 */
double inv(double x)	// 関数の定義 & プロトタイプ宣言
{
	return (1.0/x);
}

main()
{
	...
	y = inv(x);
	...
}

つまり,関数の定義がその呼び出しよりも前に書かれている場合には, プロトタイプ宣言を書く必要はない. この場合は,定義自身が宣言を兼ねることになる.

List 2 は,これまでに使って来たスタイルだ. このスタイルでも,定義とは別に, プロトタイプ宣言の文を追加しても問題ない:

double func(...);		// 宣言(不要だが,あってもよい)

double func(...)		// 定義
{
	...
}

↑ しかし,ほぼ同じコードを2回も書くのは無駄なので,やめよう. 宣言と定義とを1回にまとめるべきだ.

なお,書き順だけで済むなら,プロトタイプ宣言なんて必要ねーだろって? いや必要だ. 次のように,書き順だけでは解決不可能な状況もありえる:

double func1(...);		// 宣言(必要ない)

double func2(...);		// 宣言(必要)

double func1(...)
{
	...
	func2(...);
	...
}

double func2(...)
{
	...
	func1(...);
	...
}

↑ 宣言がない場合,どっちを先に定義すべきなんだー?

なお,関数のプロトタイプ宣言は, List 1 のようなグローバル宣言の代わりに, 次のようなローカル宣言であっても問題ない:

main()
{
	...
	double inv(double x);
	...
}
ここで,ローカル/グローバルの意味は, 前回の変数のスコープと同じ.

その他,暗黙の型宣言(教科書 pp.88-89) によって宣言が不要な場合もあるが, これからは必ず,プロトタイプを意識しよう.

なお,関数 main( ) についても, プロトタイプ宣言が必要なのだが, それについては後日.

ライブラリヘッダのインクルード

関数には,ソースファイル中で定義する「ユーザ関数」の他に, 事前に定義されている「ライブラリ関数」がある. プロトタイプ宣言は,ユーザ関数だけでなく, ライブラリ関数を呼び出す場合にも必要である.

これまでは,宣言なしでも動かせるようなライブラリ関数 (printf( )scanf( ) など, 暗黙の型宣言が有効なライブラリ関数) しか使っていなかったため,問題無く動いているかに見えた. (コンパイル時に警告は受けたものの,実行はできていた.) しかし,これからは違う. プロトタイプ宣言しないと動かせないものも使って行く.

ただし,ライブラリ関数のプロトタイプ宣言については, ソースファイル内に,いちいち記述する必要はない. 大抵のライブラリには, プロトタイプ宣言を収録したヘッダファイルが付属しており, ソースファイルでは, そのヘッダファイルの取り込みを指示するだけで済むようになっている:

#include  <ヘッダファイル.h>

これでコンパイル時に,この #include の行が, ヘッダファイルの内容(プロトタイプ宣言)へ置き換えられることになる.

関数の定義(処理内容)がヘッダに収録されているわけではない. ヘッダファイルにあるのはプロトタイプ宣言の部分だけだ. 関数の「宣言」と「定義」とを混同しないこと. 宣言はヘッダファイル定義はオブジェクトファイルに収録されている.

たとえば,printf( )scanf( ) といった 標準入出力関数のプロトタイプは, ヘッダファイル stdio.h に記述されている. List 2 を List 3 のように書き換えてみよう.

List 3. 正しいプログラムの例 inv.c
#include <stdio.h>	// printf() と scanf() のプロトタイプ宣言

/* x の逆数 */
double inv(double x)
{
	...
}

main()
{
	...
	printf(...);
	scanf(...);
	...
}

これをコンパイルすると, これまで発生していた警告メッセージが減ったハズだ. これで,正しいプログラムに一歩,近付いた.

他のよく使うヘッダファイルとしては, stdlib.hstring.hmath.h,等がある. これらのヘッダファイルは,大抵の Unix システムでは, ディレクトリ /usr/include に収められている. どんなものがあるのか,確認してみよう:

$  ls  /usr/include  |  less
less については, ここを参照して, 使い方をおぼえておこう.

ヘッダファイルによっては,プロトタイプ宣言以外の情報も記載されていたり, さらに他のヘッダファイルを取り込んでいたりするので, 実際は,ここでの説明ほど単純なものではない. しかし,それらの内容を眺めておくとよいだろう. たとえば,stdio.h の内容を見るには:

$  less  /usr/include/stdio.h
ちなみに stdio は,標準入出力(STanDard Input/Output)という意味だ.

このヘッダファイル内のどこかに, printf( )scanf( ) のプロトタイプ宣言が 記述されているハズだ. 検索してみよう.

stdio.h の内容はかなり複雑.だが,今は気にしなくてよい. 「あー確かに,printf( ) などが宣言されてるなー」 程度の理解だけで充分.

ライブラリオブジェクトのリンク

ライブラリ関数の定義(処理内容)は, 事前にコンパイルされた状態で, オブジェクトファイルに収録されている. したがって,ライブラリ関数を使うプログラムの実行には, ライブラリオブジェクトとのリンク(連結)が必要である.

関数の「宣言」と「定義」とを混同しないこと. 宣言(引数等の型)はヘッダファイル定義(処理内容)はオブジェクトファイルに収録されている.

大事なことなので,2回書きました. (が,ソースコードでは,2回も同じことを書かないこと.)

List 4 は数学ライブラリ libm を利用したプログラムの例である.

ここでは,平方根 sqrt( ) だけを使っている. 他の数学関数については, 教科書 pp.204-205pp.315-316 を参照. プロトタイプ宣言は /usr/include/math.h に収録されている.
List 4. 数学ライブラリの利用例 sqrt.c
#include <stdio.h>
#include <math.h>	// 数学関数のプロトタイプ宣言

main()
{
	double  x, y;

	printf("実数 > ");
	scanf("%lf", &x);

	y = sqrt(x);	// 平方根

	printf("平方根:%f\n", y);
}

このソースコードには,エラーはないのだが, 処理系によっては(Linux 等では), これまで通りの方法ではコンパイルできない:

$  cc  sqrt.c  -o sqrt
/tmp/ccSQVZUd.o: In function `main':
sqrt.c:(.text+0x43): undefined reference to `sqrt'	# エラー「sqrt() が定義されてないゼ」
collect2: ld はステータス 1 で終了しました

正しくは,次のコマンドでコンパイルしよう:

$ cc sqrt.c -o sqrt -lm

文字に注意:「-1(いち)」でなく「-l(エル)」だ.

一般に,ライブラリ libXX をリンクするには, cc コマンドに -lXX を付ける.

これで,数学ライブラリ libm の オブジェクトファイル /lib/libm.so/lib/libm.a がリンクされ, 実行可能なプログラムファイル sqrt が完成する.

ライブラリオブジェクトには2つの方式があり, 拡張子「.a」は静的リンクライブラリ, .so」は動的リンクライブラリ(共有ライブラリ) と呼ばれるモノだ. どちらか一方がリンクされる.

なお,前回までに利用してきたライブラリ関数 (printf( )scanf( ),等)については, 標準ライブラリ libc に収録されている. そして,この標準ライブラリについては, コンパイル時に -lc を特に指定しなくても, 自動的にリンクされることになっている.


ライブラリの作成例

さらに理解を深めるために, ライブラリを作成・利用してみよう.

List 5 および 6 は,統計ライブラリ libstat のソースおよびヘッダである.

List 5. 統計ライブラリのソースファイル stat.c
#include <stdio.h>
#include <math.h>
#include "stat.h"	// このヘッダファイルも自分で作る

// データ入力
int input(double *x, int m)
{
	int  i, n;

	printf("データの個数(%d 個以内) > ", m);
	scanf("%d", &n);

	printf("%d 個の実数 > ", n);
	for (i = 0; i < n; i++) {
		scanf("%lf", &x[i]);
	}
	return (n);
}

// 合計
double sum(double *x, int n)
{
	int  i;
	int  s = 0;

	for (i = 0; i < n; i++) {
		s += x[i];
	}
	return (s);
}

// 平均
double average(double *x, int n)
{
	return (sum(x, n)/(double)n);
}

// 分散			// 未完成(練習問題)
double variance(double *x, int n)
{
	double  a;
	double  s = 0.0;

//	a = average(...);
//	...

	return (s/n);
}

// 標準偏差
double stddev(double *x, int n)
{
	return (sqrt(variance(x, n)));
}
おや?これまでとは異なり,List 5 には main() が含まれていない. そう,ライブラリのソースファイルには,メイン関数は不要. ライブラリのソースでは,プログラムの部品だけが定義される. メイン関数は別のソースファイル (各アプリケーションプログラムのソースファイル) で定義される.
List 6. 統計ライブラリのヘッダファイル stat.h
// データ入力
int input(double *x, int m);

// 合計
double sum(double *x, int n);

// 平均
double average(double *x, int n);

// 分散
double variance(double *x, int n);

// 標準偏差
double stddev(double *x, int n);
プロトタイプ宣言の末尾にはセミコロン「;」が必要.付け忘れ注意.

まず,これらをコンパイルし,ライブラリオブジェクトを生成しておく:

$  cc  -c  stat.c  -o libstat.a

$  ls
stat.c	libstat.a
コンパイルオプション -c では, インクルードとコンパイルのみ実行し,リンクを実行しない. ライブラリオブジェクトはプログラムの部品の詰め合わせセットであって, プログラムの完成品ではないので, これ単独では実行できない.

List 7 は,このライブラリを利用するテストプログラムである.

List 7. 統計ライブラリのテストプログラム stattest.c
#include <stdio.h>
#include "stat.h"	// 統計ライブラリのヘッダファイルのインクルード

main()
{
	double  x[256];
	int     n;

	n = input(x, 256);	// 統計ライブラリの関数の呼び出し
	printf("平均 = %f\n", average(x, n));
	printf("分散 = %f\n", variance(x, n));
	printf("標準偏差 = %f\n", stddev(x, n));
}

テストプログラムのコンパイル:

$  cc  stattest.c  -I.  -L.  -lstat  -lm  -o stattest
作成したライブラリ libstat-lstat でリンク. なお,libm は, stattest.c では,直接には使われていないが, libstat の方から使われているので,-lm も指定. カレントディレクトリにあるヘッダのインクルードとオブジェクトのリンクのため, -I.(アイ)と -L. も指定.

scanf の書式

突然だがここで,関数 scanf( ) についてまとめておく. 一般的な利用形式は次の通り.

scanf(書式文字列, アドレス, アドレス, ...);

主な変換指定子:

scanf( )printf( ) の変換指定子は, 「おおむね同じ」だが... 「まったく同じ」ではない. (部分否定.「まったく異なる」といっているわけでもない.) 特に,今回初登場の実数入力については,例外的なので要注意. printf( ) の変換指定子 と比較しよう.

ところで,scanf( ) の入力データは, 引数に指定されたアドレスのメモリ領域に記録される. したがって,大抵の場合,引数のアドレスには, 変数へのポインタか文字配列の名前を使うことになる.

使用例:

int    i;
double d;
char   c;
char   s[256];

scanf("%d %lf %c %s", &i, &d, &c, s);
ここで,scanf( ) は, 文字配列 s[ ] だけ「&」を付けずに, 特別扱いしているように見える. しかし,それは外見的(記述的)に違うというだけだ. 「アドレス」という意味では, 他のデータ型とまったく同じ扱いだ. (配列とアドレスの関係を思い出そう.)

関数 scanf( ) についての詳しくは, 教科書 pp.191-194 を参照しよう.


練習問題

List 5-7 の分散関数を完成せよ.

分散={Σi(データi ー 平均)2}÷ 個数
自乗(2乗)の計算には,かけ算を使おう. 例:x2=x*x.

ちなみに,分散および標準偏差は,データの分布の広がり度合いの指標である. 詳しくは,4J の授業「確率統計」で学習予定.

分散・標準偏差は,データのバラつきが少ない場合には小さな値, バラつきが多い場合には大きな値となる. 極端な例としては全データが等しい場合,分散・標準偏差はゼロ.

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