Language:
  • 英語 (米国)
  • 日本語
more 

[Unity] Array/配列的な奴らとの付き合い方-type[]とかList<T>とか

今回もUnity始めたての頃に詰まりがちな部分について書こうかと思います。題名の通り配列的な奴らの話です。UnityというよりC#の話かな。

ただしこうスクリプト書いてこう使うんですよ、という実践レクチャーではなく、配列的なものを使ってみようとしたらなんかいくつか種類があってどれが何やら、という人へ向けてのお話です。手を付け始めは調べた事の説明の中に新たな知らない事が出てきて樹海に迷い込んで二度と出てこない、ということになりがちだからね。調べていくうちにブラウザのタブが100個とかになってて考えるのをやめる前に、うっすらでもいいので全体的な知識さえあれば疑問点がハッキリします。そうすると質問も的確になるので、実際どう使うかとかはあとでGoogle先生がいくらでも教えてくれます。取っ掛かりのためにまず全体像をつかむの大事。

とは言え、ここで初めて配列っていう存在を知るというより悩んだ人が検索で来るってパターンが多いだろうから、配列とはどういうものかという概念から話す必要はないよね?ググった時点で存在は知ってるはずなので。なんか簡潔な説明しづらいです、配列。連続した変数のようなものを使って順番にぶっこんで便利に利用するアレです。ほら余計わかりづらい。存在すら知らないものを使おうとは思わないはずなのでまあそこは飛ばします。

あと基礎なので1次元配列に関してだけ話します。多次元配列っていうのが、表にすると行と列からなる掛け算九九みたいになる(あるいはさらに複雑な)タイプね。1次元配列は掛け算九九で言えば3の段だけみたいな1列の単純なタイプです。

では、まず例によって系統を挙げる。

用途に合わせていくともっと種類あるけど、基礎的な使用法でどれ使えばいいのやら、ってなるのはこの3つだと思う。各々の利点とデメリットに焦点を当てつつ、どう使えばいいのかについて説明していきます。


ArrayList

C#では数字ならint、文字ならstringと宣言して型を厳密に扱うのが通常です。仮に
int hoge;
と宣言した変数hogeに文字を入れようとするとエラーとなります。この制約を取っ払おうとした変則的な配列と言えるのがArrayListです。つまりArrayListの各要素にはintでもstringでもfloatでも型をゴチャゴチャに入れることができます。

一見万能選手に見えて便利そうですが、実際は使用しない人が多いです。理由は2つ。
一つ目の理由は、無駄なコストが発生する。どんな型でも要素に入るように言ったけど、実際の内部処理はObjectという言わば無属性的な扱いの型に一度変換して格納されています。例えばintを入れて再び使う場合、int->Object->int という型変換が発生せざるを得ません。この処理コストが多数のデータを扱う場合はネックになってくる。一方intしか入らないよう宣言された通常の配列であれば、このコストは発生しません。
二つ目の理由は、何の型でも入るがゆえにエラーの元になる。例えば、intである42をArrayListに入れたとします。ここで一つ目の理由のとおり一旦Objectという型になるわけだけど、こうなるとコンピュータの中の小人さんたちはもうこの42が数字なのか文字なのかわからない。で、後々あなたが「さっき渡したやつから-5したら答えなに?」と聞いても、小人さんたちは「さっき渡されたやつはもう数字か文字かわからないのでアバババー」と答えます。アッパーカット。しかもたちの悪いことに、型が決められている配列ならプログラムを実行するまでもなく「文字から数字は引けないから直して」とエラーを出してくれるのに対して、ArrayListは何でも入るが故に合ってるのか間違ってるのかの判断ができずプログラムを実行してみるまでエラーかどうかわかりません。

これらのデメリットを超えてどんな型でも入るという利点を生かす使い道があまり無いため、多くの人は「ArrayListは使わなくていいや」に落ちつきます。もし型をごちゃ混ぜに入れられるからこその上手い処理を考えたとしても、後述のListの型宣言をObjectにすればちょっと手間が増えるだけで同じことができちゃうので、正直ArrayList独自の強みというのはあまり無いです。忘れといてもたぶん問題ない。


