C#のプログラムが思った通りに動かないとき、その原因の多くは「値型」と「参照型」の振る舞いの違いにあります。
本記事では、初心者の方でも確実に使い分けられるように、メモリや代入、引数、等価性の観点から順を追って詳しく解説します。
実例コードと出力もあわせて確認します。
C#の値型と参照型とは
値型とは
C#の値型は、struct
や enum
、そして int
、double
、bool
、char
などの組み込み数値型のことを指します。
値型は「値そのもの」を持ち、代入時に内容がコピーされます。
基本的に null
にはなりませんが、Nullable<T>
または T?
(例: int?
) を使えば null
を表現できます。
主な特徴は次の通りです。
- 値を直接保持します(インライン)。
- 代入や引数渡しでコピーが発生します(既定)。
- 既定値(
default
)は各フィールドがゼロ初期化された状態です。 System.ValueType
を継承します。
参照型とは
参照型は、class
、string
、配列(T[]
)、object
、delegate
、interface
などです。
変数が保持するのは「オブジェクトへの参照(アドレス)」であり、実体は管理ヒープに確保され、ガーベジコレクタ(GC)が解放を担います。
null
を代入できます。
主な特徴は次の通りです。
- 変数は参照(ポインタのようなもの)を保持します。
- 代入や引数渡しでは参照がコピーされます(同じ実体を指す)。
- 既定値(
default
)はnull
です。 - 等価性の既定は「参照の同一性」であり、オーバーライド可能です。
メモリの違い
入門では「値型はスタック」「参照型はヒープ」と説明されることが多いのですが、正確には「値型はインライン表現をとることが多く、参照型は実体がヒープ上」という把握が安全です。
ローカル変数であれば参照(や値)自体はスタックに置かれることが一般的ですが、JIT最適化などにより実際の配置は変わることがあります。
概念を掴む上では次のイメージで十分です。
- 値型: 変数が値そのものを持つ。コピーすると別物になる。
- 参照型: 変数は参照を持つ。参照をコピーすると同じ実体を指す。
次の表は概念上の要点をまとめたものです。
観点 | 値型 | 参照型 |
---|---|---|
保持するもの | 値そのもの | 参照(アドレス) |
代入 | 値のコピー | 参照のコピー(同一実体を共有) |
既定値(default) | ゼロ初期化 | null |
null許可 | 不可(Nullableで可) | 可 |
等価性(既定) | 値の比較(組み込み数値など) | 参照の同一性(多くのclass) |
メモリ確保 | インライン(文脈による) | ヒープ(実体) |
値型と参照型の違い
代入の違い
値型は「内容がコピー」され、参照型は「参照がコピー」されます。
次の例で違いを確認します。
using System;
struct Money
{
public int Amount; // フィールド(値型)
}
class Wallet
{
public int Amount { get; set; } // プロパティ(参照型の内部状態)
}
class Program
{
static void Main()
{
// 値型(int)の代入は値のコピー
int a = 100;
int b = a; // aの値がbへコピー
b = 200; // bを変更してもaには影響しない
Console.WriteLine($"a={a}, b={b}");
// struct(値型)の代入も値のコピー
var m1 = new Money { Amount = 100 };
var m2 = m1; // m1の中身がm2へコピー
m2.Amount = 200; // m2を変更してもm1はそのまま
Console.WriteLine($"m1.Amount={m1.Amount}, m2.Amount={m2.Amount}");
// class(参照型)の代入は参照のコピー
var w1 = new Wallet { Amount = 100 };
var w2 = w1; // w1とw2は同じ実体を指す
w2.Amount = 200; // 実体を書き換えるので両方の見え方が変わる
Console.WriteLine($"w1.Amount={w1.Amount}, w2.Amount={w2.Amount}");
}
}
a=100, b=200
m1.Amount=100, m2.Amount=200
w1.Amount=200, w2.Amount=200
引数での違い
C#は既定で「値渡し」です。
値型は「値のコピー」が渡され、参照型は「参照のコピー」が渡されます。
オブジェクト自体を書き換えるのは見える一方、引数変数そのものの再代入は外へ影響しません。
ref
を使うと引数そのもの(値型の実体や参照そのもの)を呼び出し元と共有できます。
using System;
class Wallet
{
public int Amount { get; set; }
}
class Program
{
static void AddTen(int x) // 値型の値渡し: xは呼び出し元のコピー
{
x += 10; // 呼び出し元の変数は変わらない
}
static void AddTenRef(ref int x) // 値型の参照渡し: 呼び出し元そのもの
{
x += 10; // 呼び出し元の変数が変わる
}
static void AddTenToWallet(Wallet w) // 参照型の値渡し: 参照のコピー
{
w.Amount += 10; // 同じ実体を見ているので外からも見える
}
static void ReplaceWallet(Wallet w) // 参照のコピーを再代入しても外へは影響しない
{
w = new Wallet { Amount = 999 }; // 呼び出し元の変数は別の参照のまま
}
static void ReplaceWalletRef(ref Wallet w) // 参照そのものを共有して再代入を外へ反映
{
w = new Wallet { Amount = 999 };
}
static void Main()
{
int v = 1;
AddTen(v);
Console.WriteLine($"After AddTen: v={v}");
AddTenRef(ref v);
Console.WriteLine($"After AddTenRef: v={v}");
var w = new Wallet { Amount = 1 };
AddTenToWallet(w);
Console.WriteLine($"After AddTenToWallet: w.Amount={w.Amount}");
ReplaceWallet(w);
Console.WriteLine($"After ReplaceWallet: w.Amount={w.Amount}");
ReplaceWalletRef(ref w);
Console.WriteLine($"After ReplaceWalletRef: w.Amount={w.Amount}");
}
}
After AddTen: v=1
After AddTenRef: v=11
After AddTenToWallet: w.Amount=11
After ReplaceWallet: w.Amount=11
After ReplaceWalletRef: w.Amount=999
等価性の違い(== と Equals)
値型の多く(組み込み数値など)は ==
が「値の等しさ」を比較します。
参照型の多くは、既定の ==
が「参照の同一性」を比較します。Equals
の既定も object
の実装に従い参照同一性のことが多いです。
ただし string
は例外で、==
が「内容の等しさ」を比較するようにオーバーロードされています。
クラスで「内容で等しい」を表したい場合は、Equals
/GetHashCode
のオーバーライドや IEquatable<T>
の実装、必要に応じて ==
/!=
演算子のオーバーロードを行います。
using System;
class Person
{
public string Name { get; set; } = "";
}
class Person2 : IEquatable<Person2>
{
public string Name { get; set; } = "";
public bool Equals(Person2? other) => other is not null && Name == other.Name;
public override bool Equals(object? obj) => Equals(obj as Person2);
public override int GetHashCode() => Name?.GetHashCode() ?? 0;
public static bool operator ==(Person2? left, Person2? right)
=> left is null ? right is null : left.Equals(right);
public static bool operator !=(Person2? left, Person2? right)
=> !(left == right);
}
class Program
{
static void Main()
{
var p1 = new Person { Name = "Alice" };
var p2 = new Person { Name = "Alice" };
var p3 = p1;
Console.WriteLine($"p1 == p2: {p1 == p2}"); // 参照同一性(既定): false
Console.WriteLine($"p1.Equals(p2): {p1.Equals(p2)}"); // 多くは参照同一性: false
Console.WriteLine($"ReferenceEquals(p1, p2): {ReferenceEquals(p1, p2)}"); // false
Console.WriteLine($"p1 == p3: {p1 == p3}"); // 同じ参照: true
var q1 = new Person2 { Name = "Alice" };
var q2 = new Person2 { Name = "Alice" };
Console.WriteLine($"q1 == q2: {q1 == q2}"); // 内容の等しさ: true
Console.WriteLine($"q1.Equals(q2): {q1.Equals(q2)}"); // true
// stringは参照型だが内容比較
Console.WriteLine($"\"abc\" == new string: {"abc" == new string(new[] {'a','b','c'})}");
}
}
p1 == p2: False
p1.Equals(p2): False
ReferenceEquals(p1, p2): False
p1 == p3: True
q1 == q2: True
q1.Equals(q2): True
"abc" == new string: True
nullとdefaultの違い
default
は型ごとの既定値を生成します。
参照型では null
、値型ではゼロ初期化です。
Nullable<T>
(例: int?
) の既定は null
です。
using System;
struct Point
{
public int X;
public int Y;
}
class Program
{
static void Main()
{
int i = default; // 0
bool b = default; // false
string? s = default; // null (参照型)
int? ni = default; // null (Nullable<int>)
var p = default(Point); // X=0, Y=0
Console.WriteLine($"i={i}, b={b}");
Console.WriteLine($"s is null: {s is null}");
Console.WriteLine($"ni.HasValue: {ni.HasValue}");
Console.WriteLine($"Point default => X={p.X}, Y={p.Y}");
}
}
i=0, b=False
s is null: True
ni.HasValue: False
Point default => X=0, Y=0
実例で理解する C#の値型と参照型
intとclassの例
int
は値型、class
は参照型の代表格です。
同じ「カウンタを増やす」でも、代入や引数の挙動が変わります。
using System;
class Counter // 参照型
{
public int Value { get; set; }
public Counter(int initial) => Value = initial; // コンストラクタで初期化
}
class Program
{
static void Main()
{
int x1 = 0;
int x2 = x1; // 値のコピー
x2++;
Console.WriteLine($"int: x1={x1}, x2={x2}"); // x1は影響なし
var c1 = new Counter(0);
var c2 = c1; // 参照のコピー(同じ実体)
c2.Value++;
Console.WriteLine($"Counter: c1.Value={c1.Value}, c2.Value={c2.Value}");
}
}
int: x1=0, x2=1
Counter: c1.Value=1, c2.Value=1
ちょっとだけOOPの視点
- 上の
Counter
はプロパティ({ get; set; }
)とコンストラクタを持つ最小構成のクラスです。 - クラスはインスタンス(オブジェクト)として生成し、複数の変数が同じインスタンスを共有できます。
- 参照型の継承やポリモーフィズム(
virtual
/override
)を使っても、参照型である以上「参照のコピー」という基本は変わりません。
配列やListでの挙動の違い
配列(T[]
)や List<T>
自体は参照型です。
配列やリストを代入すると、コレクション実体を共有します。
ただし要素が値型か参照型かで、取り出し後の書き換え挙動が変わります。
using System;
using System.Collections.Generic;
struct PointV // 値型
{
public int X;
public int Y;
}
class PointR // 参照型
{
public int X;
public int Y;
}
class Program
{
static void Main()
{
// 配列自体は参照型
var arr1 = new[] { 1, 2 };
var arr2 = arr1; // 同じ配列を指す
arr2[0] = 100;
Console.WriteLine($"arr1[0]={arr1[0]}"); // 100
// 値型要素の配列
var points = new[] { new PointV { X = 1, Y = 2 } };
var pv = points[0]; // 値のコピー
pv.X = 99; // コピーを書き換えても配列側は変わらない
Console.WriteLine($"points[0].X(before)={points[0].X}");
points[0].X = 99; // 配列の要素そのものを書き換える
Console.WriteLine($"points[0].X(after)={points[0].X}");
// List<T> も参照型
var listV = new List<PointV> { new PointV { X = 1, Y = 2 } };
var pv2 = listV[0]; // 値のコピー
pv2.X = 50; // コピーを書き換え
Console.WriteLine($"listV[0].X(before)={listV[0].X}");
listV[0] = pv2; // 書き戻す必要がある
Console.WriteLine($"listV[0].X(after)={listV[0].X}");
var listR = new List<PointR> { new PointR { X = 1, Y = 2 } };
var pr = listR[0]; // 参照のコピー(同じ実体)
pr.X = 77; // 実体を書き換える
Console.WriteLine($"listR[0].X={listR[0].X}");
}
}
arr1[0]=100
points[0].X(before)=1
points[0].X(after)=99
listV[0].X(before)=1
listV[0].X(after)=50
listR[0].X=77
このコードでは、配列や List<T>
自体は参照型であるため、変数代入によって同じコレクションを共有します。
ただし、要素が値型の場合はコピーが返されるため直接の変更は反映されず、再代入が必要になります。
一方で、要素が参照型なら参照のコピーが返るので、そのまま中身を書き換えることができます。
stringは参照型だが不変
string
は参照型ですが「不変(イミュータブル)」です。
内容を変更する操作は常に新しい文字列インスタンスを作ります。
==
は内容比較にオーバーロードされています。
using System;
using System.Text;
class Program
{
static void Main()
{
string s1 = "he";
string s2 = s1; // 参照のコピー
s2 += "llo"; // 新しい文字列が作られる(元のs1は変わらない)
Console.WriteLine($"s1={s1}, s2={s2}");
Console.WriteLine($"ReferenceEquals(s1, s2)={ReferenceEquals(s1, s2)}");
// 内容比較
string s3 = new string(new[] { 'h', 'e', 'l', 'l', 'o' });
Console.WriteLine($"s2 == s3: {s2 == s3}"); // True
// 繰り返し連結はStringBuilderが効率的
var sb = new StringBuilder();
sb.Append("he");
sb.Append("llo");
Console.WriteLine($"StringBuilder => {sb.ToString()}");
}
}
s1=he, s2=hello
ReferenceEquals(s1, s2)=False
s2 == s3: True
StringBuilder => hello
注意点とパフォーマンス
ボクシング/アンボクシングのコスト
値型を object
や非ジェネリックAPIに渡す際、値型はヒープ上に「箱詰め」されます。
これがボクシングです。
取り出すときにはアンボクシングが必要で、両者ともコストがかかります。
頻繁なボクシングは避け、ジェネリックを使いましょう。
using System;
using System.Collections;
using System.Collections.Generic;
class Program
{
static void Main()
{
int x = 123;
// ボクシング: 値型 -> object
object o = x;
// アンボクシング: object -> 値型
int y = (int)o;
Console.WriteLine($"Boxed type: {o.GetType().Name}, y={y}");
// 非ジェネリック(ArrayList)ではボクシングが発生
var arrayList = new ArrayList();
arrayList.Add(1); // boxing
arrayList.Add(2); // boxing
// ジェネリック(List<int>)ならボクシングなし
var list = new List<int>();
list.Add(1); // no boxing
list.Add(2); // no boxing
Console.WriteLine($"ArrayList first: {arrayList[0]} (boxed int)");
Console.WriteLine($"List<int> first: {list[0]} (no boxing)");
}
}
Boxed type: Int32, y=123
ArrayList first: 1 (boxed int)
List<int> first: 1 (no boxing)
ボクシングは、IComparable
のような非ジェネリックインターフェイスで値型を扱うときにも起こります。
可能な限り IComparable<T>
、IEquatable<T>
のようなジェネリックなインターフェイスを使うと効率的です。
大きなstructのコピーコストと影響
大きな struct
は代入や引数渡しのたびにコピーが発生しやすく、パフォーマンスに影響します。
C# 7.2以降では in
パラメータ(読み取り専用参照渡し)を使ってコピーを避けられます。
using System;
using System.Diagnostics;
struct Big256
{
// 256バイト規模の構造体(8バイト×32フィールド=256バイト)
public long F01, F02, F03, F04, F05, F06, F07, F08;
public long F09, F10, F11, F12, F13, F14, F15, F16;
public long F17, F18, F19, F20, F21, F22, F23, F24;
public long F25, F26, F27, F28, F29, F30, F31, F32;
}
class Program
{
static long SumByValue(Big256 b) // 値渡し(コピーが発生)
=> b.F01 + b.F02 + b.F03 + b.F04 + b.F05 + b.F06 + b.F07 + b.F08
+ b.F09 + b.F10 + b.F11 + b.F12 + b.F13 + b.F14 + b.F15 + b.F16
+ b.F17 + b.F18 + b.F19 + b.F20 + b.F21 + b.F22 + b.F23 + b.F24
+ b.F25 + b.F26 + b.F27 + b.F28 + b.F29 + b.F30 + b.F31 + b.F32;
static long SumByIn(in Big256 b) // 読み取り専用参照渡し(コピーなし)
=> b.F01 + b.F02 + b.F03 + b.F04 + b.F05 + b.F06 + b.F07 + b.F08
+ b.F09 + b.F10 + b.F11 + b.F12 + b.F13 + b.F14 + b.F15 + b.F16
+ b.F17 + b.F18 + b.F19 + b.F20 + b.F21 + b.F22 + b.F23 + b.F24
+ b.F25 + b.F26 + b.F27 + b.F28 + b.F29 + b.F30 + b.F31 + b.F32;
static void Main()
{
var big = new Big256
{
F01=1, F02=2, F03=3, F04=4, F05=5, F06=6, F07=7, F08=8,
F09=9, F10=10, F11=11, F12=12, F13=13, F14=14, F15=15, F16=16,
F17=17, F18=18, F19=19, F20=20, F21=21, F22=22, F23=23, F24=24,
F25=25, F26=26, F27=27, F28=28, F29=29, F30=30, F31=31, F32=32
};
const int N = 5_000_00; // 50万回程度に抑える(環境次第で調整)
var sw = Stopwatch.StartNew();
long s1 = 0;
for (int i = 0; i < N; i++) s1 += SumByValue(big);
sw.Stop();
Console.WriteLine($"SumByValue: {sw.ElapsedMilliseconds} ms");
sw.Restart();
long s2 = 0;
for (int i = 0; i < N; i++) s2 += SumByIn(big); // inで呼べる
sw.Stop();
Console.WriteLine($"SumByIn: {sw.ElapsedMilliseconds} ms");
Console.WriteLine($"Sanity: {s1 == s2}");
}
}
SumByValue: 20 ms
SumByIn: 9 ms
Sanity: True
設計の目安として、struct
は「小さくて、頻繁にコピーされず、概念的に値として扱う」ものに向いています。
大きくなってきたら class
の検討が安全です。
まとめ(値型と参照型の違いの要点)
要点 | 値型 | 参照型 |
---|---|---|
代入の意味 | 値のコピー | 参照のコピー |
引数(既定) | 値のコピー | 参照のコピー(同じ実体) |
等価性(既定) | 内容比較(組み込み) | 参照同一性(多くのclass) |
null | 基本不可(Nullableで可) | 可 |
default | ゼロ初期化 | null |
注意点 | 大きなstructのコピー、ボクシング | 共有状態の意図せぬ変更 |
上記を踏まえ、意図した共有/非共有の設計になっているかを常に確認すると、不具合を大幅に減らせます。
==
と Equals
、ref
/in
/out
の意味、string
の不変性もセットで覚えておくと安心です。
まとめ
本記事では、C#の値型と参照型を、メモリモデル、代入、引数、等価性、null
/default
の観点から解説し、配列やList<T>
、string
の挙動、パフォーマンス上の注意点(ボクシングと大きなstruct
のコピー)まで確認しました。
要点は「値型は値そのもの、参照型は参照を扱う」という原則と、そこから生じる挙動の違いです。
特に、メソッド引数での見え方、==
と Equals
の違い、コレクションと要素の組み合わせ(値型要素か参照型要素か)は、つまずきやすい箇所です。
実装時には、意図した共有/非共有を満たしているか、ボクシングや無駄なコピーがないかを確認し、必要に応じてIEquatable<T>
やin
パラメータなどの言語機能を活用してください。