【知っておこう】値型と参照型、値渡しと参照渡しの本質【じゃ!】

知っておかなくていい
正直これからの時代、AIにコードを書かせる上でこんなことは理解しておく必要はありません。
ただ、これらのトピックについて理解に苦しんでいた人はハッとする内容だと思います。
理解できた瞬間の気持ちよさを目的に、娯楽を目的とした読み物として読んでください。
1. プログラムが使用する2つのメモリ領域:スタックとヒープ
「値渡し」と「参照渡し」の違いは、実はプログラムにおけるメモリの利用の仕組みと密接に関係しています。
そのため、まずは「スタック領域」と「ヒープ領域」という、プログラムが使用する2つの主要なメモリ領域について理解しましょう。
プログラムが実行される際、OSからメモリ(RAM)の一部が割り当てられます。
その領域のうち、変数のデータを記憶するために使われる主要な領域が「スタック(Stack)領域」と「ヒープ(Heap)領域」です。

スタック領域(Stack)
スタックは、関数を呼び出す際の一時的なデータ(ローカル変数や引数、関数の戻り先アドレスなど)を保存するための領域です。
-
特徴: LIFO(後入れ先出し)方式でデータを管理します。スタックポインタという単一のレジスタを上下させるだけでメモリの確保と解放ができるため、非常に高速に動作します。
-
制約: コンパイル時、あるいは関数呼び出し時に「サイズが確定しているデータ」しか置くことができません。
ヒープ領域(Heap)
ヒープは、プログラムの実行中(動的)に任意のタイミングでメモリを確保・解放するための領域です。
-
特徴: データのサイズが事前にわからない場合や、関数の実行が終了した後もデータを保持し続けたい場合に使用されます。
-
制約: 空き領域を探して確保する処理が必要なため、スタックと比較して動作が低速です。また、不要になったメモリの解放(ガベージコレクションや手動での解放処理)が必要になります。
2. 値型と参照型の違いは「データの実体がどこにあるか」
変数には「値型」と「参照型」が存在します。これらの違いは、データの実体をスタック領域に置くか、ヒープ領域に置くかという1点に尽きます。
値型(Value Type)
数値(整数、浮動小数点数)や文字(キャラクタ型)、真偽値など、メモリサイズが固定されている単純なデータ型です。
値型の変数を宣言すると、スタック領域に直接データの実体が書き込まれます。
例として、int a = 10; という処理が行われた場合、スタック領域の特定のアドレス(例:0x0001)に、10 という数値データそのものが保存されます。

参照型(Reference Type)
配列、クラスのインスタンス(オブジェクト)、文字列など、サイズが可変であったりデータ構造が複雑な型です。
参照型の変数を宣言すると、データは以下の2箇所に分かれて保存されます。
-
ヒープ領域: データの実体(例:配列の要素そのもの)が保存されます。
-
スタック領域: そのヒープ領域上の「メモリの先頭アドレス(ポインタ)」が保存されます。
例として、int[] b = new int[] {1, 2, 3, 4}; という処理が行われた場合、ヒープ領域(例:アドレス 0x1000)に new int[] {1, 2, 3, 4} というデータの実体が作られ、スタック領域の変数 b の位置には、そのアドレスである 0x1000 という数値(参照値)が保存されます。

3. 値型と参照型で「コピーの挙動」が変わる理由
「値型」と「参照型」の違いが最も顕著に現れるのが、別の変数への代入です。
コンピュータが行う代入操作とは、常に「スタック領域にある値をコピーする」という動作です。挙動の差が生まれるのは、スタック上に置かれているものが型によって異なるからです。
値型の代入
int x = 10;
int y = x;
値型ではスタック上に実データが直接置かれているため、コピーされるのは実データ(10)そのものです。
y = 20; と書き換えても独立した別のスタック領域が書き換わるだけなので、x の値は 10 のままです。

参照型の代入
int[] arr1 = new int[] {1, 2, 3, 4};
int[] arr2 = arr1;
参照型ではスタック上にヒープのアドレス値が置かれているため、コピーされるのは「ヒープ領域のアドレス(例:0x1000)」です。
結果として、arr1 と arr2 のスタック上の値はどちらも 0x1000 となり、ヒープ上の同一のデータを指し示すことになります。
そのため、arr2[0] = 99; と操作すると、アドレス 0x1000 のデータが書き換わるため、arr1[0] を参照しても 99 になります。
// 参照型の代入の例
int[] arr1 = new int[] {1, 2, 3, 4}; // ヒープのアドレス 0x1000 をスタックに保存
int[] arr2 = arr1; // arr2 も同じアドレス 0x1000 をスタックに保存
arr2[0] = 99; // ヒープのアドレス 0x1000 のデータを変更
Console.WriteLine(arr1[0]); // 99 と出力される(arr1も同じヒープを指しているため)

ただし! arr2 のオブジェクト自体を置き換えると、arr2 のスタック上の値が新しいヒープ領域のアドレス(例:0x2000)に変わります。
arr1 は引き続き 0x1000 を指し示すため、arr1 と arr2 は値・アドレスともに完全に独立した状態になります。
// 参照型の代入で、変数自体を新しいオブジェクトに差し替える例
int[] arr1 = new int[] {1, 2, 3, 4}; // ヒープのアドレス 0x1000 をスタックに保存
int[] arr2 = arr1; // arr2 も同じアドレス 0x1000 をスタックに保存
arr2 = new int[] {1, 2}; // arr2 のスタック上の値が新しいアドレス 0x2000 に変わる
arr2[0] = 99; // ヒープのアドレス 0x2000 のデータを変更
Console.WriteLine(arr1[0]); // 1 と出力される(arr1はまだ0x1000を指しているため)

