値型
前ノートではC#における型を、値型、参照型、型パラメーター、ポインター型、dynamic、null許容参照型注釈などの分類軸から整理した。本ノートでは、そのうち値型を扱う。
C#における値型は変数が値そのものを直接保持する型である。値型の代入、引数渡し、戻り値、配列要素、フィールド格納では、原則として値のコピーが問題になる。これは参照をコピーして同一オブジェクトを共有する参照型とは異なる。ただし「値型は常にスタックに置かれる」という意味ではない。値型フィールドは参照型オブジェクトの内部に含まれ得るし、値型配列の要素は配列オブジェクトの内部に格納され得る。したがって値型を読む際には、型分類、コピー意味論、実行時配置、API契約を区別する必要がある。12
仕様上値型は非null許容値型とnull許容値型から成る。非null許容値型には組み込み数値型、bool、char、構造体、タプル型、列挙型などが含まれる。null許容値型は非null許容値型Tに追加のnull値を加えたT?であり、実体としてはSystem.Nullable<T>に対応する。参照型に対するT?が静的解析上の注釈であるのに対し、値型に対するT?は別の構造体型として扱われる。13
本ノートの対象は、値型全体の意味論と設計上の含意である。整数型、浮動小数点型、decimal、bool、charを組み込み値型として整理し、列挙型、構造体、record struct、null許容値型を個別に扱う。最後にコピー、boxing、一時値、防御的コピー、ジェネリクスとの関係をコストモデルとしてまとめる。
6.1 組み込み値型
組み込み値型はC#言語があらかじめ定義し、専用の型名で直接表現できる基本的な値型である。ここには整数型、浮動小数点型、decimal、bool、charが含まれる。これらは構文上は特別に見えるが、多くは.NETの対応する構造体型への別名として定義される。たとえばintはSystem.Int32、boolはSystem.Boolean、charはSystem.Charを表す。ただしnintとnuintは仕様上、System.IntPtrおよびSystem.UIntPtrで表現されるネイティブサイズ整数型であり、単純な別名としてだけ扱うべきではない。1456
整数型には符号付き整数型と符号なし整数型がある。sbyte、short、int、longは符号付き整数型であり、byte、ushort、uint、ulongは符号なし整数型である。nintとnuintはネイティブサイズ整数型であり、32ビットプロセスでは32ビット、64ビットプロセスでは64ビットの大きさを持つ。nintとnuintは相互運用や低水準処理に関係する型であり、通常のドメイン値を表すために不用意に用いるべきではない。4
| C#型 | .NET上の表現 | 意味 |
|---|---|---|
sbyte | System.SByte | 符号付き8ビット整数 |
byte | System.Byte | 符号なし8ビット整数 |
short | System.Int16 | 符号付き16ビット整数 |
ushort | System.UInt16 | 符号なし16ビット整数 |
int | System.Int32 | 符号付き32ビット整数 |
uint | System.UInt32 | 符号なし32ビット整数 |
long | System.Int64 | 符号付き64ビット整数 |
ulong | System.UInt64 | 符号なし64ビット整数 |
nint | System.IntPtr | ネイティブサイズ符号付き整数 |
nuint | System.UIntPtr | ネイティブサイズ符号なし整数 |
整数型を選ぶ際には範囲、相互運用、メモリ量、演算コスト、API契約を分けて考える。公開APIでは、単に「小さい値しか入らない」ことを理由にbyteやshortを選ぶと、呼び出し側で変換が増え、オーバーロード解決や数値昇格の見通しが悪くなる場合がある。通常の個数、長さ、添字、状態値にはintが標準的な選択になりやすい。longは範囲が必要な場合に用いる。uintやulongは負値を排除する契約を表現できるが、.NET API全体との整合や符号付き整数との混在に注意が必要である。
浮動小数点型にはfloatとdoubleがある。floatは単精度、doubleは倍精度の二進浮動小数点数である。これらは実数を完全に表現する型ではなく、有限のビット列で近似値を表す型である。したがって、0.1のような十進小数を厳密に表せない場合があり、計算結果には丸め誤差が現れる。物理量、統計量、座標、機械学習、グラフィックスなどでは、性能と精度の要件に応じてfloatまたはdoubleを選ぶ。一般的な数値計算ではdoubleが既定の選択になりやすい。5
decimalは十進小数を高い精度で扱うための値型である。decimalはfloatやdoubleより範囲は狭く、通常は演算コストも大きいが、十進表現に基づく金額、会計、固定小数点的な計算に適する。decimalを「より正確なdouble」と見るのは不正確である。decimalは二進浮動小数点ではなく、十進表現を重視する別の数値型である。数値型の選択では対象が物理量や統計量なのか、金額や十進桁を持つ業務値なのかを区別する必要がある。5
double x = 0.1 + 0.2;
decimal y = 0.1m + 0.2m;
Console.WriteLine(x); // 二進浮動小数点の丸めの影響を受ける
Console.WriteLine(y); // 十進小数として扱われるboolはtrueまたはfalseのいずれかを表す値型である。C#ではboolと整数型の間にC/C++風の暗黙変換は存在しない。if (1)のような記述は許されず、条件式にはbool型の式が必要である。これは真偽値と整数を型として分離する設計であり、条件式の意図を明確にする。bool?を用いると三値論理を表現できるが、それは通常のboolではなく、null許容値型として扱う必要がある。6
charはUnicode UTF-16コード単位を表す16ビットの値型である。charは「ユーザーが認識する一文字」を常に表すわけではない。サロゲートペアや結合文字を含む文字列では、表示上の一文字が複数のcharから成る場合がある。したがってcharは文字データ処理の最小意味単位ではなく、.NET文字列内部のUTF-16コード単位として読むべきである。文字列処理の詳細は参照型のstringおよびReadOnlySpan<char>の章で扱う。7
checkedおよびunchecked文脈は整数演算および一部の数値変換におけるオーバーフロー検査を制御する。checked文脈では、整数演算のオーバーフローに対してOverflowExceptionが発生する。定数式でオーバーフローが生じる場合はコンパイル時エラーになる。unchecked文脈では、結果は対象型に収まる下位ビットとして扱われ、典型的には折り返しが生じる。既定では、非定数式の整数演算はコンパイラオプションに依存し、通常はuncheckedとして扱われる。定数式は既定でchecked文脈で評価される。8
int max = int.MaxValue;
int wrapped = unchecked(max + 1);
// wrapped == int.MinValue
int failed = checked(max + 1);
// 実行時にOverflowExceptioncheckedとuncheckedはすべての数値型に同じ意味で作用するわけではない。主な対象は整数型、char、列挙型に関係する組み込み演算、および整数型への明示的数値変換である。float、double、System.Halfでは、無限大やNaNが関係する。decimalの演算は、範囲外の結果に対してchecked/uncheckedにかかわらず例外を投げ得る。したがってcheckedを「すべての数値計算を安全にする指定」と読むべきではない。対象となる型と演算を確認する必要がある。8
6.2 列挙型
列挙型は名前付き定数の集合を定義する値型である。列挙型はenum宣言によって導入され、それぞれの列挙メンバーは基底となる整数型の値に対応する。列挙型は単なる整数の別名ではなく、独立した値型である。したがって列挙型と整数型の間には明示的な変換が必要になる。ただし列挙型の値集合は、宣言された列挙メンバーの集合だけに制限されない。基底型が表せる値はキャストにより列挙型の値として作ることができる。910
public enum OrderStatus
{
None = 0,
Created = 1,
Paid = 2,
Shipped = 3,
Cancelled = 4
}列挙型の基底型は整数型である。明示しない場合基底型はintになる。基底型として指定できるのは、byte、sbyte、short、ushort、int、uint、long、ulongであり、char、nint、nuintは列挙型の基底型には使えない。基底型は外部データ形式、相互運用、メモリ表現、ビットフラグの範囲に関係する。特別な理由がない限りintを既定として読むのが自然である。910
列挙メンバーの値は、明示的に指定できる。値を省略した場合、最初のメンバーは0になり、後続のメンバーは直前のメンバーの値に1を加えた値になる。途中で値を明示した場合、その後の省略値は明示値からの連番になる。列挙メンバーの宣言順序は、この省略値の決定に影響する。これは、名前空間や型メンバーの参照可能性とは異なり、宣言順序が意味を持つ例である。
public enum ErrorCode : ushort
{
None = 0,
Unknown = 1,
ConnectionLost = 100,
Timeout, // 101
OutlierReading = 200
}列挙型の既定値は0をその列挙型へ変換した値である。これは値0に対応する列挙メンバーが宣言されているかどうかとは独立である。したがって列挙型を設計する際には、ほとんどの場合、None = 0、Unknown = 0、Unspecified = 0など、0に対応する明示的なメンバーを置くべきである。0に対応するメンバーがない列挙型は、既定値や配列初期化、フィールド初期化、逆シリアライズ時に扱いにくい。9
public enum Direction
{
North = 1,
East = 2,
South = 3,
West = 4
}
Direction d = default;
// dはDirection型の値だが、宣言済みメンバーではない0を持つ列挙型の値は宣言済みメンバーだけに限定されない。この性質は外部入力、数値キャスト、バイナリデータ、シリアライズされた値を扱う際に重要である。たとえば、(OrderStatus)999はコンパイルでき、実行時にもOrderStatus型の値になる。これが有効な業務状態を意味するとは限らない。外部入力から列挙型へ変換する場合には、Enum.IsDefined、範囲検査、明示的なswitch、または独自の検証ロジックを用いる必要がある。910
OrderStatus status = (OrderStatus)999;
if (!Enum.IsDefined(status))
{
throw new InvalidOperationException("未定義の状態です。");
}フラグ列挙は複数の選択肢をビットごとの組み合わせとして表す列挙型である。通常は[Flags]属性を付け、各メンバーに2の冪の値を割り当てる。[Flags]属性は、主に文字列表現や慣用的な意味付けに関係する。属性を付けるだけでビット演算の意味が作られるわけではない。ビット演算自体は列挙型に対する|、&、^、~などの演算によって成立する。10
[Flags]
public enum FileAccessMode
{
None = 0,
Read = 1 << 0,
Write = 1 << 1,
Execute = 1 << 2,
ReadWrite = Read | Write
}
FileAccessMode mode = FileAccessMode.Read | FileAccessMode.Write;
bool canWrite = (mode & FileAccessMode.Write) != 0;フラグ列挙ではNone = 0を置くこと、各ビットを重複させないこと、合成済みの便利メンバーを必要最小限にすることが重要である。また、否定演算子~を使うと基底型の全ビットが反転するため、宣言されていないビットも立ち得る。公開APIでは未定義ビットを許すのか、明示的に拒否するのかを決めておく必要がある。
列挙型は状態集合や選択肢の表現に有用である。ただし状態に振る舞いを持たせたい場合列挙型だけでは不十分になることがある。列挙値ごとに処理が分岐し続ける設計では、switchの網羅性、未定義値、将来の追加、互換性を検討する必要がある。列挙型は閉じた集合を表すように見えるが、実行時値としては基底型の範囲を取り得るため、型だけで完全な閉世界性を保証するものではない。
6.3 構造体
構造体はstruct宣言によって定義される値型である。構造体はフィールド、定数、メソッド、プロパティ、イベント、インデクサ、演算子、コンストラクター、静的コンストラクター、入れ子型などを持てる。クラスと同じようにメンバーを持てる一方で、継承、コピー、既定値、初期化、boxingの点でクラスとは異なる。11
public readonly struct Point
{
public Point(int x, int y)
{
X = x;
Y = y;
}
public int X { get; }
public int Y { get; }
public double DistanceFromOrigin()
=> Math.Sqrt((double)X * X + (double)Y * Y);
}構造体の最も重要な性質は、代入によって値がコピーされることである。構造体変数を別の変数へ代入すると、同じオブジェクトを共有するのではなく、値の複製が作られる。値渡し引数として渡す場合や戻り値として返す場合も、概念上は同じくコピーが関係する。JIT最適化により実際の機械語レベルではコピーが省略される場合があるが、言語意味論としては値のコピーとして読む必要がある。111
public struct Counter
{
public int Value;
}
Counter a = new Counter { Value = 1 };
Counter b = a;
b.Value = 10;
Console.WriteLine(a.Value); // 1
Console.WriteLine(b.Value); // 10構造体は暗黙にSystem.ValueTypeを継承し、さらにobjectへつながる。ただし、構造体がクラス継承階層の通常の派生型として振る舞うわけではない。構造体は常に暗黙にsealedであり、他の構造体やクラスから継承されない。構造体はインターフェイスを実装できるが、基底クラスを明示的に指定できない。したがって、構造体における多相性は、主にインターフェイス、ジェネリック制約、boxing、静的抽象メンバーなどを通じて現れる。11
構造体の既定値は全フィールドをそれぞれの既定値にした値である。数値フィールドは0、boolはfalse、参照型フィールドはnull、null許容値型フィールドはHasValue == falseの値になる。構造体はどのように設計しても既定値を完全には排除できない。配列確保、フィールド初期化、default式、defaultリテラル、ジェネリックコードでは、コンストラクターを明示的に呼ばずに既定値が現れる。したがって構造体は既定値が有効な状態として扱えるように設計するのが原則である。111
public readonly struct Money
{
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency ?? throw new ArgumentNullException(nameof(currency));
}
public decimal Amount { get; }
public string? Currency { get; }
}
Money m = default;
// Currencyはnullになり得るこの例では、コンストラクターはcurrencyにnullを許さないが、default(Money)はコンストラクターを通らない。したがって構造体の不変条件をコンストラクターだけで保証したつもりになってはならない。公開構造体では、既定値を有効な空状態として定義するか、すべてのメンバーが既定値を安全に扱えるようにする必要がある。
構造体のインスタンスコンストラクターには、確実な代入規則と既定初期化規則が強く関係する。C# 10以前では、構造体のコンストラクターは戻る前にすべてのインスタンスフィールドを確実に代入する必要があった。C# 11以降では、auto-default structsにより、明示的に代入されなかったフィールドはコンパイラによってdefaultで暗黙初期化される。したがって、現代のC#では「すべてのフィールドを手動で代入しなければコンパイルできない」とは限らない。ただし、コンストラクター内でthisやインスタンスメンバーを読む前に、どのフィールドが明示的に代入され、どのフィールドが既定値になるかを意識する必要がある。自動実装プロパティへの代入は、その隠れたバッキングフィールドへの代入として扱われる。1112
C# 10以降では、構造体は明示的なパラメーターなしコンストラクターを宣言できる。ただしdefault(S)はそのパラメーターなしコンストラクターを呼び出さず、ゼロ初期化された値を生成する。new S()は、公開パラメーターなしコンストラクターが存在する場合にはそれを呼び出す。配列確保では、要素はコンストラクター呼び出しではなくゼロ初期化される。したがって、構造体にパラメーターなしコンストラクターを定義しても、既定値の存在は消えない。12
public struct Token
{
public Token()
{
Value = "<generated>";
}
public string? Value { get; }
}
Token a = new Token(); // Value == "<generated>"
Token b = default; // Value == null
Token[] values = new Token[1];
// values[0].Value == null構造体の不変設計ではreadonly structが有用である。readonly structはインスタンスフィールドへの書き込みを制限し、その値を読み取り専用の値として扱うことを示す。これはAPI利用者に対する意味付けだけでなく、inパラメーターや読み取り専用文脈における防御的コピーの回避にも関係する。ただし、readonlyは参照型フィールドが指すオブジェクトの深い不変性を保証しない。構造体が参照型フィールドを含む場合、値そのものは変更されなくても、参照先の状態は変更され得る。
public readonly struct Range
{
public Range(int start, int length)
{
Start = start;
Length = length;
}
public int Start { get; }
public int Length { get; }
}構造体を可変にする場合には、コピー意味論との相互作用に注意する必要がある。可変構造体はプロパティ、インデクサ、foreach変数、readonlyフィールド、inパラメーターなどの文脈で、意図しないコピーに対して変更を行う危険がある。特にプロパティから返された構造体を変更しても、元の格納場所は変更されない場合がある。構造体は小さく、不変で、値としての同一性が自然な型に向く。大きく可変で、同一性や共有状態を持つ概念には、通常はクラスを検討すべきである。
public struct MutablePoint
{
public int X { get; set; }
public int Y { get; set; }
public void Move(int dx, int dy)
{
X += dx;
Y += dy;
}
}可変構造体は常に禁止されるわけではない。たとえばSpan<T>のような低水準のメモリ範囲を表す型では、可変的な値型としての設計が重要になる。ただし、Span<T>は通常の構造体ではなくref structであり、寿命制約や配置制約を伴う特殊な値型である。詳細はref structおよびメモリ制御の章で扱う。一般的なドメインモデルでは、可変構造体はコピーの境界が見えにくくなるため、設計上の注意が必要である。値型としての利点を得たい場合でも、不変性、サイズ、既定値、boxingの有無を確認してから採用する必要がある。
6.4 record struct
record structは構造体にrecordの合成規則を加えた値型である。record structは独立した第3の実行時型分類ではなく、構造体の一種である。したがって値型であり、コピー意味論を持ち、暗黙にSystem.ValueTypeへつながり、構造体としての制約を受ける。13
public readonly record struct Point(int X, int Y);record structの主な特徴は、値としての等値性に関するメンバーが合成されることである。通常の構造体でもEqualsやGetHashCodeを実装できるが、record structでは、宣言されたフィールドやプロパティに基づく等値性、==および!=演算子、ToString、分解、with式に関係するメンバーが合成される。これにより座標、範囲、識別子、単位付き値など、値として比較される小さなデータ構造を簡潔に定義できる。13
public readonly record struct CustomerId(Guid Value);
CustomerId a = new(Guid.Parse("00000000-0000-0000-0000-000000000001"));
CustomerId b = new(Guid.Parse("00000000-0000-0000-0000-000000000001"));
Console.WriteLine(a == b); // true位置指定record structでは、主コンストラクターのパラメーターから対応する公開プロパティが合成される。readonly record structの場合、合成されるプロパティは初期化後変更できない形になる。readonlyを付けないrecord structでは、合成プロパティが可変になり得る。この差は設計上重要である。recordと名が付いていても、record structが自動的に不変になるわけではない。13
public record struct MutableSize(int Width, int Height);
MutableSize size = new(10, 20);
size.Width = 30; // 可能このような可変record struct(mutable record struct)は、構文上は簡潔であるが値的等値性と可変性が結び付くため注意が必要である。ハッシュテーブルのキーとして使った後に値を変更すると、探索不能になる可能性がある。公開APIでrecord structを使う場合には、readonly record structを既定候補とし、可変性が必要な理由を明確にする方がよい。
public readonly record struct Size(int Width, int Height);with式は既存の値をコピーし、一部のメンバーだけを変更した新しい値を作る。record classでは参照型オブジェクトのコピー的な生成として読む必要があるが、record structでは値型のコピーとして読む。receiverが構造体である場合、まず値がコピーされ、そのコピーに対してメンバー初期化が適用される。したがってwith式は値型のコピー意味論と整合する。13
public readonly record struct Size(int Width, int Height);
Size original = new(10, 20);
Size resized = original with { Width = 30 };
Console.WriteLine(original); // Size { Width = 10, Height = 20 }
Console.WriteLine(resized); // Size { Width = 30, Height = 20 }record structは通常のstructと同じく既定値を持つ。主コンストラクターや合成メンバーがあっても、defaultによってゼロ初期化された値が作られ得る。したがってrecord structでも既定値を有効な値として扱えるかを検討する必要がある。
public readonly record struct EmailAddress(string Value);
EmailAddress e = default;
// Valueはnullになり得るこの例ではEmailAddressという型名が妥当な文字列だけを表すように見えても、defaultではValueがnullになる。record structは宣言の簡潔さと合成等値性を与えるが、不変条件を自動的に完全保証するものではない。強い検証を伴う値オブジェクトでは、通常のreadonly structとして明示的にコンストラクター、検証、等値性を実装する方が適切な場合もある。
record structの適用場面は小さく、値として比較され、分解やwith式が自然なデータ構造である。座標、サイズ、範囲、識別子、単純な測定値などが典型例になる。逆にリソース所有、同一性、ライフサイクル、遅延初期化、共有状態を持つ概念には向かない。record structはデータの形を簡潔に記述する道具であり、ドメイン不変条件や所有権モデルを自動で設計する機能ではない。
6.5 null許容値型
null許容値型は非null許容値型Tの値に追加のnull値を加えた型である。構文上はT?と書き、実体としてはSystem.Nullable<T>に対応する。ここでのTは非null許容値型でなければならず、int??のようにnull許容値型をさらにnull許容値型にすることはできない。3
int? count = 10;
int? missing = null;
Nullable<int> same = count;null許容値型は値が存在するかどうかをHasValueで表し、存在する値をValueで取り出す。HasValueがfalseのときにValueを読むと、InvalidOperationExceptionが発生する。したがってValueを直接読む前には、HasValue、isパターン、??演算子などで値の有無を明確にする必要がある。3
int? value = GetOptionalCount();
if (value.HasValue)
{
Console.WriteLine(value.Value);
}
if (value is int n)
{
Console.WriteLine(n);
}
int fallback = value ?? 0;null許容値型の既定値はHasValueがfalseである値である。これは参照型のnull参照と似た形で扱えるが、実体としてはNullable<T>構造体の値である。default(int?)は値を持たないint?であり、default(int)は0である。したがってT?の既定値とTの既定値は異なる。
int x = default; // 0
int? y = default; // HasValue == falsenull許容値型では基礎となる値型の演算子がliftされる。lifted operatorは、通常オペランドのいずれかがnullなら結果もnullになる。ただし、bool?に対する&および|は三値論理として特別な規則を持ち、片方がnullでも結果がtrueまたはfalseに決まる場合がある。また、<、>、<=、>=のような比較演算子は、片方がnullなら結果はfalseになる。==では両方がnullならtrue、片方だけがnullならfalseになる。3
int? a = 10;
int? b = null;
int? sum = a + b;
bool greater = a > b;
bool isNull = b == null;
Console.WriteLine(sum.HasValue); // false
Console.WriteLine(greater); // false
Console.WriteLine(isNull); // trueこの規則は通常の数値比較の直感と異なる場合がある。たとえば、a >= nullがfalseであっても、a < nullがtrueになるわけではない。null許容値型の比較は、順序集合に単純にnullを追加したものではなく、演算子ごとの規則として読む必要がある。
null許容値型とboxingの関係は特殊である。T?の値をboxingする場合、HasValueがfalseなら結果はnull参照になる。HasValueがtrueなら、Nullable<T>そのものではなく、基礎となるTの値がboxedされる。したがって、非nullのint?をobjectに代入しても、その実行時型はSystem.Nullable<int>ではなくSystem.Int32として観察される。3
int? a = 42;
object? boxedA = a;
Console.WriteLine(boxedA?.GetType()); // System.Int32
int? b = null;
object? boxedB = b;
Console.WriteLine(boxedB is null); // trueこの性質により、object.GetType()やis演算子だけで、値がもともとNullable<T>だったかを判定することはできない。型そのものがnull許容値型かを調べる場合は、typeof(int?)のような型情報に対してNullable.GetUnderlyingTypeを用いる。値インスタンスから観察する場合には、boxing規則によって情報が失われる。
bool IsNullableValueType(Type type)
=> Nullable.GetUnderlyingType(type) is not null;
Console.WriteLine(IsNullableValueType(typeof(int?))); // true
Console.WriteLine(IsNullableValueType(typeof(int))); // falseパターンマッチングではnull許容値型に対して基礎となる型のパターンを使うと、値が存在する場合だけマッチする。これはHasValueを確認してからValueを取り出す処理を簡潔に書く方法として有用である。特に、if (x is int n)は、xが値を持つ場合にその値をnとして束縛する。
int? x = GetValue();
if (x is int n)
{
Console.WriteLine(n);
}
else
{
Console.WriteLine("値がありません。");
}null許容値型は値が欠落し得ることを型で表すために有用である。ただし、すべての欠落可能性をT?で表すのが適切とは限らない。失敗理由を持つ処理、複数の状態を区別する処理、エラーと欠落を分ける必要がある処理では、専用の結果型、判別共用体的な設計、例外、Try*パターンなどを検討する必要がある。T?は「値があるかないか」を表す最小限の型であり、失敗モデル全体を表す機構ではない。
6.6 値型のコストモデル
値型のコストモデルはコピー、boxing、一時値、防御的コピー(defensive copy)、ジェネリクスとの関係として整理できる。値型は参照型より常に高速である、あるいは常に割り当てを避けられる、という理解は不正確である。値型は、適切に使えば割り当て削減、局所性、ジェネリック特殊化、API契約の明確化に寄与する。一方で、大きな値型、可変値型、頻繁なboxing、インターフェイス経由の呼び出し、不用意なinパラメーターは、性能と可読性を悪化させる場合がある。111
コピーは値型の基本コストである。小さな構造体であればコピーコストは無視しやすい。しかしフィールド数が多い構造体、大きな配列や複数の値を含む構造体、ネストした値型を含む構造体では、代入、引数渡し、戻り値、プロパティ取得のたびにコピーが問題になり得る。JITがコピーを省略できる場合もあるが、公開API設計では呼び出し側がどのような文脈で使うかを制御できないため、値型のサイズは重要な設計条件になる。
public readonly struct LargeValue
{
public readonly long A;
public readonly long B;
public readonly long C;
public readonly long D;
public readonly long E;
public readonly long F;
public readonly long G;
public readonly long H;
}このような大きな値型を値渡しで頻繁に受け渡すと、参照型より不利になる場合がある。inパラメーターはコピーを避けるために使えるが、常に高速化するとは限らない。小さな値型では参照渡しの間接性の方が不利になる場合がある。またinパラメーターが読み取り専用文脈を作ることで、防御的コピーが発生する場合もある。
boxingは値型をobject、System.ValueType、System.Enum、または実装インターフェイス型として扱うときに発生し得る。boxingでは、値型の値がヒープ上のオブジェクトへコピーされ、そのオブジェクトへの参照が作られる。これは割り当てとコピーを伴う。unboxingでは、ボックス化された値が期待する値型であることを検査し、値を取り出す。boxingは変換規則であり、単なる型注釈の変更ではない。211
int x = 42;
object boxed = x; // boxing
int y = (int)boxed; // unboxingインターフェイス呼び出しでもboxingが問題になる場合がある。値型がインターフェイスを実装していても、その値をインターフェイス型の変数へ代入するとboxingが発生する。一方、ジェネリック制約を使うと、値型をboxedせずに操作できる場合がある。したがって、性能上重要なAPIでは、objectや非ジェネリックインターフェイスに値型を流す経路を避け、ジェネリック型やジェネリックメソッドで型情報を保持する設計が有効になる。
public interface IMeasurable
{
int Measure();
}
public readonly struct Item : IMeasurable
{
public int Measure() => 1;
}
IMeasurable m = new Item(); // boxingが発生し得る
static int Measure<T>(T value) where T : IMeasurable
{
return value.Measure(); // ジェネリック制約によりboxingを避けやすい
}一時値も値型の読解で重要である。プロパティやメソッドの戻り値として構造体が返る場合、その結果は一時値として扱われる。可変構造体に対して一時値上で変更を行おうとすると、元の格納場所を変更できないか、コンパイル時エラーになる場合がある。値型ではどの式が変数として分類され、どの式が値として分類されるかを意識する必要がある。
public struct Position
{
public int X { get; set; }
public int Y { get; set; }
}
public sealed class Entity
{
public Position Position { get; set; }
}
Entity e = new Entity();
// e.Position.X = 10; // プロパティが返す一時値に対する変更として問題になる
Position p = e.Position;
p.X = 10;
e.Position = p;防御的コピーは読み取り専用文脈で可変構造体のメンバーを呼び出す際に発生し得る。readonlyフィールド、inパラメーター、readonly structでない値型の読み取り専用参照などでは、メンバー呼び出しが元の値を変更しないことを保証するため、コンパイラがコピーを作る場合がある。構造体メンバーにreadonlyを付ける、構造体全体をreadonly structにする、不変設計にすることは、この問題を減らす手段になる。
public struct Accumulator
{
private int _value;
public int Value => _value;
public void Add(int value)
{
_value += value;
}
}
public readonly struct ImmutablePoint
{
public ImmutablePoint(int x, int y)
{
X = x;
Y = y;
}
public int X { get; }
public int Y { get; }
public readonly int Sum() => X + Y;
}ジェネリクスとの関係では値型は重要な性能上の性質を持つ。.NETのジェネリクスは、値型の型引数に対してboxingを避けたコード生成や特殊化を行える。たとえば、List<int>はint値を要素として保持でき、ArrayListのように各要素をobjectとしてboxingする必要がない。これは、C#におけるジェネリクスが型安全性だけでなく、値型の性能にも関係する理由である。
var list = new List<int>();
list.Add(1);
list.Add(2);
int first = list[0]; // boxingなしで扱える値型を公開APIで採用する際の原則は次のように整理できる。第一にその概念が値として自然に比較・コピーされるかを確認する。第二に既定値が有効な状態として扱えるかを確認する。第三に型のサイズが小さく、頻繁なコピーに耐えるかを確認する。第四にboxingされる経路が多くないかを確認する。第五に可変性を持たせる場合、その可変性がコピー意味論と衝突しないかを確認する。
値型は実装詳細として選ぶものではなく、意味論として選ぶべき型分類である。ドメイン上の値、測定値、識別子、座標、範囲、小さな複合値には値型が適する場合がある。オブジェクト同一性、共有状態、継承、多相的な振る舞い、ライフサイクル、リソース所有を表す概念には参照型が適する場合が多い。値型と参照型の選択は、性能だけでなく意味、契約、既定値、互換性、呼び出し側の使い方を含む設計判断である。
本ノートで扱った値型の性質は、以後の参照型、継承、ジェネリクス、変換、式評価、性能の章に接続される。特にboxing/unboxingは変換の章で、値型とobject・インターフェイスの関係は継承とジェネリクスの章で、コピーと防御的コピーはref、in、out、Span<T>の章で再度扱う。値型を正確に読むには、単に「値を直接持つ型」と覚えるだけでは不十分であり、既定値、コピー、boxing、初期化、API契約を同時に追う必要がある。
脚注
-
Ecma International, ECMA-334: C# Language Specification, 7th ed., December 2023, §8 Types; Microsoft Learn, Types - C# language specification, updated 2025-09-12, §8.3 Value types. 値型、非null許容値型、null許容値型、既定値、単純型、構造体型、列挙型の仕様上の整理。 ↩︎ ↩︎2 ↩︎3 ↩︎4 ↩︎5 ↩︎6 ↩︎7
-
Ecma International, ECMA-335: Common Language Infrastructure (CLI), 6th ed., June 2012. CTS、値型、boxing、メタデータ、実行時表現に関する規範的仕様。 ↩︎ ↩︎2
-
Microsoft Learn, Nullable value types - C# reference, updated 2026-01-20.
T?、System.Nullable<T>、HasValue、Value、lifted operators、boxing、pattern matchingとの関係。 ↩︎ ↩︎2 ↩︎3 ↩︎4 ↩︎5 -
Microsoft Learn, Integral numeric types - C# reference, updated 2026-01-20. C#の整数型、対応する.NET型、値域、
nintおよびnuintの整理。ただし、nint/nuintの仕様上の扱いについては、単純な別名ではなくSystem.IntPtr/System.UIntPtrで表現される型として1を優先して読む。 ↩︎ ↩︎2 -
Microsoft Learn, Floating-point numeric types - C# reference, updated 2026-01-20.
float、double、decimalの範囲、精度、既定値、丸め、十進小数表現の整理。 ↩︎ ↩︎2 ↩︎3 -
Microsoft Learn, bool type - C# reference, updated 2026-01-20.
boolがSystem.Booleanの別名であり、値がtrueまたはfalseであること、条件式や三値論理との関係。 ↩︎ ↩︎2 -
Microsoft Learn, The char type - C# reference, updated 2026-01-20.
charがSystem.Charの別名であり、Unicode UTF-16コード単位を表すこと、およびstringとの関係。 ↩︎ -
Microsoft Learn, The checked and unchecked statements - C# reference, updated 2026-01-20.
checked/unchecked文脈、整数演算と変換、定数式、既定のオーバーフロー検査文脈の整理。 ↩︎ ↩︎2 -
Microsoft Learn, Enumeration types - C# reference, updated 2026-01-14. enumが基底整数型の名前付き定数集合として定義されること、既定値、ゼロ値、フラグ列挙、
Enum.IsDefinedの整理。 ↩︎ ↩︎2 ↩︎3 ↩︎4 -
Microsoft Learn, Enums - C# language specification, §20. 列挙型の宣言、基底型、列挙メンバー、値の範囲、
System.Enum、列挙型に対する演算の仕様上の整理。 ↩︎ ↩︎2 ↩︎3 ↩︎4 -
Microsoft Learn, Structs - C# language specification, updated 2025-12-09, §16. 構造体宣言、コピー、既定値、継承不可、boxing、コンストラクター、
readonly構造体メンバーの仕様上の整理。 ↩︎ ↩︎2 ↩︎3 ↩︎4 ↩︎5 ↩︎6 ↩︎7 -
Microsoft Learn, Parameterless struct constructors - C# feature specifications, updated 2023-06-23; Microsoft Learn, Auto-default structs - C# feature specifications, updated 2023-06-23. C# 10以降の構造体パラメーターなしコンストラクター、インスタンスフィールド初期化子、
default式、new()、配列初期化、およびC# 11以降のauto-default structsとの関係。 ↩︎ ↩︎2 -
Microsoft Learn, Record structs - C# feature specifications, updated 2023-06-23. record structが値型であり、構造体と同じ制約を受けつつ、等値性、プロパティ、分解、
with式などの合成メンバーを持つことの整理。 ↩︎ ↩︎2 ↩︎3 ↩︎4