関数3(参照呼出)

関数の参照呼び出しによるデータ共有 (関数同士でのメモリ共有)について理解しよう.

特別な変数「ポインタ(pointer)」が今回のポイント. まぁ,大抵の用途のプログラムは, ポインタなんて使わなくても作成できますが... ポインタを有効に利用すれば, メモリ使用量・実行時間に関して, より効率的なプログラムを実現できるようになる.

教科書の該当範囲:第4章(第4.3節),第7章(7.2.8)

間接参照

基礎知識

使用例:(実行は不要.読んで,考えて,理解しよう.)

int	*p;	// ポインタ変数 p の宣言.どこかの int型データを指すよ.
int	x = 1, y = 2;

p = &x;		// xのアドレスを p に代入.p は x を指すことに.
y = *p;		// p で間接的に xの値 を読み取り,y へ代入.y = x; と同じ.
*p = 0;		// p で間接的に xの値 を書き換える.x = 0; と同じ.

// 結果的に,x = 0, y = 1 となる

この例では,ポインタ p によって変数x を間接的に操作している.

文系向けな解説: この時,間接参照 *p と変数 x とは 「呼び名は異なるが,同一人物を表わしている」と考えよう. 変数=名詞(例:ジョンウン),間接参照=代名詞(例:将軍様),みたいな.

変数 y に対しても同様に, ポインタ p にアドレス &y を代入すれば, 間接操作が可能となる.

複数個の変数のどれでも, 1個のポインタ p だけを使って操作できる. これをうまく利用すれば,各々の変数を直接に別々に操作するよりも, ソースコードを短く効率的に記述できるようになる.

タスク1

ポインタ経由の間接参照について実験してみよう. 実験用プログラム indirect.c:(右クリック でダウンロード)

#include <stdio.h>

int main(void)
{
	int	x = 1;
	int	y = 2;
	int	*p;		// 変数p はポインタだよ

	// データの確認
	printf("&x = %p, x = %d\n", &x, x);	// x のアドレスと値を確認
	printf("&y = %p, y = %d\n", &y, y);	// y のアドレスと値を確認
	printf("\n");

	// x の間接参照
	p = &x;		// ポインタp に変数x を参照させたよ
	printf("p = %p, *p = %d\n", p, *p);	// 参照先の x のアドレスと値になるよ
	*p = 128;				// 間接参照で x に128が代入されるよ
	printf("p = %p, *p = %d\n", p, *p);	// 参照先の x のアドレスと値になるよ

	// データの確認
	printf("&x = %p, x = %d\n", &x, x);	// x のアドレスと値を確認
	printf("&y = %p, y = %d\n", &y, y);	// y のアドレスと値を確認
	printf("\n");

	// y の間接参照
	p = &y;		// ポインタp に変数y を参照させたよ
	printf("p = %p, *p = %d\n", p, *p);	// 参照先の y のアドレスと値になるよ
	*p = 256;				// 間接参照で y に256が代入されるよ.
	printf("p = %p, *p = %d\n", p, *p);	// 参照先の y のアドレスと値になるよ

	// データの確認
	printf("&x = %p, x = %d\n", &x, x);	// x のアドレスと値を確認
	printf("&y = %p, y = %d\n", &y, y);	// y のアドレスと値を確認
	printf("\n");

	return (0);
}
ここで,printf() の変換指定子「%p」は アドレスを 16進数として表示するためのものね. 「p」だらけで困るね.
なお,アドレスを表示するなんてことは, あくまでも実験・学習のためだけですよ. 通常の実用的なブログラムでは, ほとんどありえないでしょう.

indirect 実行中のメモリ内容の変化

コンパイルと実行:

$ cc  indirect.c  -o  indirect
$ ./indirect
&x = 0x7ffde9823624, x = 1
&y = 0x7ffde9823620, y = 2

p = 0x7ffde9823624, *p = 1		# p が x を指している(p は &x,*p は x)
p = 0x7ffde9823624, *p = 128		# *p の値を変えると...
&x = 0x7ffde9823624, x = 128		# ... x も変わる
&y = 0x7ffde9823620, y = 2