type[] (int[]とかstring[]とか)

C#の基本的な配列と言ったらこれ。以下のような感じで宣言する。
string[] TestArray = new string[5];
これでTestArrayという配列にstringが入れられる0~4の5つの要素ができます。これにTestArray[0]=”honda”;
とすれば、TestArray[0]が変数の役割を果たすので、Print(TestArray[0]);ならコンソールにhondaと出力されます。この変数が連番になっている感じなことで順番に値をぶち込んで後々便利に引き出して使うとかが基本的な配列の使い方。

例えばこれでThreeMultiply[0]~[9]に九九の3の段の配列ができるので、ThreeMultiply[6]で3*6の答えの18を引き出すとかできるようになる。まあこの場合は毎回3*6とか記述した方が楽じゃんという無駄な事になってるけど例えよ例え。

で、このtype[]の配列のメリットは一番扱いが簡単なこと。後述の比較対象であるList<T>はusingなんちゃら~とかスクリプトの冒頭で名前空間を宣言しないと使えないけど、こっちは基本的にはusingの記述は必要ない。あと、データの参照に使うだけならこっちのがちょっとだけ早い。

メリットを見るに割とList<T>より優位かな、と思えるけど「基本的には」とか「だけなら」というなんか含みがあるのが気になるよね。というのも、type[]の配列には明確なデメリットがある。要素数の増減が超苦手。一応、

とすれば、この例であればTestArrayの要素は最初の5から3つ増やせるけど、これ内部的には新たにnew string[8]な配列を作って、そこに以前の要素が5つの配列をコピーするという処理をしている。何が問題かというと、付け足すんじゃなくてもともとあった5つの要素まで無駄に作り直されてること。
別に結果は同じものができるんだからいいじゃん、と思うかもしれないけど、これが例えば10000を10001にしたいとすると、1増やすためだけにもともとの10000の要素が作り直されることになり相当無駄です。あなたが小人さんに千羽鶴を折るのを手伝ってくれるよう頼んで、「あ、やっぱ千一羽にして」と言ったら小人さんはやっと折った千羽を全部捨てて新たに千一羽の鶴を折り始めます。おい、小人おい!

更にこの要素数を変更する処理を使う場合は、using System; の名前空間の宣言が必要になります。コストがかかって速さはなくなるわ、名前空間が必要で手軽さはなくなるわでメリットが全部潰れてしまいます。

つまり、type[]の配列は「あとで配列の要素数を変更しない」場合に限って使用することで最良の結果を得ることができます。自分の中で「type[]の配列は要素数を変更しない」と決めておくことで要素数を厳密に扱うことができ、エラーの可能性も減らせる。弱点を知っておいて、長所だけ使って短所は封鎖することで最適に使えばよいのです。


List<T>

type[]の配列の弱点を担うのがこの最後のList<T>になります。つまりList<T>のメリットは要素数の増減が楽に行える。

この例ではTestListを作って順に要素に文字を入れてるけど、要素数の増減が得意なので要素の数とかいちいち宣言しなくてよい。Add(“honda”)の時点で自動的に要素数は1に増やされて、Add(“toyota”)の時点で要素数はさらに1から2になってる。やだ、この子できる子!

そしてデメリットもまんまtype[]の配列の長所の逆。データの参照に関しては処理がちょっとだけ遅くて、スクリプトの冒頭でusing System.Collections.Generic; という名前空間の宣言が必要。

ただデメリットの軽微さの割に要素数の増減という大きなメリットがあるので、配列的なものを使いたかったらList<T>一辺倒でもいいくらいに便利。さっきのスクリプト例の Addのような後ろに要素を加えるだけじゃなく、要素を途中に挿入したり逆に一部消したりといったことをするのも結局全体の要素数が変わる処理なので、List<T>が優位に使える場面は非常に多い。


