なぜ関数を作るのか? 手続き型言語における自作関数の役割についてのメモ

最近、「関数を作る理由が分かりません。教えてください」と急に話を振られたらどうしよう、と訳もなく不安に駆られるので、個人的見解をまとめてみることにした。

急に話を振られる可能性があるかというと、正直分からないのだけど、急に話を振られた時にアドリブで分かりやすく答えられる自信が無いのは確かだ。

ちなみに、タイトル通り「手続き型言語」での関数を念頭に置いているので注意。より具体的には、C言語あたりの関数。私自身が基本的にCプログラマなのでどうしようもない。

似たような処理を共通化して1つにまとめる為に使う

関数を作る理由として、個人的に一番よく言われていると思うのが、「汎用的な処理を関数として切り出しておき、同じ処理をあちこちにコピペする代わりに関数を使う」という説明。

この利点として、これまた個人的に一番よく言われてると思うのが、

  • 修正が発生した時に、その関数の中だけ直せばOK*1

という点だけど、それ以外にも、

  • (関数に適切な名前を付ければ)とりあえずその関数内部の詳細を知らなくて済む

といった利点もあると思う。

ついでに、言語によっては実行時に必要なメモリ量を減らすことに繋がる可能性もあるのではないかと個人的に思っているのだけど、その辺りはどうなんだろう? 例えばC言語C++の場合、マクロ関数やインライン関数と異なり、普通の関数ならコンパイル時に生成される実行コードは1セットだけになるはずだ*2。もっとも関数呼び出し時にコールスタックを消費するとか色々とあるので、そのあたりを足し引きして考えないといけないのだろうけど。

分割統治の道具の一つとして使う

関数を作る目的を「似たような処理を共通化して1つにまとめる為に使う」だけだとすると、何となく「じゃあ1度しか呼ばれない関数を作るのはNGなのか?」という疑問を持つ人もいるかもしれないけど、そんなことはない。

関数は、プログラムを分割する手段の1つとしても使用される。

言語にもよるけど、実用的なプログラムを作成しようとするとコードの量は増える傾向にあると思う。例えばC言語で他人も使うコンソールアプリを書こうとすると、場合によって数千行の大きさになる*3。また仕事でC++を使ってWindows GUIアプリケーションを実装すると、大抵1万行を超えることになる。

プログラムが大きくなると、全処理べた書きでは実装すら困難になる*4。そこでプログラムを幾つかのパーツに分割して、各パーツを実装して、その上で実装したパーツを組み合わせることでプログラムを完成させる――といった手段をとることになる。

で、「パーツに分割」のパーツの単位には、オブジェクト*5とかクラスとかモジュールとかファイル*6とか色々あるけど、関数もパーツの単位となりうる。

「関数を使ってプログラムを分割する」という発想は、大規模プログラミング手法の研究の初期の段階からある考え方で、「構造化プログラミング論」にも出てくる*7。分割の基準はモジュール強度/結合度*8、分割技法はSTS分割/TR分割その他が有名で、基本情報技術者試験にも出てくる。

分割技法の中には共通機能分割があって、これは「似たような処理を共通化して1つにまとめる」という、関数を作る理由として最初にあげたものとほとんど同じ考え方だ*9。結果として、複数の箇所で使用される汎用的な関数と、せいぜい1〜3ヶ所ぐらいからしか呼ばれないような専用的な関数の2パターンに分けられることになる。

もっともプログラムの大きさがより肥大化した現在では、「関数を使ってプログラムを分割する」という発想だけでは厳しいので、例えば「クラスを使ってプログラムを分割し、クラスと関数(メソッド)を使ってクラスを分割する」といった感じになる。現在では、関数はプログラムを構成するパーツとしては最小の単位なので、クラスやモジュールなどの他の単位と組み合わせて使うことが多い。

なおこの場合も、関数に適切な名前を付けることで、とりあえずその関数内部の詳細を知らなくても済むようにできる。

抽象化の道具として使う

先に挙げた理由のどれについても、

  • (関数に適切な名前を付ければ)とりあえずその関数内部の詳細を知らなくて済む

といったことを書いた。

これ、要するに関数の名前の力で「処理の抽象化」を行っているということだ。

