4. 分割したファイルでのプログラミング

【プログラミング序論D】 目次, 講義ページ, 授業科目, www.kameda-lab.org 2017/01/12a

関数サブルーチンを使うようになって,プログラム部品の再利用を始めると,部品によってはもう2度と書き直さないでずっと使いつづけるという状況が出てきます.
(より正確には,そういう状況をあちこちで作り出して楽をするために,サブルーチン化を行うのです)

こうなってくると,もう書き直さないプログラム部品については,別のファイルに移して,不用意に編集しないようにしておくほうが無難です.
(こういうことは,プログラム開発効率向上のためには重要なことです)

また,場合によっては,複数のプログラマがそれぞれ別の関数を分担して書くことが考えられます.
(関数ごとに担当者を決めて,担当者ごとに別のファイルで書いていったりするのが基本形ですね)

いずれの場合にも,本節で取り上げるファイル分割によるプログラミングが役立ちます.
(以前の講義で習ってるはずです.その意味で,本節は復習ですね)

プログラムをファイル分割して記述すると,その分割されたソースファイル毎にオブジェクトファイルを生成する(コンパイル)ことになります.
出来上がった複数のオブジェクトファイルを集めて,最終的に1つの実行ファイルを作成(リンク)します.

プログラム作成スタイルは,この複数のオブジェクトファイルをいつの時点で集めるかで,大きく2つの方法に別れます.
(1) オブジェクトファイル群を適宜生成して,リンクする方法(通常のリンク作業)
(2) オブジェクトファイル群を集めて一旦ライブラリを作成し,それを使ってあとから書いたプログラムとリンクする方法(ライブラリを用いたリンク)

本節ではその両方について学習します.


04.1. C言語プログラムのソースファイル分割

C言語では,原則として,ソースファイル分割は,関数単位でなら可能です.
1つのソースファイルに複数の関数を記述できます.
(これの究極の姿が3.4節のサンプルプログラムですね)
逆に,1つのソースファイルで関数を1つだけ書くようにしても構いません.
(ファイル数はたくさんになるでしょうが‥)
特に明文化されたルールはないですが,概ね機能が似ている関数は同じファイルに入れておくほうがよいとされています.

ファイル分割したプログラムソースは,ファイル分割毎にコンパイルをし,オブジェクトファイルを作成します.
ということは,コンパイルに必要なだけの情報は各ファイルが保持している必要があります.
そのため,それぞれの分割ソースファイルでは,中で使うライブラリ関数に合わせてヘッダファイルを用意します.
(例えばprintf()を全く使わないソースファイルではstdio.hをインクルードする必要はありません)
(入っていても特に実害はないようにstdio.hは設計されてますが,不要なものは入れないほうがよいでしょう)

【ポイント】
ソースファイル分割をすると,1つのソースファイルだけを見ていると知らない(わからない)関数が出てきます.
下記の単純化した例をみてみましょう.
main()関数が入ったソースファイル(split-main.c)だけ見ても,operator_add()関数は呼び出す側としては書いていても,その形式や機能に関する手がかりは全くありません.
分割コンパイルでは,関数の「形式」と「機能(function body)」とは分けて考えます.
関数の「形式」とは,その関数の名前,引数の数と型,そして関数自身の返値の型で定義されます.これをfunction prototypeと言います.
関数の「形式」は,お互いにその知識を共有しなくてはいけないので,split-main.c と split-sub.c の両方で同一表現である必要があります.
(関数サブルーチンを今まで書いてきたときの,中括弧が始まるまでの部分のことで,下記例では茶色の部分ですね)
(関数の引数については,型と並びが同じなら,変数名まで同じである必要はありません)

分割前 (full.c) 分割後 (split-main.c) 分割後 (split-sub.c)
#include <stdio.h>
float operation_add(float v1, float v2) {
  return v1 + v2;
}

int main(int argc, char *argv[]){
  float val1=14.2;
  float val2=3.4;
  printf("result = %f\n", operation_add(val1, val2));
  return 0;
}
#include <stdio.h>
float operation_add(float v1, float v2) ;



int main(int argc, char *argv[]){
  float val1=14.2;
  float val2=3.4;
  printf("result = %f\n", operation_add(val1, val2));
  return 0;
}

float operation_add(float v1, float v2) {
  return v1 + v2;
}






