C# の型システムを理解する上で避けて通れないのが「値型」と「参照型」の違いです。コピーしたはずなのに元のデータが変わった・メソッドに渡した値が反映されない、といったバグの多くはこの違いに起因しています。
本記事では基本のセマンティクスにとどまらず、スタックとヒープのメモリ配置・boxing/unboxing・ref/out/in 引数修飾子・string の不変性・浅いコピーと深いコピーまで体系的に解説します。
C# の型の全体分類
| カテゴリ | 定義方法 | 例 |
|---|---|---|
| 値型(Value Type) | struct |
int・double・bool・char・DateTime・Guid・enum |
| 値型(Value Type) | 組み込み数値型 | byte・short・long・float・decimal など |
| 値型(Value Type) | record struct(C# 10+) |
record struct Point(int X, int Y) |
| 参照型(Reference Type) | class |
ユーザー定義クラス・string・object・配列 |
| 参照型(Reference Type) | record(C# 9+) |
record Person(string Name, int Age) |
| 参照型(Reference Type) | インターフェース・デリゲート | IEnumerable<T>・Action・Func<T> |
string は参照型ですが、不変(immutable)のため値型のような振る舞いをします。この特殊性は後述します。また ValueTuple((int X, int Y) のような名前付きタプル)も値型です。スタックとヒープ:メモリへの配置
値型と参照型の最大の違いはメモリのどこに格納されるかです。.NET ランタイムはメモリを主にスタックとヒープの2つの領域で管理しています。
| 領域 | 特徴 | 仕組み | 格納されるもの |
|---|---|---|---|
| スタック(Stack) | 高速・自動管理 | 後入れ先出し(LIFO)。メソッド呼び出し時に確保し、リターン時に自動解放 | 値型のローカル変数・メソッド引数 |
| ヒープ(Heap) | 柔軟だがGC管理 | オブジェクトを任意のタイミングで確保。GC(ガベージコレクター)が不要になったら回収 | 参照型のオブジェクト本体 |
void ExampleMethod()
{
// ─── スタックに配置される ──────────────────────
int x = 10; // int 値そのものがスタック上
double pi = 3.14; // double 値そのものがスタック上
// ─── ヒープに配置される ────────────────────────
var list = new List<int>(); // リスト本体はヒープ
// list 変数(スタック)にはヒープ上のアドレスが入っている
// ─── struct はスタック(ローカル変数の場合)────
DateTime now = DateTime.Now; // DateTime (struct) はスタック上
// ただし class のフィールドになると struct もヒープ上に配置される
// ─── メソッドが終わるとスタックは自動解放 ──────
} // ここで x, pi, list の「参照」はスタックから消える
// ただし list が指すヒープ上のオブジェクトは GC が回収するまで残る
Span<T> のような ref struct はスタック専用で設計されています。重要なのは配置場所よりも「コピーか参照か」というセマンティクスです。代入時の動作:コピーか参照か
// ─── 値型:代入はコピーを作る ──────────────────────
int a = 10;
int b = a; // b に a の「値」がコピーされる
b = 20;
Console.WriteLine(a); // 10(a は不変)
Console.WriteLine(b); // 20
// struct も同様
DateTime d1 = new DateTime(2025, 1, 1);
DateTime d2 = d1; // 値全体がコピーされる
// d2 を変更しても d1 に影響しない
// ─── 参照型:代入は参照(アドレス)をコピー ────────
class Counter { public int Value; }
var c1 = new Counter { Value = 0 };
var c2 = c1; // c2 は c1 と同じオブジェクトを指す!
c2.Value = 99;
Console.WriteLine(c1.Value); // 99(c1 も変わる!)
Console.WriteLine(c2.Value); // 99
// ─── null の扱いの違い ────────────────────────────
int x = 0; // 値型のデフォルトは 0
// int y = null; // コンパイルエラー(値型に null は代入不可)
string s = null; // 参照型は null を保持できる(何も指さない状態)
Counter c = null;// クラスも null OK
等価比較の違い:値と参照
// ─── 値型の == は値の比較 ─────────────────────────
int x = 5, y = 5;
Console.WriteLine(x == y); // true(値が同じ)
Console.WriteLine(x.Equals(y)); // true
DateTime d1 = new DateTime(2025, 1, 1);
DateTime d2 = new DateTime(2025, 1, 1);
Console.WriteLine(d1 == d2); // true(同じ日付)
// ─── 参照型の == はデフォルトで「参照の一致」を比較 ──
class Box { public int Value; }
var b1 = new Box { Value = 10 };
var b2 = new Box { Value = 10 };
var b3 = b1; // 同じ参照
Console.WriteLine(b1 == b2); // false(異なるオブジェクト)
Console.WriteLine(b1 == b3); // true(同じオブジェクト)
Console.WriteLine(b1.Equals(b2)); // false(Equals もデフォルトは参照比較)
// ─── Equals をオーバーライドすると値比較できる ──────
// 5838(クラスとオブジェクト)記事で詳解
// ─── record は値比較がデフォルト ─────────────────
record Point(int X, int Y);
var p1 = new Point(1, 2);
var p2 = new Point(1, 2);
Console.WriteLine(p1 == p2); // true(record は値比較)
// ─── string は参照型だが == は値(文字列内容)を比較 ──
string s1 = "hello";
string s2 = "hello";
Console.WriteLine(s1 == s2); // true(内容が同じ)
Console.WriteLine(ReferenceEquals(s1, s2)); // true(インターニングにより同一参照)
string s3 = new string(new char[] { 'h', 'e', 'l', 'l', 'o' });
Console.WriteLine(s1 == s3); // true(内容比較)
Console.WriteLine(ReferenceEquals(s1, s3)); // false(異なるオブジェクト)
ReferenceEquals(a, b) は型に関係なく「同じメモリアドレスを指しているか」を確認します。参照型の == のデフォルト動作を確かめたいときに使います。ref・out・in:引数渡しのセマンティクス制御
値型をメソッドに渡すとコピーが作られるため、メソッド内で変更しても呼び出し元に影響しません。ref・out・in 修飾子を使うと、この動作を制御できます。
// ─── 通常(値渡し): 呼び出し元に変更が反映されない ──
void AddTen(int n) => n += 10;
int v = 5;
AddTen(v);
Console.WriteLine(v); // 5(変わらない)
// ─── ref: 参照渡し(読み書き両方可能) ──────────────
void AddTenRef(ref int n) => n += 10;
int v2 = 5;
AddTenRef(ref v2);
Console.WriteLine(v2); // 15(呼び出し元が変わる)
// ref は呼び出し前に初期化が必要
// AddTenRef(ref uninitializedVar); // コンパイルエラー
// ─── out: 戻り値の出力用(初期化は不要・メソッド内で必ず代入)
bool TryParse(string s, out int result)
{
if (int.TryParse(s, out result)) return true;
result = 0; // out は必ずメソッド内で代入する義務がある
return false;
}
if (TryParse("42", out int parsed))
Console.WriteLine(parsed); // 42
// C# 7+: インライン宣言
if (int.TryParse("100", out int n3))
Console.WriteLine(n3); // 100
// ─── in: 読み取り専用参照(コピーを避けつつ変更不可)──
// 大きな struct のコピーコストを避けたいとき
readonly struct BigStruct { /* 多数のフィールド */ }
void ReadOnly(in BigStruct s)
{
// s.X = 10; // コンパイルエラー(変更できない)
Console.WriteLine(s.GetHashCode()); // 読み取りのみ
}
// ─── ref 戻り値: メモリ上の値を直接返す(高度な最適化)
ref int GetRef(int[] arr, int index) => ref arr[index];
int[] data = { 1, 2, 3 };
ref int elem = ref GetRef(data, 1);
elem = 99;
Console.WriteLine(data[1]); // 99(配列の要素を直接変更)
| 修飾子 | 動作 | 事前初期化 | 主な用途 |
|---|---|---|---|
| なし(デフォルト) | 値型: コピー 参照型: 参照コピー |
初期化不要 | 通常の引数渡し |
ref |
参照渡し(読み書き可) | 呼び出し前に初期化必要 | メソッド内外で値を共有したいとき |
out |
参照渡し(書き込み専用) | 未初期化でも渡せる | メソッドから複数値を返すとき |
in |
読み取り専用参照 | 呼び出し前に初期化必要 | 大きな struct のコピーコストを避けるとき |
boxing と unboxing:値型を参照型として扱うコスト
boxing は値型を object(参照型)に変換してヒープに格納する操作です。unboxing はその逆です。どちらもコストがかかるため、ホットパスでは避けるべきです。
// ─── boxing: int → object への暗黙変換 ────────────
int value = 42;
object boxed = value; // boxing:ヒープに新しいオブジェクトが作られる
// ─── unboxing: object → int への明示キャスト ────────
int unboxed = (int)boxed; // unboxing:値を取り出してスタックにコピー
// ─── よくある boxing 発生場面 ─────────────────────
// 1. ArrayList(非ジェネリック)への追加
var list = new System.Collections.ArrayList();
list.Add(42); // boxing(int → object)
int n = (int)list[0]; // unboxing
// 2. object 型の変数への代入
object obj = DateTime.Now; // boxing
// 3. string.Format の {0} 引数
string s = string.Format("{0}", 123); // boxing 発生
// GOOD: 文字列補間は boxing を避けられる場合がある
string s2 = $"{123}"; // boxing なし(コンパイラが最適化)
// 4. ジェネリックでない IComparable
void Sort(System.Collections.IList items) { /* boxing 多発 */ }
// ─── boxing を避ける方法 ──────────────────────────
// GOOD: ジェネリックを使う(boxing しない)
var genericList = new List<int>();
genericList.Add(42); // boxing なし
int m = genericList[0]; // unboxing なし
ArrayList・Hashtable)は避け、List<T>・Dictionary<K,V> などジェネリック型を使いましょう。string は参照型だが値のように振る舞う理由
string は class(参照型)ですが、代入や比較の動作は値型のように見えます。その理由は2つの仕組みにあります。
// ─── 不変性: string の「変更」は常に新しいオブジェクトを生成 ──
string s1 = "hello";
string s2 = s1; // 参照コピー(同じオブジェクトを指す)
s1 = s1.ToUpper(); // "HELLO" という新しいオブジェクトを作り s1 に代入
Console.WriteLine(s1); // HELLO
Console.WriteLine(s2); // hello(s2 は元のオブジェクトをそのまま参照)
// ← 参照型なのに s2 が影響を受けないのは string が immutable だから
// ─── 文字列インターニング(interning)────────────────
// リテラル文字列は同じ内容なら同一オブジェクトを共有(メモリ効率化)
string a = "hello";
string b = "hello";
Console.WriteLine(ReferenceEquals(a, b)); // true(同一オブジェクト)
// new でインターニングを回避
string c = new string(new char[] { 'h', 'e', 'l', 'l', 'o' });
Console.WriteLine(ReferenceEquals(a, c)); // false(別オブジェクト)
Console.WriteLine(a == c); // true(内容は同じ)
// ─── string の == は内容比較(== をオーバーロードしている)──
Console.WriteLine("abc" == "abc"); // true
// ─── StringBuilder: 大量の文字列結合には必須 ────────
// BAD: += はループのたびに新しい string を生成 → O(n²) のコスト
string result = "";
for (int i = 0; i < 10000; i++)
result += i.ToString(); // 毎回コピー
// GOOD: StringBuilder で一気に構築してから ToString()
var sb = new System.Text.StringBuilder();
for (int i = 0; i < 10000; i++)
sb.Append(i);
string result2 = sb.ToString(); // 1回だけ string 生成
浅いコピー(Shallow Copy)と深いコピー(Deep Copy)
参照型オブジェクトをコピーするとき、浅いコピーはオブジェクトのトップレベルのフィールドだけをコピーし、ネストした参照型フィールドは元のオブジェクトと共有されます。深いコピーは完全に独立したコピーを作ります。
class Address
{
public string City { get; set; } = "";
}
class Person
{
public string Name { get; set; } = "";
public Address Address { get; set; } = new();
// 浅いコピー: MemberwiseClone() は protected なので public にラップ
public Person ShallowCopy() => (Person)MemberwiseClone();
// 深いコピー: ネストしたオブジェクトも新しく作る
public Person DeepCopy() => new Person
{
Name = this.Name,
Address = new Address { City = this.Address.City }
};
}
var original = new Person { Name = "Alice", Address = new Address { City = "Tokyo" } };
// ─── 浅いコピー ────────────────────────────────────
var shallow = original.ShallowCopy();
shallow.Name = "Bob"; // Name は独立してコピーされている
shallow.Address.City = "Osaka"; // Address は共有されている!
Console.WriteLine(original.Name); // Alice(独立)
Console.WriteLine(original.Address.City); // Osaka ← 元のデータが変わった!
// ─── 深いコピー ────────────────────────────────────
var original2 = new Person { Name = "Alice", Address = new Address { City = "Tokyo" } };
var deep = original2.DeepCopy();
deep.Address.City = "Sapporo";
Console.WriteLine(original2.Address.City); // Tokyo(影響なし)
record の with 式も浅いコピーです。ネストしたクラス型フィールドは共有されます。深いコピーが必要な場合は手動実装・シリアライズ/デシリアライズ(JSON)・AutoMapper などのライブラリを使う方法があります。struct(構造体)を選ぶ指針
ユーザー定義型を class(参照型)と struct(値型)のどちらにするかは重要な設計判断です。
| 項目 | struct(値型) | class(参照型) |
|---|---|---|
| サイズ目安 | 16バイト以下(推奨) | 制限なし |
| コピーコスト | コピーされるため小さい方が良い | コピーは参照のみ(軽量) |
| 不変性 | イミュータブルに設計すると安全 | 可変でも問題なし |
| 継承 | 不可(インターフェースは実装可能) | 可能 |
| null | デフォルト不可(Nullable<T> で対応) |
null 代入可能 |
| GC 圧力 | ヒープ割り当てなし(コレクション内は除く) | ヒープ割り当てが発生 |
| 適した用途 | 座標・色・お金・IDなど小さな値オブジェクト | エンティティ・サービス・複雑なデータ |
// BAD: 可変 struct は直感に反する動作になりやすい
struct MutablePoint { public int X; public int Y; }
MutablePoint p = new MutablePoint { X = 1, Y = 2 };
var copy = p; // 値型なのでコピー
copy.X = 99;
Console.WriteLine(p.X); // 1(影響なし → 意図どおり)
// しかし LINQ や foreach で予期しない挙動になることがある
// GOOD: readonly struct で不変性を保証する(C# 7.2+)
readonly struct Point
{
public int X { get; }
public int Y { get; }
public Point(int x, int y) { X = x; Y = y; }
public Point Translate(int dx, int dy) => new(X + dx, Y + dy); // 新しいインスタンスを返す
public override string ToString() => $"({X}, {Y})";
}
var pt = new Point(1, 2);
var moved = pt.Translate(3, 4);
Console.WriteLine(pt); // (1, 2)(不変)
Console.WriteLine(moved); // (4, 6)
// ─── 値オブジェクトの実例 ────────────────────────
readonly struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency) { Amount = amount; Currency = currency; }
public Money Add(Money other)
{
if (Currency != other.Currency) throw new InvalidOperationException("通貨が異なります");
return new Money(Amount + other.Amount, Currency);
}
public override string ToString() => $"{Amount:N0} {Currency}";
}
var price = new Money(1000, "JPY");
var tax = new Money(100, "JPY");
var total = price.Add(tax);
Console.WriteLine(total); // 1,100 JPY
ref struct:スタック専用の高性能型
C# 7.2 で導入された ref struct はスタックにのみ存在でき、ヒープには割り当てられません。Span<T>・ReadOnlySpan<T> が代表例で、ゼロコピーでメモリを扱う高性能コードに使われます。
// Span<T> は配列・文字列・スタック領域を同一 API で扱える ref struct
int[] array = { 1, 2, 3, 4, 5 };
// ─── 配列の一部を参照(コピーなし)──────────────
Span<int> span = array.AsSpan(1, 3); // インデックス1〜3の3要素
span[0] = 99;
Console.WriteLine(array[1]); // 99(Span は配列を直接参照)
// ─── 文字列を substring なしで参照 ──────────────
string text = "2025-08-28";
ReadOnlySpan<char> year = text.AsSpan(0, 4); // "2025"(コピーなし)
ReadOnlySpan<char> month = text.AsSpan(5, 2); // "08"(コピーなし)
// ─── stackalloc でスタックにバッファを確保 ────────
Span<byte> buffer = stackalloc byte[256]; // ヒープ割り当てなし
buffer[0] = 0xFF;
// ref struct の制約: クラスのフィールドにできない・async メソッドでは使えない
Span<T> はガベージコレクションを発生させずに高スループットでメモリを扱える強力なツールですが、ref struct の制約(ヒープ不可・async 不可)があります。通常の業務ロジックには List<T>・Memory<T> を使い、パフォーマンスクリティカルな I/O・パース処理・ゲーム開発で Span<T> を使いましょう。実践例:値型・参照型の落とし穴を避ける
// ─── 落とし穴 1: struct を LINQ で変更しようとする ──
struct Counter { public int Value; }
var counters = new List<Counter> { new Counter { Value = 0 } };
// foreach は コピー を返すため、コピーに対して操作している
// counters[0].Value++; // これはコンパイルエラー(直接変更はできない)
// GOOD: インデックスでアクセスして代入
// counters[0] = new Counter { Value = counters[0].Value + 1 };
// ─── 落とし穴 2: 参照型でも「再代入」は呼び元に影響しない ──────────
// string に限らず、参照型を値渡しすると「参照のコピー」が渡される
// ローカル変数 s を新しいオブジェクトに向け直しても、呼び元の text は変わらない
void ReassignString(string s) => s = "new value"; // s を別のオブジェクトに向けるだけ
string text = "original";
ReassignString(text);
Console.WriteLine(text); // "original"(呼び元の text は変わらない)
// ※ string の不変性の話ではなく「参照渡し vs 値渡し」の話
// 呼び元の参照そのものを変えたいなら ref を使う
void ReassignStringRef(ref string s) => s = "new value";
ReassignStringRef(ref text);
Console.WriteLine(text); // "new value"
// ─── 落とし穴 3: Dictionary の struct 値を変更できない ──
var dict = new Dictionary<string, Counter>();
dict["key"] = new Counter { Value = 0 };
// dict["key"].Value++; // コンパイルエラー: インデクサーの戻り値は右辺値
// GOOD: 取り出して変更して代入
var c = dict["key"];
c.Value++;
dict["key"] = c;
// または class に変えて参照のまま変更
よくある質問
ref 修飾子を使ってください。一方、参照型をメソッドに渡すとオブジェクトの「参照(アドレス)」がコピーされるため、メソッド内でプロパティを変更すると呼び出し元にも反映されます(ただし変数自体の再代入は反映されません)。s1 = s1.ToUpper() は既存の string を変更するのではなく、新しい string オブジェクトを作って s1 に代入し直しています。そのため s1 と s2 が同じオブジェクトを指していても、s1 を「変更」すると s1 だけが新しいオブジェクトを指すようになり、s2 は元のオブジェクトのままです。int[]・string[] など)は参照型です。int[] a = {1,2,3}; int[] b = a; として b[0] = 99 とすると a[0] も 99 になります。配列の独立したコピーを作るには Array.Copy・a.ToArray()・a.AsSpan().ToArray() などを使いましょう。まとめ
| 概念 | ポイント |
|---|---|
| 値型 vs 参照型 | 値型はコピー、参照型は参照コピー。代入時の挙動が根本的に異なる |
| スタック・ヒープ | ローカルの値型はスタック。参照型のオブジェクト本体はヒープ |
| 等価比較 | 値型は値比較。参照型のデフォルトは参照比較(== をオーバーロード可能) |
| ref / out / in | 値型をメソッドで書き換えたい → ref。出力専用 → out。読み取り専用参照 → in |
| boxing/unboxing | 値型 → object への変換。ホットパスでは避けジェネリックを使う |
| string の特殊性 | 参照型だが不変(immutable)+ interning で値のように振る舞う |
| 浅いコピー・深いコピー | MemberwiseClone は浅いコピー。ネストした参照型は共有されることに注意 |
| struct の設計 | 小さく・不変に設計。readonly struct でコンパイラが最適化 |
| ref struct / Span<T> | スタック専用・ゼロコピー。高性能 I/O・パース処理で活躍 |
参照型の null 問題はNullReferenceException 完全ガイドで詳しく解説しています。値型として設計された record struct や参照型の record についてはレコード型の基本とクラス/構造体との違いも参照してください。