第6章 関数

テキスト『新・明解C言語 入門編 第2版』第6章の内容に関する補足説明を提供します.

6-1 関数とは

main 関数とライブラリ関数

このセクションについての補足説明は現在のところありません.

関数とは

C言語での関数とは,いくつかの入力を受け取り,何らかの処理を行って, ひとつの値を返すものです. ただし,入力を何も受け取らない関数や(第1版 p.144,第2版 p.154), 値を返さない関数(第1版 p.142,第2版 p.152)もあります.

関数定義

関数本体の説明で「関数の中でのみ利用する変数」と述べているのは, 仮引数として宣言した変数とは別の変数です. こうした変数の例は List 6-2 にあります. この例題で定義している関数 max3 の中で, max という変数を宣言して利用しています. 仮引数として宣言された変数も,当然ながら,関数の中でのみ利用されます.

関数呼び出し

List 6-1 の max2 関数のように, 自分で定義する関数は main 関数よりも前に書きます. main 関数はソースプログラムの中で最後に書きます(第1版 p.147,第2版 p.157). 自作の関数を使うときには, それが使われる場所より前でその関数を定義しておきます. ただし,複雑なプログラムでは必ずしもこの順序が守られないかもしれません. その場合には,テキスト p.157(第1版 p.147)で説明されている 関数原型宣言(function prototype declaration)を最初に書きます.

3値の最大値を求める関数

このセクションについての補足説明は現在のところありません.

関数の返却値を引数として関数に渡す

演習 6-3 で,double 型の値の3乗値を求めて double 型で返す関数を書くならば, 関数名を fcube にするとよいでしょう. 「整数用の関数と同等の浮動小数点用の関数名の先頭に f を付けるのはC言語の慣習です.」 (『新・解きながら学ぶC言語 第2版』p.137)

自作の関数を呼び出す関数

List 6-4 で,max2 関数は max4 関数の中で使われます. そのため,max2 関数は max4 関数より先に定義しておきます. この順序が逆になると,下図のように, clang でのコンパイルでエラーとなります. コンパイラによっては警告だけかもしれません.

implicit declarration of function

テキスト p.157(第1版 p.147)で説明されている関数原型宣言を書いておけば, コンパイルで警告は出ません.

function prototype declaration of the max2 function

値渡し

テキストの第1版には誤植が1か所あります. テキスト p.140 での,値渡し (pass by value)の解説で, 「仮引数 x は実引数 a のコピーであり,仮引数 y は実引数 b のコピーです」 と書かれているのは, 「仮引数 x は実引数 a のコピーであり,仮引数 n は実引数 b のコピーです」 の誤りです(y でなく n).

List 6-6 の power 関数が実行されると,終了時の n の値は -1 です. この関数の仮引数 n に,実引数として 2 という値が代入されたとします. while 文の制御式 n-- > 0 の評価は, 後置減分演算子(--)が適用される前に行われます(テキスト第2版 p.81, p.89;テキスト第1版 p.79, p.87 参照). そのため,2 > 0 という式が評価され,評価値は 1(真)となります. 続いて,n の値は後置減分演算子によってひとつ減らされて,1 となります. ループ本体が実行されるとき,n の値は 1 です. ループ本体の実行が終わって, 再び制御式 n-- > 0 が評価されるときには, 1 > 0 という式が評価されることになります(評価値は 1). 続いて,後置減分演算子が適用され,n の値は 0 になります. 次に制御式が評価されるときには, 0 > 0 を評価することになるので,この評価値は 0 となり, ループ本体は実行されません. ループ本体は実行されませんが,ディクリメントは行われるので, n の値は -1 になります.

List 6-6 で,関数間の引数の受渡しが値渡しになっていることを確認しておきましょう. すなわち,main 関数での変数 a および b の値は,コピーされて, power 関数の仮引数 x および n に代入されていることを確認します. そうすると,power 関数の中で n の値(b の値のコピー)は変更されますが, b の値は変更されません. List 6-6 のプログラムに,以下の // 追加 // と注釈がついている2行を加えます.