というわけで、総括としては基本的にはList<T>を使っておけば問題ないです。そしてGetComponentsInChildrenみたいな複数の要素を取得するものを一時的に入れとくとか、処理上一回必要なだけでもう使わないみたいな使い捨ての場合にtype[]の配列を使えばいいと思います。




ここからは、やや詳細な比較としてtype[]の配列とList<T>での、要素数の増減、削除、挿入、クリア、コピー、などのスクリプトを書いておきます。
辞書的な使い方も兼ねて。


type[]の配列

* using System; の名前空間の宣言が必要な場合は忘れないように

要素の変更(上書き)

この例ではインデックス1の要素の値(toyota)をsuzukiにしている。

要素数の増減

この例では要素数を5から8にしている。

削除

要素ごと消すのではなく、数値なら0、stringなどならnullになる。要素数自体は増減しないので全体の要素数TestArray.Lengthは変わらない。上の例なら
int[] TestArray = {0, 1, 2, 3, 4};
だったとするとインデックスが3の部分から2つ消すので
int[] TestArray = {0, 1, 2, 0, 0};
こうなる。
ちなみに全消去なら Array.Clear(TestArray, 0, TestArray.Length); とすればよい。

挿入

単純に挿入することはできない。挿入後に必要となる要素数の配列を新たに作り、後述のCopyを使うなどして挿入された形を自分で作る。

クリア

要素数を0にすることでクリア化。あるいはTestArray = new string[]; と新たに宣言し直す。

上書きコピー

コピー先の配列には必要な要素数が存在してなければならない。上の例なら
int[] TestArray = {0, 1, 2, 3, 4};
int[] NewArray = {11, 12, 13, 14, 15, 16, 17, 18, 19};
だったとすると、TestArrayの0~2をNewArrayの5~7に上書きする形になるので
NewArray = {11, 12, 13, 14, 15, 0, 1, 2, 19};
こうなる。

コピー元の要素全体を写すならCopyToというのもある。これは using System; の宣言は必要ない。

こちらもコピー先の配列には必要な要素数が存在してなければならない。上の例ならTestArrayの全ての要素をNewArrayの3番目から要素数ぶん上書きする。


List<T>

* List<T>を使う場合 using System.Collections.Generic; の名前空間の宣言が必須

要素の変更(上書き)

この例ではインデックス1の要素の値(toyota)をsuzukiにしている。

要素の追加

要素数(入れ物)は自動で増えるので、type[]の配列と違って要素数を意識せず値を入れればよい。Addは末尾に1つの要素を追加、AddRangeは末尾に他のList<T>を追加するので、上の例では
TestList = new List() {“Red”, “Blue”, “Green”};
NewList = new List() {“Cyan”, “Purple”, “Gray”}
だったとすると末尾にYerrowが追加され、さらにその後にNewListが追加されるので
TestList = new List() {“Red”, “Blue”, “Green”, “Yerrow”, “Cyan”, “Purple”, “Gray”};
こうなる。AddRangeは同じ型であればtype[]の配列を追加することも可能。

追加元のList(上の例でのNewList)の要素の一部だけを追加したい場合は、GetRangeを使って参照する範囲を指定することができる。

これでNewListのインデックス1から2つ分の要素がTestList末尾に追加される。ちなみにGetRangeで存在しない要素まで含めた範囲を指定すると例外が発生して何もAddされないままになるので注意。

要素の削除

Remove系はtype[]の配列と違って要素ごと(入れ物ごと)消すので全体の要素数TestList.Countも消した数に応じて減る。上の例では
TestList = new List() {“Red”, “Blue”, “Yerrow”, “Red”, “Blue”, “Yerrow”};
だったとすると、Removeで要素の先頭から見て最初に該当したものが削除されるので
TestList = new List() {“Red”, “Yerrow”, “Red”, “Blue”, “Yerrow”};
まずこうなる。RemoveAtは指定のインデックスの要素が削除されるので
TestList = new List() {“Red”, “Yerrow”, “Blue”, “Yerrow”};
更にこうなる。そしてRemoveRangeにより範囲削除されるので
TestList = new List() {“Red”};
最終的にこうなる。ちなみにRemoveRangeで存在しない要素部分を含めた範囲を削除しようとすると例外が発生して、存在している部分も消去されないので注意。