p = 0x7ffde9823620, *p = 2		# p が y を差している(p は &y,*p は y)
p = 0x7ffde9823620, *p = 256		# *p の値を変えると...
&x = 0x7ffde9823624, x = 128
&y = 0x7ffde9823620, y = 256		# ... y も変わる
アドレスの値 0x.... は実行する度に異なる. 各回の実行中には変わらない. 何度も実行し,確認せよ.

ポインタ経由で他の変数を操作できることを理解できたかな? ソースコードと実行結果とを徹底的に読み比べよう.


参照呼び出し

基礎知識

プログラミング言語の一般論として, 関数の呼び出しの際,引数の使用方法には,次の2種類がある:

  • 値渡し(値による関数の呼び出し,値呼び出し): 実引数の値が仮引数の変数に代入される.
  • これまで主に使ってきた呼び出し方法. sum(n) のように.

    呼出先の関数内で仮引数の変数値を変化させても, 呼出元の関数内では実引数の変数値は変化しない.

    実引数と仮引数とでは,値は等しいが,メモリ領域は別々. もし仮引数と実引数とが同じ名前であったとしても, 両者の実体は別物(分身,コピー)ね.
  • 参照渡し(参照による関数の呼び出し,参照呼び出し): 仮引数と実引数とが同じメモリ領域を共有する.
  • これまでも実は使っていた.scanf("%d", &x) のように. さらに実は...,scanf( ) や printf( ) の書式文字列 "%d" も参照渡し.

    呼出先の関数内で仮引数の変数値を変化させると, 呼出元の関数内でも実引数の変数値が変化する.

    もし仮引数と実引数とが異なる名前であったとしても, 1つのメモリ領域に2つの名前を付けただけであって, 両者の実体は同一.

C言語の場合,関数呼び出しはすべて値渡しとして実行される. しかし,ポインタを引数にすれば,実質的に参照渡しと同じことになる. また,関数の戻り値は1個だけ(またはゼロ個)に制限されているが, 参照渡しを利用すれば,引数を実質的に第2,第3,...の戻り値とすることも可能となる.

似たような専門用語たちの微妙な違い... 実引数(呼び出し側の引数), 仮引数(呼び出され側の引数), 戻り値などについて,以前の説明を再確認しよう.
C++言語には真の参照渡しがある.
タスク2

変数値を書き換えるような関数を作成し, 値渡しと参照渡しの動作の違いを確認してみよう.

まず,値渡し版の失敗例を試してみよう. reset.c

#include <stdio.h>

/*
引数 x の値をゼロにする関数
(値渡し版,失敗例)
*/
void reset(int x)	// 仮引数 x は main() 側の x とは別物
{
	printf("sub():1: x = %d\n", x);
	x = 0;		// 変数x の値をゼロにする
	printf("sub():2: x = %d\n", x);
}

int main(void)
{
	int	x = 123;

	printf("main():1: x = %d\n", x);
	reset(x);	// 変数x のによる呼び出し
	printf("main():2: x = %d\n", x);
	return (0);
}

実行結果:

main():1: x = 123
sub():1: x = 123
sub():2: x = 0		# sub() 側ではリセットできたが...
main():2: x = 123	# main() 側に戻るとできてない
main() 内で reset(x) を呼び出すと → 実引数 x の分身(仮引数の x)が作られ → reset() 内での x = 0 は仮引数の方だけを変更する. 実引数の方は変更されない.

reset(値渡し版)の実行中のメモリ内容の変化

次に,関数を参照渡し版に書き換えてみよう.

/*
引数 *p の値をゼロにする関数
(参照渡し版,成功例)
*/
void reset(int *p)	// ポインタp で x のアドレスを受け取る
{
	printf("sub():1: *p = %d\n", *p);
	*p = 0;		// ポインタp の参照先の値をゼロにする
	printf("sub():2: *p = %d\n", *p);
}