関数は、ある特定の結果を得るのに必要な幾つかの処理内容を1つにまとめたものだ。例えば文字列長を求める関数の中身は、文字列長を得るのに必要な幾つかの処理で構成されている。

ところで、文字列長を求めたいプログラマとしては、文字列長を求める関数の使い方は必要だけど、文字列長を求める関数の詳細な実装はとりあえず不要だ。また、後でソースコードを読む人からすれば、文字列長を求めていることが分かればとりあえず十分で、最初から文字列長を求める関数の詳細な実装を知りたいというケースはほとんど無い。

ここで、もし文字列長を求める関数の名前が例えばSubFunc3だったとしたら、後でソースを読むことになった人は「SubFunc3って何をやっているんだ?」と疑問に思い、SubFunc3の詳細な実装を調べることになる。しかし、もし関数の名前がCalcStringLengthだったとしたら、「多分、文字列長を求めているんだろう」と一目で判断できる。C言語なら、標準ライブラリのstrlenを使っていれば、大抵のCプログラマは何をやっているか理解できるはずだ。後でCalcStringLengthやstrlenの実装を調べる必要性が生じるまで、詳細な実装を調べなくて済む。

「必要になるまで、詳細な実装を調べなくて済む」という点は、後々のメンテナンス性を考えると結構重要だと思う。

極端な話、C言語のプログラムでmain以外にSubFunc1〜SubFunc30までの名前から何をやっているか分からない関数が30個あって、ついでにvaliable1〜10の名前からどんな役割を担っているか分からないstaticなグローバル変数があったとして、そんなプログラムのメンテナンスを命じられたとしたら、正直逃げられるなら逃げたい。独力で解析できるかもしれないけど、正直面倒だと思う。

問題を解決する為の専用言語を創る為に使う

関数による処理の分割と名付けがうまい具合にかみ合うと、上位階層のコードが擬似言語的になることがある。

例えば、以下のコードはコマンドプロンプトの劣化版みたいな感じの対話型コンソールアプリ*10のメインルーチン部分だけど、

  1. コマンドを読み込む。
  2. 読み込んだコマンドを実行する。
  3. 1に戻る。

といった基本手順が分かるようになっている*11

/** メインループ。
 *  コマンドを読み取り、実行する。EOFを検出したら終了する。
 */
int main(void)
{
	int ac;
	char **av;

	init_string_list();

	for (;;) {
		if (read_cmd(&ac, &av) == E_EOF) {
			break;
		}
		exec_cmd(ac, av);
	}

	return EXIT_SUCCESS;
}

read_cmdとかexec_cmdといった関数はC言語の標準ライブラリには含まれていないのだけど、私の頭の中で考えたおおまかな手順が、

  1. 標準入力からコマンドが入力されるのを待つ。
  2. コマンドが入力されたら、それをmain関数のargc、argvみたいに「コマンド名を含む引数の数」と「コマンド名を含む引数へのポインタ配列」の形式で取得する。
  3. コマンドを実行する。
  4. コマンドの実行が終了したら、コマンド入力待ちに戻る。

といった感じで、これを「標準ライブラリにはない、私の脳内の妄想が生んだ便利な関数」を使ってコードに落とし込んだらこうなった訳だ。

つまりこのコードは、私が問題をどのように理解しているかを、C言語を(C言語の枠内で)拡張した専用言語で表現したものだ。C言語では、言語を拡張する一般的且つ比較的安全な手段が関数/構造体/共用体/enum/typedefぐらいで、このコードに示した例では関数を使用している。Javaならクラスで拡張するだろうし、Lispなら関数とマクロを使うだろう。

今度はボトムアップ的な例を挙げてみる。先ほどのコードと同じアプリからだ。

このアプリを実装するにあたり、諸事情により固定配列をバッファとして使用する必要があった。標準入力からバッファサイズに収まりきらない入力があった場合、バッファに詰めるだけ詰め込み、残りは改行が現れるまで読み飛ばすことになる*12

読み飛ばしの処理は必須だけど、しかしアプリ全体としてはあまり本質的ではない問題だ。そこで入力をバッファに読み込む処理と読み飛ばし処理をまとめて1つの関数にしてしまうことにした。

/** 1行読み込み、改行を取り除き、文字数を返す。
 *  入力がバッファサイズを超える場合は、読めるだけ読み込んでE_NGを返す。
 *  (残りは改行が現れるまで読み捨てる)
 *  各引数の役割はfgets に準じるが、バッファは1byte余分に確保しておくこと。
 *  (最大文字数 + 改行 + NUL文字)
 */