Function prototypesは,上記のsplit-main.cでのように明示的に書いてもよいのですが,あちこちで参照することになるので,まとめて一つのファイルに書いておく習慣があります.
このfunction prototypesを集めて保持するのがヘッダファイルの役割の1つです.
ここでは,04-01-SplitHead.hというファイルに集めることにします.
そうすると,分割ソースは下記のようになります.
分割前 (04-01-Full.c) ヘッダ使用しての分割後 (04-01-SplitHead-Main.c) ヘッダ使用しての分割後 ( 04-01-SplitHead-Sub.c) ヘッダファイル (04-01-SplitHead.h)
#include <stdio.h>
float operation_add(float v1, float v2) {
  return v1 + v2;
}

int main(int argc, char *argv[]){
  float val1=14.2;
  float val2=3.4;
  printf("result = %f\n", operation_add(val1, val2));
  return 0;
}
#include <stdio.h>
#include "04-1-SplitHead.h"



int main(int argc, char *argv[]){
  float val1=14.2;
  float val2=3.4;
  printf("result = %f\n", operation_add(val1, val2));
  return 0;
}

float operation_add(float v1, float v2) {
  return v1 + v2;
}






float operation_add(float v1, float v2) ;
// Function prototype宣言では関数の引数名に意味はないので,
// float operation_add(float, float) ;
// でも可.

04-01-SplitHead-Main.cから,ヘッダファイルのincludeの仕方に2通りあることがわかります.
ヘッダファイルが < > で囲まれている場合は,コンパイラ(の導入時)に設定されたルールに従ってコンパイラがどこかから探してきます.
ライブラリ関数のヘッダファイルなどがこれに相当します(例:stdio.h, math.hなど.)
一方,ヘッダファイルが " " で囲まれている場合は,コンパイル時にユーザが明示的にどこのディレクトリを探してきてほしいかを指定します.
ただ一つの例外がカレントディレクトリで,特に指定しなくても," "で囲まれたファイルがそこにあればそれを自動的に読み込みます.

ということで,分割コンパイルは,04-01-SplitHead-Main.c, 04-01-SplitHead-Sub.c, 04-01-SplitHead.hをカレントディレクトリに置いた状態で:

$ gcc -Wall -c 04-01-SplitHead-Main.c (オブジェクトファイル 04-01-SplitHead-Main.o が作成される)
$ gcc -Wall -c 04-01-SplitHead-Sub.c (オブジェクトファイル 04-01-SplitHead-Sub.o が作成される)
$ gcc -Wall -o 04-01-Split-Go 04-01-SplitHead-Main.o 04-01-SplitHead-Sub.o (実行ファイル 04-01-Split-Go が作成される.オブジェクトファイルの並び順は任意)
$ ./04-01-Split-Go
という手順になります.
このとき,最初2行では gcc コマンドはコンパイラとして機能しています.
3行目ではコンパイルを行っておらず,gcc コマンドはリンカとしてのみ機能しています.
(違うコマンドになっていたほうが,わかりやすいと個人的には思うのですが,これもいろいろ事情があってGNU compilerでは同じ名前になっています)

【動的ライブラリ】
上記手順の3番目の04-01-Split-Goの生成で実行ファイルが生成されていますが,実は最近の開発実行環境ではこれは最終的な実行ファイルではありません.
さらに,上記手順の4番目でエンターキーを押した瞬間に,幾つかの動的ライブラリが付加され,ようやくアプリケーションプログラムとして実行できる状態になります.

$ ldd 04-01-Split-Go
とすることで,どれだけの動的ライブラリを実行時に呼び出す予定かがわかります.
(動的ライブラリの対応が1つでも欠けていると実行できません)
(OSの機能として使わせてもらうダイナミックライブラリには対応するファイル名がありません.アドレスのみ表記されます)
(右端は実際のメモリ空間中でどこに配置されるかを示すアドレスです.何ビットでアドレスが表現されていますか?)

演習

04-01-ex1: 本講義でターゲットにしているUbuntu Linuxは何bit OSか.調査方法とともに示しなさい.

04-01-ex2: 上記のex1から,本講義でターゲットにしているUbuntu Linuxは,論理的に最大 何bit のアドレス表記まで対応するでしょうか.

04-01-ex3: 上記のex1から,本講義でターゲットにしているUbuntu Linuxでは,論理的に最大 何バイトの実メモリを扱えることになるでしょうか.


04.02. 簡易電卓・分割コンパイル版

