C#の構造体:値コピー・防御的コピー・ボックス化のコスト実測
C#の構造体を.NET 10で検証。値と参照のコピーの実際の出力、readonly・record・ref struct、警告の出ない防御的コピー、インターフェイスへのキャストで起きるボックス化の割り当て量と選び方。
2026年11月改訂: この記事は2021年に韓国語で書いた構造体の講座を、日本語の読者向けに書き直したものです。例は .NET 10.0.14(C# 14)でコンパイルし直しても出力は同じでしたが、「構造体はスタックに作られる」「引数なしのコンストラクターは宣言できない」「構造体のメンバーは初期化できない」という説明は、今では誤りか(最初のものは2021年当時から不正確)、C# 10 で変わっています。値のコピーが実際にどう見えるか、
readonly・record・ref struct、警告なしに値が変わらない防御的コピーの罠、そして構造体をインターフェイスとして扱ったときのボックス化の割り当て量を実測した結果を加えました。
「構造体は軽いからクラスの代わりに使えば速い」と教わった人や、構造体にしたら値が変わらなかった、むしろメモリが増えた、という経験がある人向けの記事です。先に結論を書くと、構造体は代入や受け渡しのたびに丸ごとコピーされる値で、得をするのは小さくて変更しないデータだけです。インターフェイスや object として扱った瞬間にボックス化でヒープ割り当てが発生しますが、ジェネリック制約(where T : IShape)にすると0バイトになりました。
2021年の例と訂正が必要な説明
元記事の「生徒の点数」構造体の例はそのまま動きました。new で作った student1 も、new なしで宣言してフィールドを1つずつ埋めた student2 も出力されます(名前と科目は日本向けに変えています)。
struct Test
{
public string name;
public int japanese, math, social, science;
public Test(string name, int japanese, int math, int social, int science)
{ this.name = name; this.japanese = japanese; this.math = math; this.social = social; this.science = science; }
public void Show()
{
Console.WriteLine(name + "さんのテストの点数:");
Console.WriteLine("国語: " + japanese + ", 数学: " + math + ", 社会: " + social + ", 理科: " + science);
}
}
// Main
Test student1 = new Test("佐藤", 94, 96, 84, 92);
student1.Show();
Test student2;
student2.name = "鈴木"; student2.japanese = 100; student2.math = 84; student2.social = 96; student2.science = 86;
student2.Show();
佐藤さんのテストの点数:
国語: 94, 数学: 96, 社会: 84, 理科: 92
鈴木さんのテストの点数:
国語: 100, 数学: 84, 社会: 96, 理科: 86
new を使わない書き方は、すべてのフィールドを埋めた後でしか使えません。science の代入を1行消すと Show() の呼び出しで失敗します。
error CS0165: 未割り当てのローカル変数 'student2' が使用されました
元記事の説明で直すべき点は次のとおりです。
| 2021年版の説明 | .NET 10/C# 14 で確認した結果 |
|---|---|
| 構造体はスタックに作られる | 宣言された場所に値として入る。 ローカル変数は通常スタック、クラスのフィールドや配列の要素はヒープ(そのオブジェクトの中)。ボックス化されるとヒープに新しいオブジェクト |
| メモリ消費が少ないので好まれる | 割り当ては減るが、代入・受け渡しのたびに全体をコピー。大きいと逆に遅い |
| 引数なしのコンストラクターは宣言できない | C# 10 から宣言できる。 ただし default と配列の要素では実行されない(後述) |
| 構造体のメンバーは初期化できない | C# 10 からフィールド初期化子が書ける |
| デストラクターは宣言できない | 正しい(CS0575: クラスのみがデストラクターを含むことができます) |
| 継承をサポートしない | 正しい(CS0527: インターフェイス リストの型 'PointS' はインターフェイスではありません)。インターフェイスの実装はできる |
値のコピーと参照のコピーを出力で確かめる
同じ形の構造体とクラスを代入し、メソッドに渡し、コレクションに入れてみました。
var ps = new PointS { X = 1 };
var ps2 = ps; ps2.X = 99; // 値がまるごとコピーされる
var pc = new PointC { X = 1 };
var pc2 = pc; pc2.X = 99; // 参照だけがコピーされる
Console.WriteLine($"struct: ps.X={ps.X} ps2.X={ps2.X}");
Console.WriteLine($"class : pc.X={pc.X} pc2.X={pc2.X}");
MoveS(ps); MoveC(pc);
Console.WriteLine($"after MoveS(ps), MoveC(pc): ps.X={ps.X} pc.X={pc.X}");
MoveRef(ref ps);
Console.WriteLine($"after MoveRef(ref ps): ps.X={ps.X}");
static void MoveS(PointS p) => p.X += 10;
static void MoveC(PointC p) => p.X += 10;
static void MoveRef(ref PointS p) => p.X += 10;
struct PointS { public int X; }
class PointC { public int X; }
struct: ps.X=1 ps2.X=99
class : pc.X=99 pc2.X=99
after MoveS(ps), MoveC(pc): ps.X=1 pc.X=109
after MoveRef(ref ps): ps.X=11
構造体はメソッドの中でコピーが変わっただけで、元の値はそのままです。ref で渡して初めて元が変わります(ref の動きはメソッドの記事を参照)。コレクションではもっと差がはっきりします。
var list = new List<PointS> { new() { X = 1 } };
list[0].X = 5; // コンパイルエラー
var tmp = list[0]; tmp.X = 5; list[0] = tmp; // 取り出して変えて戻す
PointS[] arr = [new() { X = 1 }];
arr[0].X = 5; // 配列は要素そのものを指すので OK
error CS1612: 変数ではないため、'List<PointS>.this[int]' の戻り値を変更できません
List<T> のインデクサーはコピーを返すので、そのコピーを書き換えるコードはコンパイラが止めてくれます。配列は要素への参照を返すので、そのまま変わります。修正後のコードの出力は list[0].X=5、arr[0].X=5 でした。
引数なしのコンストラクター(C# 10)と default の罠
C# 10 から、構造体にフィールド初期化子と引数なしのコンストラクターを書けるようになりました。ただし default と配列の要素はコンストラクターを通りません。
struct Score
{
public string Name = "(none)";
public int Math = 50;
public Score() { }
public Score(string name) { Name = name; }
public override string ToString() => $"Name={Name ?? "null"}, Math={Math}";
}
// new Score()、default、new Score("Kim")、new Score[1][0] を出力
new Score() -> Name=(none), Math=50
default -> Name=null, Math=0
new Score("Kim") -> Name=Kim, Math=50
new Score[1][0] -> Name=null, Math=0
Name が null にならない前提で書いたコードは、配列から取り出した値で壊れます。構造体は全フィールドが 0/null の状態も正しい値になるように設計するのが原則です。LangVersion=9 でコンパイルすると error CS8773: Feature 'parameterless struct constructors' is not available in C# 9.0. と Feature 'struct field initializers' のエラーが出て、C# 10 の機能であることを確認できました。
readonly struct、record struct、ref struct
2021年以降、構造体にはいくつかの修飾子が加わりました。
readonly struct(C# 7.2) はすべてのフィールドが readonly でなければならず、作成後は変更できません。
error CS8340: 読み取り専用の構造体のインスタンス フィールドは、読み取り専用である必要があります。
error CS0200: プロパティまたはインデクサー 'Range3.X' は読み取り専用であるため、割り当てることはできません
record struct(C# 10) は値の比較(==)、ToString()、with 式をコンパイラが生成します。
readonly record struct Money(decimal Amount, string Currency);
var m1 = new Money(100, "JPY");
var m2 = m1 with { Amount = 250 };
Console.WriteLine($"{m1} | {m2} | equal? {m1 == new Money(100, "JPY")}");
Money { Amount = 100, Currency = JPY } | Money { Amount = 250, Currency = JPY } | equal? True
ref struct(C# 7.2) はスタック上にしか置けない構造体です。代表は Span<T> で、ヒープに出る道はすべてふさがれています。
error CS0029: 型 'Buf' を 'object' に暗黙的に変換できません
error CS8345: フィールドまたは自動実装プロパティは、それが ref 構造体のインスタンス メンバーである場合を除いて、型 'Buf' にすることができません。
error CS9244: 'Buf' 型は、ジェネリック型またはメソッド 'List<T>' のパラメーター 'T' として使用するためには、ref 構造体または ref 構造体を許容する型パラメーターにすることはできません。
C# 13 からは ref struct もインターフェイスを実装でき、where T : ILen, allows ref struct 制約でジェネリックに渡せます。確認したコードは 3 を出力し、LangVersion=12 では error CS9202: Feature 'ref struct interfaces' is not available in C# 12.0. になりました。ただし ref struct をインターフェイス型の変数に変換することは今もできません(ボックス化が禁止されているため)。
防御的コピー:警告なしに値が変わらない
値を書き換えるメソッドを持つ構造体を readonly フィールドや in 引数で扱うと、コンパイラは元の値を守るために隠れたコピーの上でメソッドを実行します。
var h = new Holder();
h.ReadOnlyCounter.Increment(); h.ReadOnlyCounter.Increment();
h.MutableCounter.Increment(); h.MutableCounter.Increment();
Console.WriteLine($"readonly field: {h.ReadOnlyCounter.Value}, normal field: {h.MutableCounter.Value}");
var c = new Counter();
Bump(in c);
Console.WriteLine($"after Bump(in c): {c.Value}");
static void Bump(in Counter x)
{
x.Increment(); // 隠れたコピーの上で実行
Console.WriteLine($"inside Bump: {x.Value}");
}
class Holder { public readonly Counter ReadOnlyCounter; public Counter MutableCounter; }
struct Counter { public int Value; public void Increment() => Value++; }
readonly field: 0, normal field: 2
inside Bump: 0
after Bump(in c): 0
ビルドの警告は1つもありませんでした。 Increment() を4回呼んだのに、変わったのは普通のフィールドの方だけです。防ぐ方法は2つあり、どちらも静かなバグをコンパイルエラーに変えてくれます。
- 構造体を
readonly structで宣言する →Value++でerror CS0191: 読み取り専用フィールドに割り当てることはできません … - 値を変えないメソッドに
readonlyを付ける(C# 8)→ 書き換えようとするとerror CS1604: 読み取り専用であるため 'Value' に割り当てできません
readonly でない大きな構造体を in で渡すと、メソッドやプロパティを呼ぶたびにコピーが起き、in にした意味がなくなります。in 引数は readonly struct と組み合わせるのが原則です。
インターフェイスへのキャストでボックス化:割り当て量の実測
構造体を object やインターフェイス型に変換すると、ヒープにボックスオブジェクトが作られ、値がその中にコピーされます。どれだけ割り当てられるかを GC.GetAllocatedBytesForCurrentThread() で測りました。8バイトの readonly struct Square : IShape(Unsafe.SizeOf で確認)100万個の Area() を、5つの方法で合計しています。各方法は [MethodImpl(NoInlining)] のメソッドで、ウォームアップ400回のあと GC.Collect() してから5回計測しました。
static double SumGeneric<T>(T[] items) where T : IShape // ジェネリック制約
{ double t = 0; for (int i = 0; i < items.Length; i++) t += items[i].Area(); return t; }
static double SumLocalCast(Square[] items) // インターフェイス型のローカル変数
{ double t = 0; for (int i = 0; i < items.Length; i++) { IShape s = items[i]; t += s.Area(); } return t; }
static double AreaOf(IShape s) => s.Area(); // インターフェイス型の引数
// SumIfaceArray: IShape[] に入れてから合計 / SumObjectList: List<object>.Add してからアンボックス
interface IShape { double Area(); }
readonly struct Square(double side) : IShape { public double Side { get; } = side; public double Area() => Side * Side; }
| 方法(100万個) | 割り当て | 時間(2〜5回目) |
|---|---|---|
ジェネリック where T : IShape |
0 バイト | 0.95〜1.31 ms |
IShape s = items[i] ローカル変数 |
24,000,000 バイト | 3.22〜3.26 ms |
AreaOf(IShape) 引数 |
24,000,000 バイト | 3.81〜4.33 ms |
IShape[] に格納 |
32,000,024 バイト | 18.24〜19.12 ms |
List<object>.Add |
32,000,056 バイト | 19.45〜20.69 ms |
ボックス1個は24バイト(8バイトの値+オブジェクトヘッダーと型ポインターの16バイト)で、配列の32MBはボックスの24MBに参照の配列8MBが加わった値です。ローカル変数に一瞬入れるだけの場合でも、.NET 10 の JIT はボックスを消してくれませんでした。一方ジェネリック制約にすると、JIT が Square 専用のコードを作ってインターフェイスメソッドを直接呼ぶので、割り当ては0でした。ArrayList のように object を入れるコレクションのボックス化コストはジェネリックの記事で別に測っています。
同じ理屈で、Equals を自分で実装していない構造体を Dictionary のキーにすると、比較のたびに割り当てが起きます。string と int を持つ普通の構造体と、同じ中身の readonly record struct をキーにして100万回引きました(キー1,000個、ウォームアップ後の最後の3回)。
| キーの型 | 時間 | 割り当て |
|---|---|---|
普通の struct(既定の ValueType.Equals/GetHashCode) |
118.8 / 125.4 / 121.3 ms | 184,021,600 バイト |
readonly record struct |
12.3 / 14.8 / 12.6 ms | 0 バイト |
1回の検索で約184バイトが割り当てられ、約9倍遅くなりました。参照型のフィールドを持つ構造体の既定の Equals はリフレクションの経路を通るためです。構造体をキーにするなら、record struct にするか IEquatable<T> を実装しましょう。
構造体を選ぶ基準
Microsoft Learn の「Choosing Between Class and Struct」のガイドラインは、構造体をインスタンスのサイズが16バイト未満で、寿命が短く、不変で、頻繁にボックス化されない場合に限っています。今回の実測も同じ方向でした。
| 状況 | おすすめ |
|---|---|
| 座標・色・金額のように小さく変わらない値 | readonly struct/readonly record struct |
値で比較する、Dictionary のキーにする |
readonly record struct か IEquatable<T> を実装 |
| 大きなデータ(数十バイト以上)を頻繁に渡す | class(または in + readonly struct) |
値が変わり続ける、List<T> の中で書き換える |
class |
| インターフェイスとして扱うことが多い | class、またはジェネリック制約でボックス化を回避 |
| バッファの一部をコピーせずに扱う | Span<T> などの ref struct |
元記事の生徒の点数の構造体は、文字列1つと int 4つ(64ビットで24バイト)なのでガイドラインより大きく、フィールドも書き換えます。今書き直すなら record class のほうが合っています。クラス側の継承や virtual のルールはクラスの継承、構造体が実装するインターフェイスの新しい機能はインターフェイスの記事で扱っています。
確認環境:.NET SDK 10.0.403/ランタイム 10.0.14、C# 14(net10.0 の既定)、Release ビルド、macOS(Apple Silicon Mac)。エラーメッセージは日本語 SDK の出力そのままです。
よくある質問
構造体は必ずスタックに作られる?
いいえ。構造体は宣言された場所に値としてそのまま入ります。ローカル変数なら通常はスタックですが、クラスのフィールドや配列の要素ならそのオブジェクトと一緒にヒープにあります。object やインターフェイスにキャストするとヒープにボックスが新しく作られ、8バイトの構造体1個につき24バイトが割り当てられました(.NET 10、Apple Silicon Mac)。
構造体をインターフェイスとして渡すと、どれくらいボックス化が起きる?
8バイトの構造体100万個を IShape 型の変数や引数で渡して合計すると 24,000,000 バイトが割り当てられ、where T : IShape 制約のジェネリックメソッドでは0バイトでした。時間もジェネリックが約1ms、インターフェイス経由が約3〜4msでした(.NET 10、Release)。
readonly フィールドに入れた構造体のメソッドを呼んでも値が変わらないのはなぜ?
防御的コピーのせいです。readonly フィールドや in 引数の構造体に対して値を書き換えるメソッドを呼ぶと、コンパイラが隠れたコピーを作り、そのコピーの上で実行します。Increment() を2回呼んでも0のままで、警告も出ませんでした。構造体を readonly struct にするか、メソッドに readonly を付ければコンパイルエラーに変わり、ミスを防げます。