もっと複雑な条件で抽出して削除したい場合はRemoveAllを使えば自前のメソッドを通してtrueを返す要素のみ削除できる。これは説明が長くなるので今回は割愛。

要素の挿入

Add系は末尾に追加だったがInsert系は好きな位置に要素を挿入できる。上書きでなく挿入なので全体の要素数TestList.Countは増加する。上の例では
TestList = new List() {“Red”, “Blue”, “Yerrow”};
NewList = new List() {“Green”, “Cyan”, “Purple”};
だったとすると、まずTestListのインデックスが1の場所にBlackが挿入され
TestList = new List() {“Red”, “Black”, “Blue”, “Yerrow”};
となり、さらにインデックスが2の場所からNewListが挿入され
TestList = new List() {“Red”, “Black”, “Green”, “Cyan”, “Purple, “Blue”, “Yerrow”};
こうなる。InsertRangeもまたAddRange同様、同じ型であればtype[]の配列を挿入することが可能。

挿入されるList(先の例でのNewList)の一部分だけを挿入したい場合は、AddRangeと同様にGetRangeを使って参照する範囲を指定することができる。

これでNewListのインデックス1から2つの要素を抜き出してTestRangeのインデックス3の位置から挿入する。ちなみにGetRangeで存在しない要素部分を含めた範囲を指定した場合は例外が発生して何も挿入されないのもAddRangeと同様。

クリア

要素数を0にしてクリアする。type[]の配列と違い0やnullになるわけでなく要素丸ごとクリアするので、クリア後に参照しようとすると例外になる。続けてTestList.TrimExcess();を実行する事でメモリの予約箇所的な部分も開放するので、特に大きな要素数からクリアしたりRemove系でかなり少なくした場合は合わせて実行するとよい。

上書きコピー

List<T>を別のList<T>の要素に上書きコピーするようなメソッドは用意されてないので、該当部分をRemoveRangeで削除したのちInsertRange(あるいはAddRange)で挿入するか、for文でTestList[i] = hoge;を使って1要素ごと順に上書きする。


type[]の配列とList<T>のコンバート(変換)

type[]の配列とLitstList<T>の相互コンバートのやり方。

type[]の配列からList<T>を作成する

Listを作成するときにtype[]の配列を引数として渡せばそのまま作成される。

一部を抜き出して新たなListを作るならLinqを使えばできるが、場合によってはとりあえずListにしてからRemoveRangeなどで整えた方が楽かもしれない。一応Linqを使った例。

Skipで2つの要素を飛ばして、Takeで5つの要素を取得している。


List<T>からtype[]の配列を作成する

ToArrayを使って作成する。

一部を抜き出して新たな配列を作るならGetRangeを使えばよい。

これでTestListのインデックス2の要素から3つぶんだけ抜き出した配列が作成される。


type[]の配列をList<T>へ上書きコピー

配列はInsertRangeでList<T>に挿入できるので、配列(の一部を)List<T>にコピーする場合は、List<T>同士のコピーと同じようにRemoveRangeとInseraRangeを合わせて使って、上書きコピーした形に整える。


List<T>をtype[]の配列に上書きコピー

List<T>(の一部)をすでに存在するtype[]の配列にコピーしたい場合はCopyToを使う。コピー先の配列はあらかじめ必要な要素数以上を持っていなければならない。

これでTestListのインデックス2から3要素分がTestArrayのインデックス0以降に上書きコピーされる。

こんなもので。ゲーム制作に限らずコンピュータ処理において連続した何かを扱う事は様々な場面で必要なので、配列的なものの扱いも避けては通れません。要素のソート(並び替え)や要素の検索、冒頭でちょっと述べた多次元配列とか、インデックスを数字じゃなく自分で決めた文字にするDictionaryとか、まだまだ奥は深いですが、とりあえず樹海に迷い込む前に理解の足掛かりになればと思います。