配列とは、タイプが同一の変数の数列を扱うためのデータタイプです。
配列(array)にはいくつかの制限があります。宣言時に指定した要素数で固定されますし、そのせいで自由にデータの挿入が出来なかったり。C#では、例えばListの方が使いやすいので、無理に配列を使わなくてもいいかもしれません。
*追記*Listでも本記事と同様な現象は起きます。
しかし、今回はそんな配列について、思わぬ事態に遭遇したのでまとめてみたいと思います。配列に配列を代入したら、どうなるのか?
浅いコピー、深いコピー
タイトルの浅いコピーと深いコピーというのは、Shallow Copy, Deep Copyから来ています。まず変数は変数に代入することで、その内容をコピーすることが出来ます。
int a = 12, b = 8;
a = b;
// a >> 8, b >> 8
これは単純なintタイプの変数ですが、この時はaの値は上書きされ、8となります。ただ、その後にbに2を代入すると、aは8、bは2という風に別々の値になります。別々の変数なので当然ですが。
b = 2;
// a >> 8, b >> 2
しかし、配列も同様に、単純な代入でコピーをしようとすると、不思議なことが起こります。
配列のコピー
int[ ] array1 = {1, 2, 3, 4, 5}
int[ ] array2 = array1;
配列の場合、ある配列に対して別の配列を代入すると、たしかに内容、各要素の値は同じな配列となるので、コピーできたかのように見えます。しかし、その後が問題です。
array1[1] = 0;
// array1 >> 1,0,3,4,5
// array2 >> 1,0,3,4,5
array1の値を変更すると、なぜかarray2の値も変更されます。これこそが、浅いコピーによる不可思議な現象です。
変数をコピーする目的は、値が同じだが独立した別々の変数、配列を得る、というもの。しかし、Shallow Copyでは、メモリ上に存在する一つのデータ列を2つの配列が共有する、という現象が起きてしまいます。
array1 >> {1, 2, 3, 4, 5} << array2
配列とポインタ
実は、配列は本質的にポインタと同じ構造となっています。ポインタとは、ある変数のメモリ上のアドレス値を格納するための変数です。(C#でポインタは基本的にはあまり触れられませんが、CやC++では初心者殺しで有名)
配列変数は複数のデータを実際にまとめて格納しているわけではなく、実際には一番先頭の値(つまり、[0]の値)が保存されているメモリアドレス値を格納してるので、仕組みは実はポインタと同じになります。
配列の各要素のデータは、メモリ上に連続的に保存されています。アドレス値を利用したインデクシング(indexing)によって、各要素の値を出し入れできるというのが、配列の仕組みです。
array << アドレス番号が記録されている
[1] << そのタイプのバイト数×添え字の分だけアドレス番号をずらす
array[2] << arrayがintの配列なら、intは4バイトなので、4*2=8 arrayのアドレスがもし100なら、三番目の要素は100+8 = 108番地のメモリに記録されていることになり、そのアドレスのメモリに格納されているデータをコンピュータが読み込む、ということになります。
こうした仕組みはC#であっても同じで、つまり配列に配列を直接代入しても、アドレス値がコピーされるだけで、同じ要素を持った別の配列は作られません。
なので、浅いコピーに対する深いコピーとは、プログラマが明示的に同じ要素数を持った別の配列を宣言して、一つずつコピー先の要素に対応したコピー元の要素の値を代入していく、ということになります。
int[ ] array2 = new int[ array1.Length ]
for(int i= 0; i < array1.Length; i++){
array2[i] = array1[i];
}
こうすれば、同じ値を持った別個の配列が作られるので、片方の操作がもう片方に反映されることはありません。
きっかけ、倉庫番
倉庫番というゲームをコンソールアプリで作っていて、この事態に遭遇しました。コンソールアプリはCUIで、文字しか表示できません。しかし、ローグという偉大なゲームもCUIから誕生していますし、単純な昔ながらのゲームを原始的に再現するのは練習になるだろうと思ったので、挑戦しました。ただし、ノーヒントではなく、Python版の解説には目を通してます。
ルールとしては、プレイヤーは荷物や箱を押すことしか出来なく(引くことは出来ない)、指定の場所(床)に全ての箱を移動させたらクリアです。ゲームが積むこともあるので、レベルリセット機能も必要になります。
プログラムの流れとしては、まずマップを読み込み、画面に表示する。そして、プレイヤーの入力を受け付けた後、プレイヤーや箱を移動させ、コンソールの表示をConsole.Clearで消して、入力後のマップを直ぐに描画する、というもので、これをゲームクリアするまでループします。
また、この手のマス目の2Dゲームは、マップをテキストで表すことが出来ます。実際、その方式の練習も兼ねていました。応用すれば、3Dゲームでも同様にステージやダンジョンを単純なテキストデータで表現、保存することも可能なようです。
プログラム内部では、charの配列として扱います。
char[ ][ ] map = {
new char[ ] = {'#', '#', '#', '#', '#'},
new char[ ] = {'#', ' ', ' ', ' ', '#'},
new char[ ] = {'#', '#', '#', '#', '#'}
} //こんな感じです
2Dマップなので、二次元配列でなければいけません。二次元配列とは、配列の配列です。しかし、ジャグ配列を採用しました。二次元配列とちがい、各行の要素数を自由に変更できるからです。
また、各面を管理するのに、配列の配列(マップ)を配列(各面)として、まとめる必要があり、大きさの違うマップをより簡潔にまとめるのにも適していました。
二つのマップを利用する
そんなわけで、ひとまず岩を押す機能は置いておいて、移動の実現を優先しました。移動キーを押したら、その先のマスをチェックして、壁でなかったらプレイヤーの文字をそのマスに表示させ、プレイヤーのいたマスには、マップの元の床を表示させる、という入れ替え方式です。
これには、その面のマップ構造を表す配列と、現在のマップ状態を表す配列の2つの配列が必要でした。それで、最初はマップを読み込んだ時点で、今回扱った浅いコピーをやってしまったのでした。
単純な移動だけだと、問題は顕在化しなかったのですが、岩を押す機能を加えたとたんに、奇妙な動作をするようになりました。プレイヤーが増えたり、岩が増えたりです。最初はとにかく原因不明で焦りました。
それで、とりあえず2つの配列を同時に画面に映そう、とやってみたら、見事に操作にあわせて、2つのマップがシンクロして動きました。
マップ構造の配列は、変わってしまっては駄目です。プレイヤーや岩が移動した後に、元々その床が何だったのかを参照するデータベースだからです。繰り返しますが、浅いコピーによって、独立した2つの配列に出来ていなかったのが原因。移動だけの時は上手くいっていた(ように見えていた)ので、原因究明が難しかったです。
原因が分かったので、先程書いたようにきちんと深いコピーで配列を作り、無事問題は解決されました。
配列には気をつけよう!
というわけで、配列の宣言、代入やあるいはポインタとしての性質について、書いてみました。C#使いであっても、ある程度ポインタの知識は必要ですね。
ただ、難しく考えすぎず、変数は箱ではなくて、メモリ上の一区画、と捉えて、そのメモリの番地、住所がアドレス、だと考えればいいと思います。