#include <stdio.h>

//--- xのn乗を返す --- //
double power(double x, int n)
{
  double tmp = 1.0;

  while (n-- > 0)
    tmp *= x;

  printf("n = %d\n", n); // 追加 //
  
  return tmp;
}

int main(void)
{
  double a;
  int b;

  printf("aのn乗を求めます。\n");
  printf("実数a:"); scanf("%lf", &a);
  printf("整数b:"); scanf("%d", &b);

  printf("%.2fの%d乗は%.2fです。\n", a, b, power(a, b));
  printf("a = %.2f; b = %d\n", a, b); // 追加 //

  return 0;
}

このプログラムの実行結果は以下のようになり, n の値は更新されていますが,a の値は変化していないことがわかります.

List 6-6

6-2 関数の設計

値を返さない関数

List 6-7 および List 6-8 は直角二等辺三角形を作るプログラムとされていますが, 出力を見ればすぐわかるように,二等辺三角形にはなっていません. 垂直方向と水平方向で,* の間隔が異なるためです.

関数の汎用性

演習 6-6 で作成する関数 alert は, List 6-8 で作成した put_chars 関数を利用することができます. 汎用性が高い関数をせっかく作成したので,それを利用します.

他の関数を利用せず単独で動作するように alert 関数を作成したいのであれば, たとえば,次のように書けばよいでしょう.


void alert(int n)
{
  for (int i = 1; i <=n; i++) {
    putchar('\a');
  }
}

引数を受け取らない関数

List 6-9 で,非負の整数を反転する仕組みはわかったでしょうか? テキストの実行例のように,正の整数 128 が入力されたとしましょう. この値(厳密には,この値のコピー)は, main 関数の中で rev_int 関数(reverse integer という意味)に渡されます. 値を渡された rev_int 関数の中では,この値は仮引数 num に代入されます. 値の反転は do 文で実現されています. 最終的には反転した値が代入される変数 tmp は,最初は 0 で初期化されています. 1回目の do 文の実行(do 文のループは必ず1回は実行されます)で,


tmp = tmp * 10 + num % 10;

という文は,


0 * 10 + 128 % 10

を計算して tmp に代入することを意味します. 128 % 10 は 8 を返しますから,tmp には 8 が代入されます. 次の


num /= 10;

という文で,num の値が更新されます. 変数 num(更新前の値は 128)は int 型の整数ですから,10 で割ったあまりは切り捨てられ, 新たな num の値は 12 となります. 制御式 num > 0 は真なので,do 文のループ本体は再び実行されます. 今度は,


tmp = tmp * 10 + num % 10;

という文は,


8 * 10 + 12 % 10

を計算して tmp に代入することを意味します. 12 % 10 は 2 を返しますから,tmp には 82 が代入されます. 次の


num /= 10;

という文で,num の値が更新されます. 変数 num の値は 12 でしたから,更新後の num の値は 1 です. 制御式 num > 0 は真なので,do 文のループ本体は再び実行されます. 今度は,


tmp = tmp * 10 + num % 10;

という文は,


82 * 10 + 1 % 10

を計算して tmp に代入することを意味します. 1 % 10 は 1 を返しますから,tmp には 821 が代入されます. 次の


num /= 10;

という文で,num の値が更新されます. 変数 num の値は 1 でしたから,更新後の num の値は 0 です. 制御式 num > 0 は偽なので,do 文のループ本体を抜けます. 最後に,


return tmp;

により,rev_int 関数は 821 という値を返します.

値の反転を行う do 文は,入力された値が正の数であるかどうかを確認する if 文に埋め込まれています. この確認は scan_pint 関数(scan positive integer という意味)で行っていますから, if 文はなくても動作します. わざわざ無駄なことをしているようですが, 別のプログラムで rev_int 関数を使うときに, いつも scan_pint 関数と共に使われるとは限りません. そのため,rev_int 関数に渡された値が正の整数かどうかを確認することには意味があります.

