メモリ管理(3):高度な配列操作

配列とポインタの応用例として, 多次元配列と動的配列について, メモリマップを意識しながら理解しよう.

教科書の該当範囲:第5.7節,第8.7節(ただし,難しすぎて参考にならない...)
参考書の該当範囲:なし

多次元配列

1次元配列(基本的な配列)

たとえば,3つの int 型変数からなる 1次元配列 int a[3] は, 次のように定義(宣言・初期化)される:

int a[3] = { 0, 1, 2 };

ちなみにこれは,次のような長たらしいソースと同じことだった:

int a[3];

a[0] = 0;
a[1] = 1;
a[2] = 2;

また,これらの配列要素 a[i]i = 0 〜 2)は, メモリ内では要素番号順に連続的に配置されるのだった.

ここまでは,すでに学習済み.

多次元配列(配列の配列の ... の配列)

Cでは,配列の配列(2次元配列)とか, さらにその配列(3次元配列)などの多次元配列も定義できる. たとえば,3つint 変数からなる1次元配列が さらに2つ集まってできた2次元配列 int b[2][3] は,次のように定義される:

int b[2][3] = {
	{ 0, 1, 2 },
	{ 3, 4, 5 },
};

ここで,配列[3] が2つだからといって, 配列[3][2] ではないことに注意しよう. この多次元配列は全体的には要素数2の配列であって, その各要素が要素数3の配列となっている. ...という考え方から,Cでは, 添字(そえじ;要素番号)の並べ方は, 数学の「行列」と同じように, 行番号,列番号の順(縦方向,横方向の順)である.

同じ「行列」とは言え, ラーメン屋などの「待ち行列(queue)」は1次元, 数学の「行列(matrix)」は2次元. 両者は別物ね,

そしてこの例では,各配列要素は次のように初期化されることになる: (添字の変化に注意して,上と比べてみよう.)

int b[2][3];

b[0][0] = 0;	// 0 行目開始
b[0][1] = 1;
b[0][2] = 2;

b[1][0] = 3;	// 1 行目開始
b[1][1] = 4;
b[1][2] = 5;

これらの配列要素 b[i][j]i = 0 〜 1j = 0 〜 2) のメモリ内の配置はどうなるか?考えて行こう.

多次元配列のメモリマップ(多次元配列と1次元配列の相互変換)

ところで,2次元配列とはいっても, 実際のメモリアドレスは1次元なので, メモリの中では1次元的に(1列に並んで)格納されることになる. つまり,上の2次元配列 int b[2][3] は, メモリマップ的には, 次の1次元配列 int c[6] とまったく同じことになる:

int c[6] = { 0, 1, 2, 3, 4, 5 };

または:

int c[6];

c[0] = 0;
c[1] = 1;
c[2] = 2;

c[3] = 3;
c[4] = 4;
c[5] = 5;
ここで,配列要素の格納順序に注意しよう. 配列 b[2][3] と配列 c[6] の初期化コードを比較せよ.

配列 bc の間で要素番号の対応関係を調べてみると, 結局,2次元配列 b[H][W] の要素 b[y][x] は, 1次元配列 c[W*H] の要素 c[y*W + x] に対応することになる. 次は,そのイメージ図だ.


2次元配列(H行W列)とメモリマップ(1次元配列 W×H要素)

ところで,1次元配列 a[N] では, 要素数 N を省略できる場合があった. int a[] = { ... } とか, int func(int a[]) のように. (「省略して良い」とは言っていない!!)

ところが,2次元配列 b[H][W] の場合, 横方向の要素数 W は, 各要素のアドレスの計算(y*W + x)に必要となるので, 省略できない. つまり,int func(int b[][]) とするのは間違い.

ただし,縦方向の要素数 H については省略できる. つまり,最低限, int func(int b[][W]) などとしなければならない だがこれでは,非対称で不格好だ. また,バッファオーバランも気になる.

したがって,2次元配列の要素数については,省略できる場合でも省略せず, int func(int b[H][W]) と書くべき.

なお,2次元配列を1次元配列として取り扱うには, 次のようにポインタへキャストすればよい:

int b[2][3] = {{...}};
int *c;

c = (int *)b;

こうしておけば,2次元配列の要素 b[y][x] を 1次元配列の要素 c[y*3 + x] と見なして取り扱えるようになる.

ポインタと配列の関係については,前期に学習済み.

これらの説明が本当であるか, 次の練習問題に取り組み,確認してみよう.

練習問題

上の説明の通り2次元配列 b[2][3] を 1次元配列 c[6] として取り扱えることを確かめるため, すべての要素 b[y][x] および c[y*3 + x] を表示するプログラムを作成せよ.

ソース 2d.c

#include <stdio.h>

int main(void)
{
	int b[2][3] = {{...}};
	int *c;
	...

	c = ...;

	for (y = ...) {
		for (x = ...) {
			printf("b[%d][%d] = %d ,   ", y, x, b[y][x]);
			printf("c[%d*3 + %d] = %d\n", y, x, c[y*3 + x]);
		}
	}
	...
}

動的配列

静的配列(通常の配列,固定長配列)

まず,次のような配列の利用方法は,ありがちな間違い. array-1.c

#include <stdio.h>

int main(void)
{
	int  n;

	printf("配列のサイズ > "); 
	scanf("%d", &n);

	int a[n];	/* まちがい */

	a[0] = 123;	/* ...配列に対する何らかの処理 */

	return (0);
}