今度は,04.01.節と同じ手法を03.04.節の簡易電卓プログラム(03-04-SimpleCalculator.c)に適用してみましょう.
一度は自分でやってみて,それから内容の確認をするように.
(要望が多いので適用した結果も下記に載せてますが,安易に結果を見てしまうと学習効果があがらず,結局自分の首を絞めますよ.)

簡易電卓プログラムのソースファイル分割について,以下のような方針を立てましょう.
(1) 加減乗除の基本4演算に対応する4つの関数が入ったソースファイル.これは1度書いたら,おそらく二度と手を加えないでしょう.
(その意味では,誰が書いても似たようなソースになると思われるので,自分で書かずに,他の人の手になるものでもいいかもしれませんね)
(2) それ以外の新しい演算に対応する関数が入ったソースファイル.
(これはオリジナルな関数を用意する場合,ここに書きましょう,という部分ですね)
(3) 上記(1)(2)の関数プロトタイプをまとめたヘッダファイル.
(もし(1)(2)に新しい関数を追加したら,ここに対応する関数プロトタイプを追加していく必要があります)
(4) main()関数が入ったソースファイル.これはユーザインタフェースなども司り,変更の可能性が高いです.

下記が実際の分割例です.この通りである必要はありません.
04-02-SC-BasicFunctions.c:加減乗除の基本4演算に対応する4つの関数
04-02-SC-ExtraFunctions.c:新しい演算に対応する関数
04-02-SC.h:共通ヘッダファイル
04-02-SC-Main.c:main()関数

こちらはコンパイル例です.

$ gcc -Wall -c 04-02-SC-BasicFunctions.c
$ gcc -Wall -c 04-02-SC-ExtraFunctions.c
$ gcc -Wall -c 04-02-SC-Main.c
$ gcc -Wall -o 04-02-SC-Go 04-02-SC-Main.o 04-02-SC-BasicFunctions.o 04-02-SC-ExtraFunctions.o
$ ./04-02-SC-Go

【余談】ヘッダファイル中のマクロを使ったトリック(多重インクルードの防止)