4. 値渡し(Pass-by-Value)と参照渡し(Pass-by-Reference)
ここまでの「型」の話と切り離して理解しなければならないのが、関数への引数の「渡し方」です。
「値渡し」「参照渡し」はいずれも関数呼び出しに固有の概念であり、変数への単純な代入とは区別されます。
関数を呼び出す際、スタック領域に新しいブロック(コールスタックフレーム)が積まれます。呼び出し元の変数をこの新しいフレームにどのように引き渡すかで、挙動が大きく変わります。
値渡し(Pass-by-Value)
値渡しとは、呼び出し元のスタック領域にある値を、関数の引数用に確保された新しいスタック領域にコピーして渡す方式です。
つまり、上のセクションで見た「変数への代入」と全く同じ挙動です。
現在主流の多くの言語(Java、Python、JavaScript、Ruby、Go、C#のデフォルトなど)における関数の引数渡しは、すべて「値渡し」です。
- 値型を渡す場合 → 実データがコピーされる(関数内での変更は呼び出し元に影響しない)
- 参照型を渡す場合 → アドレス値がコピーされる(関数内でヒープ上のデータを変更すると呼び出し元にも影響するが、引数を丸ごと別のオブジェクトに差し替えても呼び出し元の変数には影響しない)
参照渡し(Pass-by-Reference)
C++(& を使用)、C#(ref キーワードを使用)など、一部の言語で明示的にサポートされている渡し方です。
参照渡しとは、スタック上の値をコピーして渡すのではなく、呼び出し元のスタック領域の「変数そのものの場所(メモリアドレス)」を関数に渡す方式です。
関数側で引数に代入を行うと、関数のローカルなスタックではなく、呼び出し元のスタック領域を直接書き換えます。
つまり、値型であっても、呼び出し元の変数自体を新しい値に書き換えることが可能になります。
// 値型の「参照渡し」の例
// 引数を参照渡しすることで、呼び出し元の変数自体を書き換えることが可能になる
void ModifyValue(ref int y) {
y = 99;
}
// 呼び出し元
void Example() {
int x = 10;
ModifyValue(ref x);
Console.WriteLine(x); // 99 と出力される
}

参照型の場合
また、参照型の変数を「参照渡し」した場合、関数内で全く新しいインスタンス(別のアドレス)を代入すれば、呼び出し元の変数もその新しいインスタンスを指すようになります。
つまり、参照型の参照渡しは、オブジェクト内部の値を変更することも、変数自体を新しいオブジェクトに向け直すことも可能になります。
// 参照型の「参照渡し」の例
// ① アドレス先のヒープを書き換える(呼び出し元に影響する)
void ModifyArray(ref int[] arr2) {
arr2[0] = 99;
}
// ② 引数を丸ごと別のオブジェクトに差し替える(呼び出し元も新しいオブジェクトを指すようになる)
void ReplaceArray(ref int[] arr2) {
arr2 = new int[] {1, 2};
arr2[0] = 99;
}
// 呼び出し元
void Example() {
int[] arr1 = new int[] {1, 2, 3, 4};
ModifyArray(ref arr1);
Console.WriteLine(arr1[0]); // 99 と出力される(ここまでは「参照型の値渡し」と同じ挙動)
arr1 = new int[] {1, 2, 3, 4};
ReplaceArray(ref arr1);
Console.WriteLine(arr1[0]); // 99 と出力される(arr は新しいオブジェクトを指すようになるため)
}

5. まとめ:4つの組み合わせによる整理
これまでの事実を整理すると、「型」と「渡し方」の組み合わせによって、挙動は以下の4パターンに分類されます。
| データ型 | 関数の渡し方 | スタック領域にコピーされるもの | 関数内で呼び出し元の変数自体を書き換えられるか | 関数内で呼び出し元のデータの一部を書き換えられるか |
|---|---|---|---|---|
| 値型 | 値渡し | 実データ | 不可 | - |
| 参照型 | 値渡し | ヒープのアドレス値 | 不可 | 可能 (ヒープを操作するため) |
| 値型 | 参照渡し | スタックの変数アドレス | 可能 | - |
| 参照型 | 参照渡し | スタックの変数アドレス | 可能 | 可能 |
「スタックにあるものを操作しているのか」「ヒープにあるものを操作しているのか」。常にこの物理的な事実を意識することで、複雑なプログラムにおけるデータの状態変化を、正確に予測できるようになるはずです。

Discussion
ヒープにあるメンバ変数を参照渡しすることもできますね。
配列の インデクサは 参照戻り値であることに注意が必要です。(その為
List<T>だとできない)C# の ref ローカル もしくは 参照変数 がいわゆる C++ でいうところの 参照変素では……? (C#7 から追加された構文 配列のインデクサはそれ以前から使用可能だったが一般化された流れ)
※ただし、 C# の参照変数 という表現自体は 稀に 参照型が指定された変数のことを指す場合がある為、非常に語弊がありえる用語であることも注意が必要です。(その為の ref ローカル変数という用語なので。