処理系によっては,これでも実行できる場合もある. しかし,C言語の標準規格(ANSI C)には違反している. この実験室の環境では, 次のコンパイル方法で確認できる:

$ cc  ソース.c  -Wall  -ansi  -pedantic

...残念でした. 標準Cの通常の配列は,静的配列(固定長配列)であり, 要素数を実行時には変更できない. 要素数を変えるには,ソースコードを書き換え,コンパイルし直す必要がある.

これはめんどくさいぞ...
あーそれと,1行コメントの「//」も, 実は C++ 用であり,ANSI C では違反なので,今回は封印.

そうすると,配列を利用するプログラムの実行中に, 実際にデータを入力してみたら, 配列要素の個数が足りなくなってしまった...ということはありえる話だ. C言語では,配列要素の個数をプログラムの実行中に簡単には変えられないので, とりあえずの解決策としては,充分に大きな配列を用意しておき, その一部だけを使うことになるだろう. array-2.c

#include <stdio.h>

#define	SIZE	1000000		/* かなり大きめに設定 */

int main(void)
{
	int	a[SIZE];
	int	n;

	printf("配列のサイズ > "); 
	scanf("%d", &n);
	if (n >= SIZE) return (1);

	...
}

しかしこれでは, 有限なメモリ資源を無駄使いすることになってしまうし, それでもやはり,足りなくなってしまうこともあるだろう.

動的配列(可変長配列)

標準C言語で動的配列(可変長配列)を実現するには, 次のようなメモリ管理用ライブラリ関数を利用しよう:

これらのライブラリ関数は, ヘッダファイル stdlib.h の中で プロトタイプ宣言されている. 詳しくは,教科書 K&R の pp.203-204 を参照しよう.

なお,メモリ領域のデータサイズの指定には通常, sizeof 演算子が利用される:

結局,次のように記述するのが正解となる. array-3.c

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
	int	n;
	int	*a;

	printf("配列のサイズ > "); 
	scanf("%d", &n);

	a = (int *)malloc(sizeof(int)*n);	/* int a[n] のメモリ領域を確保 */
	if (a == NULL) return (1);		/* 確保できなかった場合のエラー処理 */

	...

	free(a);				/* 使い終わったらメモリ領域を解放 */

	return (0);
}

固定配列では変数の宣言時にメモリ領域が確保されていた.

一方,動的配列ではポインタの宣言時には配列の実体はまだ存在せず, malloc() の実行時に配列のメモリ領域が確保される.

なお,malloc( )calloc( ) では, この例の (int *) のように, 戻り値の型を適切にキャストしてやる必要がある. 面倒ではあるが,その代わり, これらの関数では,あらゆる型の動的配列を確保できるようになっている.

また,プログラム終了時には自動的に free( ) してもらえるが..., コンピュータ任せにはせず,プログラマが意識的に free( ) すべきだ. 特に,長期間に渡って異常終了せずに連続動作すべきシステム (例:銀行,通信,等)では,非常に重要だ. 下の練習問題でこの関数の重要性を確認しよう.

なお,malloc( )free( ) の関係は,ちょうど, fopen( )fclose( ) の関係と同様である. 要するに,前処理(準備)と 後処理(片付け)だ.

前処理については, 準備しなければ本処理を実行できない訳で, 省略するとプログラムは動かない. 準備は絶対に必要だ.

一方,後処理については,本処理が実行できた後のことなので, 省略しても動いてしまう訳で,片付けは不要では? いや,その考えは甘え. 本処理が1回だけの場合なら後処理なしでも動くが, 複数回の場合にはうまく行かないだろう. 後処理は次回の処理の準備のための準備として必要. 片付けを怠らないこと!!

練習問題

とにかく 1 MB ずつのメモリ確保 (char *)malloc(1024*1024)free( ) せずに繰り返すプログラムを作成し, 手元のコンピュータで確保可能なメモリ容量の最大値を確認せよ.

1 KB(キロバイト)= 1024 B(byte,バイト)
1 MB(メガバイト) = 1024 KB
1 GB(ギガバイト) = 1024 MB

ヒント: わざと間違えてメモリリークを発生させてみる問題. 前期に試した fclose( ) せずに fopen( ) を繰り返すプログラム fclose1.c を参考にしよう.

誤解しないこと. 「malloc( ) できたメモリ容量 コンピュータのメモリ容量」 ではない.

なお,一体どっちなんだー?とか,気にしないでよい. 確かなのは,メモリ容量には制限がある(=無限ではない)ということだけだ. malloc( ) したら,最後に必ず,free( ) すること.

さらに,静的配列では何MB まで使用可能か? たとえば,char a[1024*1024*...]; などとして探ってみよう. 大きなメモリ領域を使いたい場合にも, 動的配列が有効だとわかるだろう.


本日の課題

Tic-tac-toe(三目並べゲーム)について, 基本プログラムのソース ttt.c をダウンロードし,これを元にして, ゲーム盤を任意の大きさ n × n に変更できるように拡張せよ. ただし,標準C言語の規則に従うこと.

ヒント:静的2次元配列の部分をすべて動的1次元配列に置き換えること. メイン関数で malloc() を使う他, 各ユーザ関数の引数 (int bd[N][N], ...)(int *bd, int n, ...) 等へと変更. bd[y][x] はどうする? 後片付けも忘れるな.

また,余裕がある人は,勝敗判定や人工知能(PC対ユーザ)などの処理を追加し, ゲームとしての完成度を高めてみよう.

提出:


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