int main(void)
{
	int	x = 123;

	printf("main():1: x = %d\n", x);
	reset(&x);	// 変数x の参照による呼び出し
	printf("main():2: x = %d\n", x);
	return (0);
}

実行結果:

main():1: x = 123
sub():1: *p = 123
sub():2: *p = 0		# sub()ではリセットできた
main():2: x = 0		# main()でもリセットできてた
main() 内で reset(&x) を呼び出すと → 引数がコピー p = &x され → reset() 内での *p = 0 は x = 0 となる.

reset(参照渡し版)の実行中のメモリ内容の変化

ちなみに,これまでによく利用してきた入力関数 scanf() も 実は参照渡しの関数である. たとえば,scanf("%d", &x); として呼び出すと, scanf()内で入力された数値が 呼出元の変数 x のメモリ領域に格納される.


配列と関数

基礎知識

配列データを引数として関数を呼び出すこともできる. ただし,C言語の配列は,次のような性質をもつ:

  • 配列データは各要素の添字順に 連続的アドレスのメモリ領域に記録される.

    たとえば,a[0]a[1]a[1]a[2], 等は互いにアドレスの隣接したメモリ領域を使う.

    つまり,最初の要素 a[0] のアドレスがわかれば, 任意の要素 a[k] のアドレスもわかることになる.
  • 配列名はそのメモリ領域の先頭アドレスを表わす.

    たとえば,a&a[0] と同じ意味をもつ.

    つまり,&a[0] とか書くのは冗長. 短く,a と書こう.

    要するに配列名はポインタと同じことなので, 逆に,アドレス(ポインタ)を配列名としても利用できる. たとえば,int a[10]; int *p; p = a; とすると, p[5]a[5] と同じ意味をもつ.

したがって,関数の引数が配列の場合には,常に参照渡しとなり, 配列のメモリ領域は呼出元と呼出先の関数の間で共有されることになる.

配列内のデータすべてが値渡しとしてコピーされるわけではない. 先頭アドレスだけがコピーされる.
多数のデータのコピーには時間もメモリも費やしてしまう. こんな無駄なことにならないよう,合理的にできている.
タスク3

配列を引数とする関数の定義では, 実引数は配列名(先頭アドレス),仮引数はポインタとすることになる. さらに,配列の要素数も引数として適切に利用し, バッファオーバラン等の重大事故を防止する必要がある.

バッファオーバランについては,以前にも実験した. 安全対策を施さず,配列要素へ直接代入するとどうなるか? 再度確認しておこう.

配列引数の適切な使用例:set.c

#include <stdio.h>

/*
配列要素へ安全に数値を代入する関数
引数 p:配列の先頭アドレス
     n:配列の要素数
     i:要素番号
     v:数値
戻り値:エラーコード(0:正常,1:エラー発生)
*/
int set(int *p, int n, int i, int v)
{
	if (i < 0) return (1);		// バッファオーバラン防止
	if (i >= n) return (1);		//    〃

	p[i] = v;	// p は a なので,a[i] = v; と同じこと
	return (0);
}

#define	NUM	10	// 配列a の要素数

int main(void)
{
	int	a[NUM];
	int	n = 0;

	n += set(a, NUM, 8, 1);		// これは...
//	a[8] = 1; n++;			// ...これと同じだよね?なぜわざわざ関数化?
	printf("8: OK\n");	// 動作継続の確認

	n += set(a, NUM, -100, 1);	// 要素番号を間違えてみよう
//	a[-100] = 1; n++;		// ...これだとオーバランで強制終了かも
	printf("-100: OK\n");	// 動作継続の確認

	n += set(a, NUM, 10000, 1);	// さらに盛大に間違えてみよう
//	a[10000] = 1; n++;		// ...これもオーバランかも
	printf("10000: OK\n");	// 動作継続の確認

	printf("オーバラン防止回数:%d\n", n);
	return (0);
}