04-02-SC.hの中には,見慣れないマクロ命令(#で始まる行のこと)があります.
#ifndef 〜 → あるマクロ「〜」がまだ未定義であれば条件に適合したとみなして,次に出てくる#endifまでの部分を全て有効にします.
(if not defined 〜 の省略形で ifndef 〜 となっています)
裏を返せば,あるマクロ「〜」がすでに定義済であれば,次に出てくる#endifまでの部分を全部無効(無かったこと)にします.
今,ifndefの引数には _04_02_SC_H というオリジナルな(私が勝手に決めて他の人がまず定義することはなさそうな変な名前の)マクロが用意されてます.
こうしておくことで,1つのソースプログラム内で2回 #include "04-02-SC.h" とされても破綻しないようになっています.
同じ仕掛けは,例えば /usr/include/math.h の _MATH_H マクロで見ることができます.

演習

04-02-ex1: 上記サンプルで,04-02-SC.hを用意しないとすると,他の3ファイル内に何を用意しなくてはいけないか述べなさい.

04-02-ex2: 上記サンプルで,04-02-SC.hを用意する利点について,思いつくところを2点以上述べよ.

04-02-ex3: 上記の簡易電卓プログラムに以下の機能拡張をしなさい.
・演算子 "exp" .その前の数値Aとその後の数値Bに対して,"A exp B" で AB の数値を求める.A,Bは浮動小数点数.
・演算子 "log" . その前の数値Aとその後の数値Bに対して,"A log B" で logAB の数値を求める.Aは正の浮動小数点数,Bは0以上の浮動小数点数.
・演算に際して不正な数値が与えられていた場合は,その旨を表示し,演算を行わないこと.
・これらの演算子に対応する関数は,04-02-SC-ExtraFunctions.cにその実体を記述すること.
・他のファイルも適宜書き直すこと.

04-02-ex4: ファイル群の圧縮を実施しなさい.
・上記「コンパイル例」と同じディレクトリに居るものとします.
・bash上で,"zip 04-02-ex4.zip 04-02-SC-*.c"と実行します.

04-02-ex5: zipファイルの内容を確認しなさい.
・bash上で,"unzip -l 04-02-ex4.zip"と実行します.
・("unzip 04-02-ex4.zip"とすると,実際に展開を開始するので注意)


04.03. 簡易電卓・自家製ライブラリの製作と利用

分割コンパイルでプログラミングしていると,幾つかのソースファイルについて更新する必要がなくなってきたりします.
こうしたソースファイルについて,4.2節の最後の作業のようにオブジェクトファイルをいちいち全部作り直すのは無駄です.
例えば,先の例で 04-02-SC-BasicFunctions.c と 04-02-SC-ExtraFunctions.c とに手を入れなくなってきたとしましょう.
04-02-SC-Main.c だけをいじってテストをしているときに,04-02-SC-BasicFunctions.o と 04-02-SC-ExtraFunctions.o まで毎回作り直す必要はありません.

$ gcc -Wall -c 04-02-SC-Main.c
$ gcc -Wall -o 04-02-SC-Go 04-02-SC-Main.o 04-02-SC-BasicFunctions.o 04-02-SC-ExtraFunctions.o
$ ./04-02-SC-Go
これだけを繰り返せばいいことになります.

こうなってくると,04-02-SC-BasicFunctions.o と 04-02-SC-ExtraFunctions.o をリンク時(04-02-SC-Go作成時)にいちいち指定するのが面倒に感じてきます.
(そう感じない?でも簡易電卓に関するユーザ定義関数が増えて,こうした分割ソースファイルが10個ぐらいあるとだんだん嫌になってきませんか?打ち間違いも増えそうです)

そこで,こうした「全くもう改変を加えない分割ソースファイル群に対応するオブジェクトファイル群」をまとめて扱おう,という概念が生まれました.
それがライブラリです.

【ライブラリ作成】
ライブラリの作成は意外に簡単です.
4.2節の最後の状態を仮定します.
ライブラリの作成には,ar というコマンドを使います.
(後でも書きますが,ここで作成するのは静的ライブラリです)

$ ar r libSC.a 04-02-SC-BasicFunctions.o 04-02-SC-ExtraFunctions.o (自家製ライブラリ libSC.a を04-02-SC-BasicFunctions.o 04-02-SC-ExtraFunctions.o から作成)

作ったからには,出来たことを確認しておきましょう.

$ ls (libSC.aが出来ていることを確認)
$ ar t libSC.a (libSC.aの中身が2つのオブジェクトファイルから構成されていることを確認)

libSC.aの生成が無事確認できたら,もう 04-02-SC-BasicFunctions.c, 04-02-SC-ExtraFunctions.c, 04-02-SC-BasicFunctions.o, 04-02-SC-ExtraFunctions.o は不要です.
(気持ちよく消してしまってもいいのですが,ちょっと安全のために,どこか別のディレクトリにでも移しておきましょう)
($ mkdir old_src)
($ mv 04-02-SC-BasicFunctions.c 04-02-SC-ExtraFunctions.c 04-02-SC-BasicFunctions.o 04-02-SC-ExtraFunctions.o old_src)

この先必要なのは,04-02-SC.h と libSC.a の2つだけになります.
この2つ(お手製ライブラリ)を使って,04-02-SC-Main.c で簡易電卓を記述してコンパイルするときは下記のようにします.

$ gcc -Wall -c 04-02-SC-Main.c
$ gcc -Wall -o 04-02-SC-Go 04-02-SC-Main.o -L. -lSC (リンク時,-L. と -lSC に注意)
$ ./04-02-SC-Go
ここで -lSC は libSC.a を利用する,という意思表示です.
(なぜ libSC.a を使いたいと意思表示するときに,lib と .a を外して,-l と SC の間にスペースをいれないようにして指定しなくていけないかは,昔からの伝統でそうなってるんだ!,としか言いようがないです)
-lSCだけだと,コンパイラ(正確にはリンカとして働いているgccコンパイラ)指定のディレクトリ内しか libSC.a を探そうとしないので,カレントディレクトリをその探索に加えてほしいという意思表示の為に, -L. を付け加えます.
(なぜカレントディレクトリ . を指定するときに,-L と . との間にスペースをいれないようにして指定しなくていけないかは,これまた昔からの伝統の都合でそうなってるんだ!,としか言いようがないです.言語は文化ですから,時々論理的でない部分があったりします.)

今は実際にはもう編集しているのは04-02-SC-Main.cだけなので,上記の作業はもう少し単純化して,
(正確に言えばコンパイルとリンクをまとめてgccにお願いして,ということです.)
(この「まとめてお願い」できるようにするためにgccにコンパイラ・リンカの両方の機能が付けられているのです.)

$ gcc -Wall -o 04-02-SC-Go 04-02-SC-Main.c -L. -lSC
だけで構わなくなります.
ここに至って,皆さんは,自家製ライブラリを作成して,それを使うプログラマになったわけです.
(わーい,パチパチ)

ここで作ったライブラリは静的ライブラリと呼ばれるものです.
静的ライブラリは,リンク時点に実行ファイルに必要なコードが実際に埋め込まれます.
一方,動的ライブラリは,リンク時点では予約だけしておいて,実行時に必要なコードが実際にくっつきます.

【かなり余談】

ところでこれ,何か見たことがある感じではありませんか?
数学ライブラリ関数を使った簡単なプログラムを書いたときのことを思い出してください.
(本科目より前にそういうことをしたことがあるはずです.カリキュラム的にそうなってるはず.)

math-sample.c
#include <stdio.h>
#include <math.h>
int main(int argc, char *argv[]){
  double angle = 45.0;
  printf("sin(%g[degree]) is %f\n", angle, sin(angle / 180.0 * M_PI));
  return 0;
}

$ gcc math-sample.c -lm
$ ./a.out
とほぼ同じですね.(libm.aを探すのに -L. はいらないため,そこだけは違います)
(正確には数学ライブラリは今や通常は動的ライブラリなので,静的ライブラリバージョンのlibm.aは使ってなかったりしますが,数学ライブラリに関しては機能的は動的ライブラリも静的ライブラリも等価です.)
(正確に今使っている数学ライブラリ関数の動的ライブラリファイルの所在地を知りたい人は,$ ldd a.out を実行して,libm で始まる行を探してください)

ほんと?と思う人は,

$ less /usr/include/math.h
$ ar t /usr/lib/libm.a
とか実行してみましょう.
サイズはそれぞれ桁違いですが,簡易電卓ライブラリと構造は変わりません.
(ディストリビューションによっては,/usr/lib/libm.aをもう置いてないこともあります.その場合は以下の話は実証することはできないので,読むだけにしておいて下さい)
(libm.aに含まれているオブジェクトファイルの数は私の手元の環境で実に444個‥たった2個のlibSC.aとはずいぶんな違いです)
(あるディレクトリ以下〔例えば /usr 以下〕で特定のファイル名のファイルを探すときは, $ find /usr -name libm.a のようにして探すのがunixでは普通です.)

それでもまだほんと?と思う人は,ちょっと悪ノリしてみましょう.
(/usr/lib/libm.aが存在しない環境では,残念ながら以下を実施することはできません)
これまでの数学ライブラリ(libm.a)を子飼いの sugaku ライブラリ(libsugaku.a)に交換します.
(って本家をコピーしてきて名前付け替えるだけですがそれはまあ.)

$ cp /usr/include/math.h sugaku.h
$ cp /usr/lib/libm.a  libsugaku.a

math-sample-sugaku.c
#include <stdio.h>
#include "sugaku.h"
int main(int argc, char *argv[]){
  double angle = 45.0;
  printf("sin(%g[degree]) is %f\n", angle, sin(angle / 180.0 * M_PI));
  return 0;
}

$ gcc -o math-sample-sugaku math-sample-sugaku.c -L. -lsugaku
$ ./math-sample-sugaku
今やlibm.aは利用してません.
その代わりに,コピーしてきたlibsugaku.aの中のオブジェクトを使ってこのプログラムは動いてます.
以下の二つの実行結果をよく比較してみましょう.おそらく1行だけ異なるはずです.(割り当てられるアドレスは動的に毎回決定されるのでそこは無視してください)
$ ldd a.out
$ ldd math-sample-sugaku

(何といいますかここで「おおすげー」というような感想が持ってもらえると嬉しいのですが‥私が初めてこれを知ったときはコンパイラとOSの裏側をようやく見れるようになったという気がしたものです)

演習

04-03-ex1: 上記の math-sample-sugaku がOS標準の数学ライブラリを使っていないことを,unixのコマンドを利用して明示しなさい.

04-03-ex2: 動的ライブラリと静的ライブラリの違いを述べなさい.

04-03-ex3: 現在のOSでは,リンク時に動的ライブラリが多く使われています.静的ライブラリに対する動的ライブラリの優位な点について述べ,現行のOSで動的ライブラリが広く使われるようになった理由について考察しなさい.


kameda[at]iit.tsukuba.ac.jp.