static int read_line(char *buf, int size, FILE *in)
{
	int len;
	char *lb;
	_Bool overflow = false;

	assert((buf != NULL) && (size > 1) && (in != NULL));

	buf[0] = '\0';
	if (fgets(buf, size, in) == NULL) {
		return E_EOF;
	}

	len = (int) strlen(buf);
	lb = &buf[len-1];
	if (*lb != '\n') {
		int c;
		while ((c = fgetc(in)) != EOF) {
			if (c == '\n') {
				break;
			}
		}
		overflow = true;
	}

	*lb = '\0';
	return (overflow) ? E_NG : --len;
}

この関数のおかげで、上位の呼び出し側は入力の読み飛ばしという瑣末なことを気にする必要がなくなり、より本質的なところに集中すればよくなった。

新しい関数を定義するということは、今までその言語に含まれていなかった機能を追加するということだ*13。つまり新しい関数を定義することで、それ以前よりも機能が追加された、よりパワフルな言語になる。

この考え方は特に新しいものではない。例えば古典的名著である『ソフトウェア作法』では、第3章の3.8以降で書庫操作用のarchiveというプログラムを実装しているのだけど、このプログラムは3.7以前の項で別のプログラムを実装する時に作成した比較的汎用的なサブルーチンを組み合わせて実装されている。この点についてP.159にこんな記述があるので引用する。

これらの下位の補助ルーチンは、言語の拡張であり、ありふれた操作を簡潔に、解くべき問題の真の内容から目をそらすことなく表現するための手段なのだ、という風に考えることをおぼえてほしい。

とりあえず『Code Complete第2版 上』を買って読め

長々と書いてきたけど、多分これが一番よい答えだと思う*14。だけど相手に嫌われるかもしれない。あいたたた。

本気で職業プログラマになるんなら、安い買い物だと思うんだけどなあ。

言語/ライブラリ/各種ツールのリファレンスなどはネット上の情報でも十分だけど*15、それ以外の「ノウハウ」が絡んでくる情報となると、今のところネットよりも書籍の方が有効だと思う。個々の情報はネット上にもあるのだけど、書籍だと体系付けられて1つにまとまっているので便利だったりする。それに書籍化される時点でそれなりに内容が精査される面もある。まあ例え酷い内容だったとしても、大抵は誰かがネットで酷評しているので、事前にググれば何とかなるだろう。

*1:但し関数のインターフェースが変わらない場合に限る。

*2:最適化とかを考えない場合。

*3:例えばFreeBSD 4.11付属のpingはコメントや空行を含めて1459行。IPv6対応のpingだと2715行。どちらもヘッダファイルを除く。

*4:私の場合、数百行で破綻する。

*5:JavaScriptのようなクラスの無いOOP言語を想定している。ところで個人的にJavaScriptはどちらかというと関数型言語的だと思っているのだけど、世間的にはどうなんだろう?

*6:C言語には言語機能としてのモジュールは無いけど、代わりにソースコードをファイル分割することでモジュールの代用とすることが可能。

*7:但し、関数ではなくて「閉じたサブルティン(closed subroutine)」という表現だ。

*8:私は面倒なので「関数は単機能で、データは必要なパラメータのみを引数指定して、結果は戻り値で取得するのが基本(但し例外あり)」とだけ覚えている。

*9:但し共通機能分割は「STS分割やTR分割などでモジュール分割→同じようなモジュールが複数あるので共通化」といった手順で設計段階に行う、というニュアンスが強いと思う

*10:もっとも組込みコマンドしか使えないけど。ftpとかを対話モードで使うような感じか?

*11:特に狙ったわけではなく、半ば偶然にこうなった。しかし「基本手順」というあたりが非常に手続き型っぽい発想なんだろうなあ。

*12:本来ならEOF対策も考えるべきなんだろうけど、色々あって諦めた。

*13:車輪の再発明は除く。

*14:このエントリよりも遥かに有益。

*15:言語の壁を越えられるなら。私は英語が苦手なので、英語の一次情報よりも日本語の二次情報に頼ることになるのだけど、その二次情報って大抵は書籍だ。