まずは,このソースをそのままコンパイルし実行しよう. その後,コメント化されている a[-100] = 1; 等の行を 有効化(「//」を削除)し,わざとオーバランを発生させてみよう.

安全対策のためコードは長く,プログラミングは面倒になりますが, これはソフトウェアの品質確保のために必要な投資です. ただし,できるだけ関数化・効率化を図りましょう.

配列引数の仮引数について, ポインタではなく配列として記述する流儀もある. 例:int set(int p[], int n, ...) のように. この流儀でも,安全対策の必要性は変わらない.
タスク4

(今回の課題です.)

次のような5個の配列操作関数を定義せよ:

  • 全要素を表示する関数 void print(int *p, int n)
  • 全要素を値 v に初期化する関数 void reset(int *p, int n, int v)
  • 全要素に対して各符号の個数を調べる関数 int check(int *p, int n, int *np, int *nm)
    • *np:正の要素の個数(参照渡し)
    • *nm:負の要素の個数(参照渡し)
    • 戻り値:ゼロの要素の個数
    正・負のカウントでは, 間接参照「*」と加算「++」とを 組み合わせる必要があるだろう. このとき,演算子の優先順位に注意しよう. *np を加算するつもりで *np++ と書くと, これは *(np++) の意味になってしまう. 正しくは,(*np)++ と書くべき.
  • 全要素を入力する関数 void input(int *p, int n)
  • 全要素を配列 p から配列 q へコピーする関数 void copy(int *p, int *q, int n)

なお,仮引数の p および q は 配列の先頭アドレスのポインタ, n は配列の要素数, v は初期値である.

テスト用ソースファイル断片 array.c

#include <stdio.h>

...

#define	NUM	5

int main(void)
{
	int     a[NUM];
	int     b[NUM];
	int	np, nm, nz;

	print(a, NUM);		// ゴミが表示される

	nz = check(a, NUM, &np, &nm);	// 符号をカウント
	printf("ゼロ|正|負:%d|%d|%d 個\n", nz, np, nm);

	reset(a, NUM, 7);	// a の全要素を 7 にする
	print(a, NUM);		// 7 7 7 ... が表示される

	nz = check(a, NUM, &np, &nm);	// 符号をカウント
	printf("ゼロ|正|負:%d|%d|%d 個\n", nz, np, nm);

	input(a, NUM);		// てきとーなデータを a にキーボード入力すると...
	print(a, NUM);		// そいつらが表示される

	copy(a, b, NUM);	// a の全要素を b にコピーする
	print(b, NUM);		// b として,a と同じ内容が表示される

	return (0);
}

余裕ある人は,他の配列操作関数を考案・実装してみては? 合計・平均・最大・最小などの計算とか.


本日の課題

レポートを提出せよ.

質問 Q1〜Q3 に回答し,電子メールで提出せよ.

  • 提出方法: 電子メール
    • 宛先:yanagawa@kushiro-ct.ac.jp
    • 件名:c-0714
    • 発信者:pXXXXXX@kushiro.kosen-ac.jp
      pXXXXXX」は各自のユーザ名ですよ. 必ず,学校のメールアカウントを使って提出しよう.
    • 提出期限:2023.07.21金17:00(提出遅れは減点対象)
    • 課題:
      • Q1. タスク4で定義した関数のソースコード (main 以外,関数定義部分のみ)をコピー&ペーストせよ.*
        不完全・未完成でも提出OK.減点あり.
    • 調査:
      • Q2. 今回の学習時間は?* 約○時間
        学習時間=授業時間+自習時間. 学習の開始から調査,課題取組,レポートの完成までにかかった時間の合計, 分単位は切り上げ. 課題にかかった時間だけではない. 時間の長短はレポートの成績評価には反映されないので,正直に答えてください.
      • (Q3. 感想・意見など)
  • 注意事項:
    • メール本文の先頭に名乗り(クラス,番号,氏名)を記述.
    • メール本文の末尾に署名(氏名,所属,等)を記述.
    • ソースコードのインデントの乱れは減点対象.

      メールの送信形式を必ず「テキスト形式 or プレーンテキスト」に変更せよ.

      「HTML」等では,せっかく整えたインデントが乱される.