コレクション【C#】

C#文法:コレクション:B12_誤ったコピー

ここからは、配列のコピーのやり方を見ていきます。ただし、いきなり正しい方法を紹介するのではなく、まずは「誤ったコピー」から始めます。配列をコピーしたつもりが、実はコピーになっていなかった――というのは、C#を学び始めたときに多くの人が一度は踏む落とし穴です。この現象は「参照代入問題」とも呼ばれます。

このレクチャーでは、わざと誤った書き方をして、何が起きるのかをデバッガーで確認し、そのうえで「なぜそうなるのか」を参照型の仕組みから説明します。正しいコピーの方法は次回のレクチャーで扱います。

テスト用のボタンを追加する

これまでのレクチャーと同じように、フォームに検証用のボタンを1つ追加します。ボタンの Text は「誤ったコピー」としておきます。

図1 フォームに「誤ったコピー」ボタンを追加する

図1 フォームに「誤ったコピー」ボタンを追加する

追加したボタンをダブルクリックすると、クリックイベントのハンドラーが自動生成されます。今回は button9_Click という名前で生成されました。まずは、これから何を検証するのかがわかるようにコメントを書いておきます。

リスト1 生成されたイベントハンドラーにコメントを書く

private void button9_Click(object sender, EventArgs e)
{
    //誤ったコピー
    //参照代入問題
}

図2 button9_Clickにコメントを記述したところ

図2 button9_Clickにコメントを記述したところ

コピー元とコピー先の配列を用意する

「配列をどうやってコピーするか」を考えるために、まずコピー元となる配列を用意します。int の配列 haiA を作り、10、20、30 という3つの値を設定します。

続いて、コピー先となる配列 haiB を用意します。こちらは値を設定せず、要素数だけを指定して生成します。要素数には haiA.Length を指定します。この場合 haiA の要素数は3なので、haiB は3個分の領域だけを持った配列になります。

リスト2 コピー元haiAとコピー先haiBを用意する

//誤ったコピー
//参照代入問題
var haiA = new int[] { 10, 20, 30 };   // コピー元:10, 20, 30
var haiB = new int[haiA.Length];       // コピー先:要素数3(haiAと同じ)だけ確保

図3 haiAとhaiBを生成するコード

図3 haiAとhaiBを生成するコード

要素数を 3 と直接書かずに haiA.Length としている点にも注目してください。こうしておけば、コピー元の要素数が変わっても、コピー先は常に同じ大きさで確保されます。

イコールで代入してしまう

ここからが本題です。「haiA を haiB にコピーしたい」と考えたとき、次のようにイコールで代入してしまうのが、今回のテーマである「誤ったコピー」です。

リスト3 誤ったコピー(参照の代入)

var haiA = new int[] { 10, 20, 30 };
var haiB = new int[haiA.Length];

haiB = haiA;   // コピーのつもりで代入してしまう(これが問題)

図4 haiB = haiA; と代入する

図4 haiB = haiA; と代入する

int や string の変数であれば、イコールで代入すれば値が渡されるので、同じ感覚で書いてしまいがちです。コンパイルエラーにもならず、実行時の例外も発生しません。それでも、この書き方には問題があります。

片方の配列だけ値を変更してみる

どう問題なのかを確かめるために、代入のあとで haiA のインデックス1番目(2個目の要素)だけを 999 に変更します。haiB には一切触れません。

リスト4 haiAのインデックス1番目だけを変更する

var haiA = new int[] { 10, 20, 30 };
var haiB = new int[haiA.Length];

haiB = haiA;
haiA[1] = 999;   // haiAだけを変更する。haiBには触れていない

図5 haiA[1] = 999; を追加したところ

図5 haiA[1] = 999; を追加したところ

本当にコピーできているのであれば、haiA は 10、999、30 に変わり、haiB は 10、20、30 のまま残るはずです。実際にどうなるかを見てみましょう。

デバッグ実行で確認する

haiA[1] = 999; の行にブレークポイントを置いて実行し、「誤ったコピー」ボタンをクリックします。すると、この行を実行する直前で処理が止まります。

図6 haiA[1] = 999; の行で停止した状態

図6 haiA[1] = 999; の行で停止した状態

この状態で、haiA と haiB をそれぞれウォッチウィンドウに追加して中身を確認します。変数を右クリックして[ウォッチの追加]を選ぶと登録できます。

図7 代入直後のウォッチ。haiAもhaiBも10、20、30

図7 代入直後のウォッチ。haiAもhaiBも10、20、30

haiA は 10、20、30。haiB も 10、20、30 です。この時点では、きちんとコピーされているように見えます。ここが、この問題に気づきにくい理由です。

では、ステップ実行で haiA[1] = 999; の1行を通過させます。

図8 1行実行後のウォッチ。haiAもhaiBもインデックス1が999になっている

図8 1行実行後のウォッチ。haiAもhaiBもインデックス1が999になっている

変更したのは haiA だけのはずなのに、haiA も haiB も、インデックス1番目の値が 999 になっています。2つの配列が連動しているように見えます。

