閉じる

C#の値型と参照型の違いをやさしく解説

C#のプログラムが思った通りに動かないとき、その原因の多くは「値型」と「参照型」の振る舞いの違いにあります。

本記事では、初心者の方でも確実に使い分けられるように、メモリや代入、引数、等価性の観点から順を追って詳しく解説します。

実例コードと出力もあわせて確認します。

C#の値型と参照型とは

値型とは

C#の値型は、structenum、そして intdoubleboolchar などの組み込み数値型のことを指します。

値型は「値そのもの」を持ち、代入時に内容がコピーされます。

基本的に null にはなりませんが、Nullable<T> または T? (例: int?) を使えば null を表現できます。

主な特徴は次の通りです。

  • 値を直接保持します(インライン)。
  • 代入や引数渡しでコピーが発生します(既定)。
  • 既定値(default)は各フィールドがゼロ初期化された状態です。
  • System.ValueType を継承します。

参照型とは

参照型は、classstring、配列(T[])、objectdelegateinterface などです。

変数が保持するのは「オブジェクトへの参照(アドレス)」であり、実体は管理ヒープに確保され、ガーベジコレクタ(GC)が解放を担います。

null を代入できます。

主な特徴は次の通りです。

  • 変数は参照(ポインタのようなもの)を保持します。
  • 代入や引数渡しでは参照がコピーされます(同じ実体を指す)。
  • 既定値(default)は null です。
  • 等価性の既定は「参照の同一性」であり、オーバーライド可能です。

メモリの違い

入門では「値型はスタック」「参照型はヒープ」と説明されることが多いのですが、正確には「値型はインライン表現をとることが多く、参照型は実体がヒープ上」という把握が安全です。

ローカル変数であれば参照(や値)自体はスタックに置かれることが一般的ですが、JIT最適化などにより実際の配置は変わることがあります。

概念を掴む上では次のイメージで十分です。

  • 値型: 変数が値そのものを持つ。コピーすると別物になる。
  • 参照型: 変数は参照を持つ。参照をコピーすると同じ実体を指す。

次の表は概念上の要点をまとめたものです。

観点値型参照型
保持するもの値そのもの参照(アドレス)
代入値のコピー参照のコピー(同一実体を共有)
既定値(default)ゼロ初期化null
null許可不可(Nullableで可)
等価性(既定)値の比較(組み込み数値など)参照の同一性(多くのclass)
メモリ確保インライン(文脈による)ヒープ(実体)

値型と参照型の違い

代入の違い

値型は「内容がコピー」され、参照型は「参照がコピー」されます。

次の例で違いを確認します。

C#
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 を使うと引数そのもの(値型の実体や参照そのもの)を呼び出し元と共有できます。

C#
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> の実装、必要に応じて ==/!= 演算子のオーバーロードを行います。

C#
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 です。

C#
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 は参照型の代表格です。

同じ「カウンタを増やす」でも、代入や引数の挙動が変わります。

C#
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> 自体は参照型です。

配列やリストを代入すると、コレクション実体を共有します。

ただし要素が値型か参照型かで、取り出し後の書き換え挙動が変わります。

C#
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 は参照型ですが「不変(イミュータブル)」です。

内容を変更する操作は常に新しい文字列インスタンスを作ります。

== は内容比較にオーバーロードされています。

C#
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に渡す際、値型はヒープ上に「箱詰め」されます。

これがボクシングです。

取り出すときにはアンボクシングが必要で、両者ともコストがかかります。

頻繁なボクシングは避け、ジェネリックを使いましょう。

C#
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 パラメータ(読み取り専用参照渡し)を使ってコピーを避けられます。

C#
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のコピー、ボクシング共有状態の意図せぬ変更

上記を踏まえ、意図した共有/非共有の設計になっているかを常に確認すると、不具合を大幅に減らせます。

==Equalsref/in/out の意味、string の不変性もセットで覚えておくと安心です。

まとめ

本記事では、C#の値型と参照型を、メモリモデル、代入、引数、等価性、null/default の観点から解説し、配列やList<T>stringの挙動、パフォーマンス上の注意点(ボクシングと大きなstructのコピー)まで確認しました。

要点は「値型は値そのもの、参照型は参照を扱う」という原則と、そこから生じる挙動の違いです。

特に、メソッド引数での見え方、==Equals の違い、コレクションと要素の組み合わせ(値型要素か参照型要素か)は、つまずきやすい箇所です。

実装時には、意図した共有/非共有を満たしているか、ボクシングや無駄なコピーがないかを確認し、必要に応じてIEquatable<T>inパラメータなどの言語機能を活用してください。

この記事を書いた人
エーテリア編集部
エーテリア編集部

C#の入門記事を中心に、開発環境の準備からオブジェクト指向の基本まで、順を追って解説しています。ゲーム開発や業務アプリを目指す人にも役立ちます。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!