関数の返却値での初期化

このセクションは,第2版では「引数を受け取らない関数」のサブセクションとなりました.

List 6-9 では,main 関数の中で scan_pint 関数が呼び出され, それが返す値で変数 nx を初期化しています. 関数の返却値での初期化が可能なのは, 自動記憶域期間 (automatic storage duration)を持つ変数だけです. テキスト p.174(第1版 p.162)で学習するように, 静的記憶域期間(static storage duration)を持つ変数は, main 関数の実行前に初期化されますから, この時点で実行できない他の関数の返却値で初期化することはできません.

ブロック有効範囲

このセクションについての補足説明は現在のところありません.

ファイル有効範囲

すべての関数の外側で定義される変数を 外部変数 (external variable)あるいは グローバル変数 (global variable)と呼びます(この用語はテキストにはありません). List 6-10 での配列 tensu は,変数でなく配列ですが, main 関数および top 関数の外側で定義されているので, 外部変数(グローバル変数)に相当します. 外部変数は,それにアクセスするどの関数の定義よりも前で宣言します. 通常は,List 6-10 での配列 tensu のように, プリプロセッサのコマンド(# で始まる)の直後に書きます.

外部変数は,宣言された場所以降の, プログラム中のどの関数からでもアクセスできます. これに対して, ブロックの内部(たとえば,main 関数の内部)で宣言された変数は, そのブロックの内部でのみ使用できます. ブロックの外側からアクセスすることはできません.

宣言と定義

List 6-10 で配列 tensu の宣言に使われている extern という宣言は, テキスト p.174(第1版 p.162)で説明されている 記憶域クラス指定子 (storage class specifier)のひとつです.

関数の外側で 定義 された外部変数は, それを使う関数の中で 宣言 します. 関数の中で外部変数を宣言するときに, extern を先頭につけます. List 6-10 はこの原則に従っています. 宣言と定義の違いは,今の段階では少しわかりにくいかもしれません. 変数の実体を作り出す,すなわち, メモリ上の記憶領域が変数に割り当てられるとき,それを定義と呼びます. 第10章の List 10-2 で,変数に割り当てられた領域のアドレスを表示します.

ただし,外部変数が,それを使用する関数より前に, 同一のソースファイルの中で定義されている場合には, 関数内でその外部変数を宣言する必要はありません. したがって,List 6-10 では,main 関数および top 関数の中での配列 tensu の宣言は省略できます.

省略可能ならば,なぜ extern 宣言などというものが必要なのでしょうか? このテキストで学習する範囲では,その必要性はほとんどありません. しかし,より進んだプログラミングでは,これが必要になってきます.

このテキストで学習する範囲では, ソースプログラムはひとつのファイルでできており, このファイルをコンパイルして実行プログラムを作っています. より進んだプログラミングでは, ソースプログラムが複数のファイルから構成され, それらのファイルを別々にコンパイルしてからリンクさせるということが行われます.

たとえば,ソースプログラムが part1.c と part2.c という2つのファイルから構成されているとします. そして,グローバル変数 glb が,part1.c の中で定義されているとします. このグローバル変数を part2.c の中で使用するには, part2.c の中で,extern をつけてこの変数を宣言すればよいのです. たとえば,part2.c の冒頭部分で,


#include <stdio.h>

extern int glb;

と書くことで, part2.c の中でもグローバル変数 glb を使用できるようになります.

関数原型宣言

テキスト p.157(第1版 p.147)ページにある,関数の配置についての推奨 「呼び出される側の関数を前に,呼び出す側の関数を後ろに配置しよう」 に従えば,関数原型宣言は不要になります.

しかし,プログラムが複雑になると,この原則に従うのは難しくなります. たとえば,自作の関数定義がたくさんあり, 自作関数が自作関数を呼び出すということが頻繁に生じると, 関数定義の配置を決めることは面倒になります. それならば,関数原型宣言を書いてしまった方が簡単です. 以下の書籍の p.177 では,「関数プロトタイプを書くスタイルが正当」と述べています.

林晴比古 (2003) 新訂 新C言語入門 ビギナー編 ソフトバンククリエイティブ

ヘッダとインクルード

多くの Unix 系 OS での標準の設定では, ソースプログラムのコンパイル時に読み込まれるヘッダ <stdio.h> は, ヘッダファイル


/usr/include/stdio.h

です.ディレクトリ構造で一番上に位置するルートディレクトリの中に usr というディレクトリがあります. その下に include というディレクトリがあり, ヘッダファイル stdio.h はここに含まれています.

2023年11月時点での,大学の演習室の環境では, ヘッダファイルはディレクトリ構造のもっと深いところにあります.

このファイルの中に記述されている putchar 関数の関数原型宣言を見るには, 文字列を検索する grep コマンドを使うとよいでしょう. ヘッダファイルは /usr/include/stdio.h であるとします. ターミナルで,


grep putchar /usr/include/stdio.h

と入力して [return] キーを押してください(putchar の前後に半角スペース). ヘッダファイル stdio.h の中で,文字列 putchar を含む行が表示されます. 下図では,一番上に putchar 関数の関数原型宣言が表示されています.

grep putchar /usr/include/stdio.h

2023年11月時点での,大学の演習室の環境では, ヘッダファイル stdio.h はディレクトリ構造のもっと深いところにあります. 第5章の List 5-10 で学習したように, プリプロセッサが処理を行ったところでコンパイルを止めて, その結果をファイルに出力して内容を確認すると, ヘッダファイル stdio.h がどこから読み込まれているかわかります. その場所をコピーして, grep コマンドで putchar 関数の関数原型宣言を見たのが下図です.

grep putchar /usr/include/stdio.h

関数の汎用性

このセクションについての補足説明は現在のところありません.

配列の受渡し

List 6-11 の main 関数では, 英語と数学の点数の入力を促す表示を行うために printf 関数を使用しています. 出力される「英語」という文字と「数学」という文字の位置を揃えるために, 「数学」での printf 関数に与える書式文字列の中に, 4つの半角空白が含まれています. 二重引用符で囲まれた書式文字列の直前にも半角空白が入っていますが, これはもちろん出力されません. プログラム中での「英語」と「数学」の位置をそろえるためにこうしているだけです.

配列の受渡しについて正確に理解するには,テキストにも書かれているように, ポインタ(pointer)の学習が必要です. 第10章の p.298(第1版 p.280)に,ここと同じ「配列の受渡し」というセクションがあります. それまで,テキストでも書かれているように, とりあえずの理解をしておいてください. ポインタを学習すると,max_of 関数の関数頭部は


int max_of(int *v, int n)

と書くのが本来の書き方だとわかります.

配列の受渡しと const 型修飾子

本章第1節で学習したように, 「関数間の引数の受渡しは,値渡しによって行われる」(第1版 p.140,第2版 p.151)のでした. たとえば,List 6-5 では,main 関数は自身が保持している変数 a と b の値をコピーして power 関数に渡していました. そのため,渡された値を power 関数が変更しても, main 関数が保持している a および b の値には影響しないのでした.

これに対して,何らかの関数を呼び出して配列を渡す場合には, コピーではなくもとの配列そのものが渡されます. よって,呼び出された関数において配列の値が書き換えられると, 呼び出した側が保持していた配列の値も書き換えられます. 同一の配列を2つの関数が共有しているからです.

正確には,配列の受渡しにおいてもコピーが行われているのですが, コピーされているのはポインタ(配列が記録されている記憶域のアドレス)であるため, 配列そのものが受渡しされていることと同じになります. ポインタについては第10章で学習します.

関数に配列を渡して,その関数が配列の内容を変更した後, 表示のコントロールは main 関数が行います(List 6-12 のように,表示用の関数を別に呼び出すことはあります). 表示は特定の配列に依存する部分が大きいからです. たとえば,関数に渡される配列の名前は様々ですから, 配列名の表示は main 関数がコントロールすべきです. 配列を操作する関数は,どのような名前の配列を渡されても, 同じ操作を行うようにします.

const 型修飾子(type qualifier)について, 誤解しやすい点を2つ注意しておきます.

const 型修飾子は, 型(たとえば int 型)を修飾して「読み出し専用」であることを意味します. List 6-12 での print_array 関数の仮引数宣言では, 「const int 型」の配列 v が宣言されています. 型を修飾しているのですから,「const int 型」の配列であって, const の「int 型の配列」ではないので注意してください.

もうひとつの誤解しやすい点は, 関数の仮引数での const 型修飾子は, その関数がその引数の値を変更しないということを述べているのであって, const 型の引数を渡すことを要求しているのではないということです. たとえば,List 6-12 では, main 関数での2つの配列 ary1 および ary2 は int 型であって, const int 型ではありません. これらの配列を,const 型修飾子があるため配列の要素の値を変更しない, print_array 関数に渡しています.

線形探索(逐次探索)

ある目的を達成するための手順のことを アルゴリズム (algorithm)と呼びます. IT 用語辞典 e-Words(http://e-words.jp/)では, アルゴリズムの意味を以下のように説明しています.

アルゴリズムとは、ある特定の問題を解く手順を、 単純な計算や操作の組み合わせとして明確に定義したもの。 数学の解法や計算手順なども含まれるが、 ITの分野ではコンピュータにプログラムの形で与えて実行させることができるよう定式化された、 処理手順の集合のことを指すことが多い。

データの中から特定のものを探すことを 探索 (search)と呼びます, List 6-13 から List 6-15 では,最も基本的な探索アルゴリズムである, 線形探索(linear search)を学びます.

もう慣れたとは思いますが,配列の要素の数と, 末尾要素の添字はひとつずれていることに注意してください. たとえば,List 6-14 の main 関数では, int x[NUMBER + 1] と宣言していますから, 配列 x (第1版では vx)の要素数は 5 + 1 = 6 です. この配列の末尾の要素には 番兵 (sentinel)が格納され,x[5] としてアクセスできます. 検索対象となるデータは x[0] から x[4] でアクセスできます.

番兵を置き忘れると,key と一致する要素はいつまでも見つかりませんから, 無限ループになってしまいます(配列の要素数を超えて, 存在しない要素にアクセスすることになるので, どのような動作になるのかはわかりません). もし無限ループに入ってしまったら,ターミナルで, コントロールキーを押しながら [C] キーを同時に押してください. プロセスを止めることができます

List 6-15 の for 文はとてもすっきりしていますね. この for 文は,v[i] が key と一致しない限り, i をひとつずつ増やしながらループを回しています. 検索対象となったデータのどこかで key が見つかるか, 見つからずに番兵(key が格納されています)までたどり着くと, ループを抜けます. 番兵までたどり着いたときには,i の値は n になっています.

List 6-15 の for 文を,もう少し List 6-14 と似たものにするなら, 以下のように書くこともできます. ループの継続条件(制御式)が空白なので「無限ループ」になっています. どこかで key が見つかると,break 文でループを抜けます. 配列の最後の要素に番兵を置くことを忘れないように.


for (i = 0; ; i++)
    if (v[i] == key)
       break;

演習 6-11 は少し難しいですが,よい問題です. がんばってプログラムを書いてみてください. List 6-14 の実行例のようにデータ(1, 7, 5, 7, 2, 4, 7)と 検索キー(探す値:7)を入力すると,


7は3個含まれます。
vx[1] = 7
vx[3] = 7
vx[6] = 7

と表示されるようにしてください.

多次元配列の受渡し

テキスト第1版での演習 6-12 には誤植があります(初版で確認). 行列 a および b の積を格納する行列 c は,3行3列ではなく4行4列です. したがって,関数 mut_mul の宣言は以下のようになります. 第2版では修正されています.


void mat_mul(const int a[4][3], const int b[3][4], int c[4][4])

この問題は演習 5-10(第1版 p.127,第2版p.135)の関数版です. 数学での行列(matrix)を扱っています. 高校数学のカリキュラムから行列が消えてしまいましたから, 知らなければこの問題はスキップしてかまいません.

演習 6-13 は演習 5-12(第1版 p.127,第2版p.135)の関数版です. 『解きながら学ぶC言語』(初版)で提示されている解答が想定されているならば, ポインタを学習した後でないとこの問題は解決できません.スキップしてください. 直観的に言えば,データは3次元配列として記録されており, ここから2次元配列の部分を取り出して関数に渡す必要があります. 『解きながら学ぶC言語』第1版の初版で提示されている解答には少しおかしな部分があり, 以下のような警告が出ます.ただし,実行プログラムは作られ,動作します.

incompatible pointer types

警告が出ないようにするには, 関数 mat_add および mat_print に配列(正確には,int[3] へのポインタ)を渡す部分を, 以下のように修正します.


mat_add(&tensu[0][0], &tensu[1][0], sum, 4);

mat_print(&tensu[i][0], 4);

ポインタについて学習したら,演習 6-13 での main 関数を以下のように書き換え, 多次元配列へのポインタについて理解を深めるとよいでしょう.


int main(void)
{
  int (*p1)[2][4][3]; // int[2][4][3]型の配列へのポインタ
  int (*p2)[4][3]; // int[4][3]型の配列へのポインタ
  int *p3; // int へのポインタ
  int tensu[2][4][3] = {
    {{91,63,78}, {67,72,46}, {89,34,53}, {32,54,34}},
    {{97,67,82}, {73,43,46}, {97,56,21}, {85,46,35}}
  };
  int sum[4][3];


// 途中省略 //


  p1 = &tensu; // 配列 tensu 全体へのポインタ
  p2 = tensu; // int[4][3]型の配列を要素とする配列 tensu の先頭要素へのポインタ
  p3 = &tensu[0][0][0];
  // tensu[0][0][0] へのポインタ

  printf("p1 = %p\n", p1);
  printf("p1 + 1 = %p\n", p1 + 1);
  putchar('\n');

  printf("p2 = %p\n", p2);
  printf("p2 + 1 = %p\n", p2 + 1);
  putchar('\n');

  for (int i = 0; i <= (2 * 4 * 3); i++) {
    printf("p3 + %d = %p\n", i, p3 + i);
  }
  
  return 0;
}

6-3 有効範囲と記憶域期間

有効範囲と識別子の可視性

このセクションについての補足説明は現在のところありません.

記憶域期間

有効範囲(scope)という概念と, 記憶域期間(storage duration)という概念を, 混同しないように注意してください. これらの区別を理解するために,List 6-18 での変数 sx に注目してください. この変数は関数 func の中で宣言されていますから, この関数の中だけで有効です. 他の場所(func 関数の外)から参照することはできません. しかし,宣言で static が付けられていますから, この変数(sx)はプログラムの終了時まで保持されています.

テキスト第1版 p.162 の最後にある「この宣言は,代入ではなく初期化を意味しているのですから」 という文は,解説としておかしいと思います. 初期化を意味しているという点では,static int sx = 0; と, 次の行の int ax = 0; は同じです. 変数 sx の初期化が1度だけであるのは,static が付けられているからです.

演習 6-15 で作成する関数 put_count の宣言は,


void put_count(void)

としてください.テキストでは仮引数が空白になっています(第1版および第2版の初版で確認). 修正してください.