なぜ連動するのか

連動しているように見えますが、実際には「連動している」のではなく、内部的に「同じものを見ている」のです。

配列は参照型です。参照型の変数は、データそのものを持っているのではなく、データが置かれている場所(アドレス)だけを持っています。したがって、参照型と参照型をイコールでつなぐと、中身の値がコピーされるのではなく、参照先のアドレスが渡されます。その結果、2つの変数が同じ参照先を見ることになります。

図9 haiB = haiA; によってhaiBはhaiAと同じ参照先を見るようになる

図9 haiB = haiA; によってhaiBはhaiAと同じ参照先を見るようになる

もともと haiA と haiB は、それぞれ new で生成された別々の配列、つまり違うアドレスを見ていました。

図10 代入前はhaiAとhaiBがそれぞれ別の参照先を持っている

図10 代入前はhaiAとhaiBがそれぞれ別の参照先を持っている

ところが haiB = haiA; の行で、「haiB も haiA と同じアドレスを見なさい」としてしまっています。これ以降は、haiA を変えても haiB を変えても、同じ参照先の値が変わるだけです。コードにコメントで状況を書き込むと、次のようになります。

リスト5 参照先の移り変わり

// haiA → 配列① [10, 20, 30]
var haiA = new int[] { 10, 20, 30 };

// haiB → 配列② [0, 0, 0](①とは別の配列)
var haiB = new int[haiA.Length];

// haiB → 配列① を見るように変わる
// (配列②はどこからも参照されなくなる)
haiB = haiA;

// 配列① が [10, 999, 30] になる
// haiAもhaiBも配列①を見ているので、どちらから見ても999
haiA[1] = 999;

せっかく new int[haiA.Length] で確保した haiB 用の領域(配列②)は、代入した瞬間に誰からも参照されなくなり、まったく使われていない点にも注意してください。

逆方向も同じです。動画では haiA 側を変更しましたが、haiB 側を変更しても haiA から見た値が変わります。また、2つの変数が同じインスタンスを指しているかどうかは object.ReferenceEquals で確かめられます。

リスト6 (補足)haiB側を変更した場合と、同一インスタンスの確認

var haiA = new int[] { 10, 20, 30 };
var haiB = new int[haiA.Length];

var before = object.ReferenceEquals(haiA, haiB);   // false:別々の配列

haiB = haiA;

var after = object.ReferenceEquals(haiA, haiB);    // true:同じ配列を見ている

haiB[0] = 5;          // haiB側を変更しても…
var value = haiA[0];  // 5:haiAから見た値も変わっている

まとめ

今回書いたコードの全体は次のとおりです。

リスト7 誤ったコピー(完成コード)

private void button9_Click(object sender, EventArgs e)
{
    //誤ったコピー
    //参照代入問題
    var haiA = new int[] { 10, 20, 30 };
    var haiB = new int[haiA.Length];

    haiB = haiA;
    haiA[1] = 999;
}

配列は参照型なので、イコールでつないでも中身はコピーされず、同じ参照先を見る変数が1つ増えるだけです。片方を変更するともう片方も変わったように見えますが、それは連動しているのではなく、同じものを見ているからです。このように、参照型をイコールでつないでコピーしたつもりになってしまうのが「参照代入問題」です。

では、配列を正しくコピーするにはどうすればよいのか。その方法は次回のレクチャーで見ていきます。

C#文法:コレクション

■非公開コース「C#14新機能」プレゼント:
非公開コース「C#14新機能」(80分)をご覧になりたい方は
こちらからURLとパスワードを発行していますので、ご覧になってみてください。
非公開コース「C#14新機能」を観る

A01_はじめに
A02_プロジェクトの作成
B01_配列とは
B02_配列の生成とアクセス
B03_生成と同時に値を設定する
B04_型推論による生成
B05_メソッドの引数などにする場合の注意点
B06_Length
B07_IndexOfでの検索
B08_FindIndexでの検索
B09_Find
B10_Exists
B11_FindAllとFindLast
B12_誤ったコピー
B13_Array.Copy
B14_範囲指定のコピー
B15_Resize
C01_ArrayList
C02_List
C03_List 動的な要素の変更
C04_Listのコンストラクタ
C05_Listのコンストラクタ_Capacity
C06_ListTからArrayクラスのメソッドが呼ばれている
D01_コレクションインタフェースとは
D02_異なるコレクションクラスに互換性を持たせる
D03_インタフェースの階層構造
D04_クライアントができる操作を制限させる
D05_クライアントができる操作を制限させる_後半
D06_Enumerableの拡張メソッドに関して
D07_ReadOnly系のコレクションインタフェース
D08_AsReadOnly
D09_ToListでコピーする
D10_ListTはprivateで使う

■非公開コース「C#14新機能」プレゼント:
非公開コース「C#14新機能」(80分)をご覧になりたい方は
こちらからURLとパスワードを発行していますので、ご覧になってみてください。
非公開コース「C#14新機能」を観る