int? や DateTime? のような Nullable 値型は「null を持てる値型」として日常的に使います。しかし「なぜ値型に null が持てるのか」「null + 5 は何を返すのか」「null < 5 は true か false か」など、仕組みを掘り下げると意外な挙動があります。
本記事では Nullable<T> の構造・リフト演算子・比較の特殊ルール・??/??= の深掘り・boxing の落とし穴・ジェネリクス制約・DB 設計まで体系的に解説します。NullReferenceException の原因と ?./?[] 演算子はNullReferenceException完全ガイドを、コンパイラによる null 安全(#nullable enable)はnull許容参照型の基本を参照してください。
Nullable<T> の仕組み
int? は C# コンパイラが提供する糖衣構文で、実体は System.Nullable<T> 構造体です。
// 書いたコード int? a = 42; int? b = null; // コンパイラが展開するコード(意味的に同等) Nullable<int> a2 = new Nullable<int>(42); Nullable<int> b2 = new Nullable<int>(); // HasValue = false // Nullable<T> の主要メンバー Console.WriteLine(a.HasValue); // True Console.WriteLine(a.Value); // 42 Console.WriteLine(b.HasValue); // False // Console.WriteLine(b.Value); // InvalidOperationException! HasValue が false のとき Value は例外 // 安全な値取得: GetValueOrDefault Console.WriteLine(b.GetValueOrDefault()); // 0 (int の既定値) Console.WriteLine(b.GetValueOrDefault(-1)); // -1 (指定した既定値) Console.WriteLine(a.GetValueOrDefault(-1)); // 42 (HasValue = true なので実際の値)
Nullable<T> の制約型パラメーター
T には値型(struct)のみが使えます。string?・object? などの参照型の ? は別物(null 許容参照型)で、Nullable<string> ではありません。参照型はそもそも null を持てるため、Nullable<T> でラップする必要がないのです。| 書き方 | 実体 | 値型 / 参照型 |
|---|---|---|
int? |
Nullable<int>(構造体) |
値型 |
DateTime? |
Nullable<DateTime>(構造体) |
値型 |
bool? |
Nullable<bool>(構造体) |
値型 |
string? |
string(参照型)+ コンパイラ注釈 |
参照型(Nullableではない) |
int[]? |
int[](参照型)+ コンパイラ注釈 |
参照型(Nullableではない) |
リフト演算子(Lifted Operators)— null の伝播ルール
C# は Nullable<T> に対して「リフト演算子」を自動的に定義します。演算のどちらかのオペランドが null の場合、結果も null になります(例外: 等値比較)。
int? a = 10; int? b = null; int? c = 5; // 算術演算: どちらかが null → 結果は null Console.WriteLine(a + b); // null(null が伝播) Console.WriteLine(a + c); // 15 Console.WriteLine(a * b); // null Console.WriteLine(b - b); // null(null + null も null) // null 伝播を利用した安全なチェーン計算 int? score1 = 80; int? score2 = null; int? score3 = 70; int? total = score1 + score2 + score3; Console.WriteLine(total); // null(1つでも null があると全体が null) // null を 0 として計算したい場合は ?? で事前変換 int safeTotal = (score1 ?? 0) + (score2 ?? 0) + (score3 ?? 0); Console.WriteLine(safeTotal); // 150
比較演算の特殊ルール
比較演算(</>/<=/>=)でどちらかが null の場合は常に false を返します。等値比較(==/!=)は異なるルールを持ちます。
int? a = null; int? b = 5; int? c = null; // 大小比較: どちらかが null → 常に false Console.WriteLine(a < b); // False(null は 5 より小さくない) Console.WriteLine(a > b); // False(null は 5 より大きくない) Console.WriteLine(a <= b); // False(null <= 5 も false!直感と異なる) Console.WriteLine(a >= b); // False // 等値比較: null == null → true, null == 値 → false Console.WriteLine(a == c); // True(null == null は true) Console.WriteLine(a == b); // False(null == 5 は false) Console.WriteLine(a != b); // True // 注意: !(a < b) は a >= b と同じ結果にならない Console.WriteLine(!(a < b)); // True(false の否定) Console.WriteLine(a >= b); // False(別の結果!) // これが「リフト比較演算子は SQLの NULL セマンティクスと同じ」理由 // int? を int として比較したい場合は HasValue で確認してから bool isSmaller = a.HasValue && b.HasValue && a.Value < b.Value; Console.WriteLine(isSmaller); // False(a が null なので false)
null <= 5 は false、!(null < 5) は true — 両者は異なります。SQL の NULL セマンティクスと同じです。ソート・フィルタリングで Nullable 値型を扱うときは .HasValue チェックを先に行うか、?? でデフォルト値を与えてから比較してください。Nullable.Compare / Nullable.Equals 静的メソッド
Nullable クラス(非ジェネリック)には、null を含む比較を安全に行う静的メソッドがあります。
int? a = null;
int? b = 5;
int? c = null;
// Nullable.Compare<T>: null を「最小値」として扱う比較
// 戻り値: 負=左が小さい, 0=等しい, 正=左が大きい
Console.WriteLine(Nullable.Compare(a, b)); // -1(null < 5)
Console.WriteLine(Nullable.Compare(b, a)); // 1(5 > null)
Console.WriteLine(Nullable.Compare(a, c)); // 0(null == null)
Console.WriteLine(Nullable.Compare(b, b)); // 0(5 == 5)
// Nullable.Equals<T>: 等値判定(== と同じ動作)
Console.WriteLine(Nullable.Equals(a, c)); // True(null == null)
Console.WriteLine(Nullable.Equals(a, b)); // False(null != 5)
// Nullable.Compare は List.Sort や Array.Sort の comparer に使える
var scores = new List<int?> { 90, null, 70, null, 85 };
scores.Sort((x, y) => Nullable.Compare(x, y));
// → null, null, 70, 85, 90(null が先頭)
Console.WriteLine(string.Join(", ", scores.Select(s => s?.ToString() ?? "null")));
null 合体演算子(??)の深掘り
基本と三項演算子との比較
int? input = null;
// 三項演算子で書く場合(冗長)
int result1 = input.HasValue ? input.Value : -1;
// ?? を使う(簡潔・同じ意味)
int result2 = input ?? -1;
// 参照型にも使える
string? name = null;
string display = name ?? "名前未設定";
Console.WriteLine(display); // "名前未設定"
// ?? は右辺が評価されるのは左辺が null のときだけ(短絡評価)
string? cached = null;
string value = cached ?? LoadFromDatabase(); // null のときだけ DB アクセス
static string LoadFromDatabase() { Console.WriteLine("DB アクセス"); return "data"; }
?? のチェーンと優先順位
string? s1 = null; string? s2 = null; string? s3 = "見つかった"; // ?? は左から右に評価(最初の非 null を返す) string result = s1 ?? s2 ?? s3 ?? "デフォルト"; Console.WriteLine(result); // "見つかった" // 設定値の優先順位付け取得(よく使うパターン) string? envConfig = null; // 環境変数(最優先) string? fileConfig = null; // 設定ファイル string? hardcoded = "localhost:5432"; // ハードコード(最後の砦) string dbHost = envConfig ?? fileConfig ?? hardcoded; Console.WriteLine(dbHost); // "localhost:5432" // 注意: ?? の優先順位は低い(= より低い) // 次の2行は意味が異なる int? a = null; int x = (a ?? 0) + 5; // OK: (null ?? 0) + 5 = 5 int y = a ?? (0 + 5); // OK: null ?? 5 = 5(同じ結果だが意図が異なる) // int z = a ?? 0 + 5; // OK: a ?? (0 + 5) と解釈される(??が+より低優先度)
null 合体代入演算子(??=)— C# 8+
??= は「左辺が null の場合のみ右辺を代入する」演算子です。遅延初期化・デフォルト値の補完に便利です。
// 基本: 左辺が null のとき右辺を代入
string? name = null;
name ??= "Unknown";
Console.WriteLine(name); // "Unknown"
name ??= "Another"; // name はすでに非 null なので代入されない
Console.WriteLine(name); // "Unknown"(変更なし)
// 遅延初期化パターン(ロック不要のシンプル版)
private List<string>? _cache;
public List<string> GetCache()
{
_cache ??= new List<string>(); // null なら初期化
return _cache;
}
// 展開すると: if (_cache is null) _cache = new List<string>(); return _cache;
// Nullable 値型にも使える
int? count = null;
count ??= 0;
count++; // count = 1
Console.WriteLine(count); // 1
// Dictionary への既定値設定
var settings = new Dictionary<string, string?>();
settings["timeout"] = null;
settings["timeout"] ??= "30";
settings["host"] ??= "localhost";
Console.WriteLine(settings["timeout"]); // "30"
Console.WriteLine(settings["host"]); // "localhost"
パターンマッチングと Nullable<T>
C# 7 以降のパターンマッチングは Nullable 値型と組み合わせることで、HasValue チェックと値取り出しを一度に行えます。
int? score = 85;
// is null / is not null パターン(C# 9+)
if (score is null)
Console.WriteLine("スコアなし");
else
Console.WriteLine($"スコア: {score}");
// 型パターンで値を取り出す(HasValue + Value の代わり)
if (score is int s) // null でなければ s に int として取り出す
{
Console.WriteLine($"取り出した値: {s}"); // s は int 型(int? ではない)
Console.WriteLine(s > 60 ? "合格" : "不合格");
}
// switch 式でのパターンマッチング(C# 8+)
static string Evaluate(int? score) => score switch
{
null => "データなし",
< 0 => "無効なスコア",
< 60 => "不合格",
< 80 => "合格",
<= 100 => "優秀",
_ => "範囲外",
};
Console.WriteLine(Evaluate(null)); // "データなし"
Console.WriteLine(Evaluate(85)); // "優秀"
Console.WriteLine(Evaluate(-1)); // "無効なスコア"
// when ガード節と組み合わせ
static string Grade(int? score) => score switch
{
int n when n >= 90 => "A",
int n when n >= 70 => "B",
int n when n >= 50 => "C",
int => "D", // 50未満の全ての int
null => "未受験",
};
boxing / unboxing の落とし穴
値型は object に代入するとき boxing(ヒープへのコピー)が発生します。Nullable<T> の boxing は特別な挙動をします。
int? a = 42;
int? b = null;
// boxing: Nullable<int> → object
object boxedA = a; // ← int としてboxingされる(Nullable<int>ではない!)
object boxedB = b; // ← null が格納される(Nullable<int>ではなく null そのもの)
Console.WriteLine(boxedA.GetType().Name); // "Int32"(Nullable<int>ではない)
Console.WriteLine(boxedB == null); // True
// unboxing
int unboxedA = (int)boxedA; // OK: int にアンボックス
int? unboxedA2 = (int?)boxedA; // OK: int? にもアンボックスできる
// NG: null を int にアンボックスしようとすると NullReferenceException
try
{
int danger = (int)boxedB; // NullReferenceException!
}
catch (NullReferenceException) { Console.WriteLine("null のアンボックスは失敗"); }
// 安全なアンボックス
int? safeUnbox = boxedB as int?; // as を使うと失敗時に null を返す(int? なので)
// ただし as は参照型と Nullable<T> にのみ使える
// 実際の落とし穴: interface 経由で箱に入れると型が変わる
IComparable boxedComp = a; // int としてboxingされる
Console.WriteLine(boxedComp.GetType().Name); // "Int32"(Nullable<int>ではない)
・
Nullable<T> が boxing されると、値がある場合は T として、null の場合は null として boxing されます。・コード内で
Nullable<int> が boxed された object の型を確認すると Int32 になります。これは意図的な CLR の設計です。・
GetType() が呼べるのは boxed された後なので、null 状態では呼べません。ジェネリクスと Nullable<T>
// T を Nullable<T> にするには where T : struct 制約が必要
public static T? FindFirst<T>(IEnumerable<T> source, Func<T, bool> predicate)
where T : struct // T が値型であることを保証
{
foreach (var item in source)
{
if (predicate(item))
return item; // T → T? への暗黙変換(HasValue = true)
}
return null; // null(HasValue = false)を返せる
}
int[] numbers = { 3, 7, 2, 9, 1 };
int? found = FindFirst(numbers, n => n > 5);
Console.WriteLine(found); // 7(最初に5より大きいもの)
int? notFound = FindFirst(numbers, n => n > 100);
Console.WriteLine(notFound.HasValue); // False
// 制約なしジェネリクスでは T? を作れない
// public static T? Bad<T>(T value) => value; // CS8627: 'T' が構造体でないと ? は使えない
// 値型と参照型両方対応したい場合は class/struct で分ける
public static T? FindFirst<T>(IEnumerable<T?> source, Func<T, bool> predicate)
where T : class
{
foreach (var item in source)
{
if (item is not null && predicate(item)) return item;
}
return null;
}
実践パターン
DB 値の型設計
// DB の NULL 可能列は C# で Nullable 値型にマップする
public class UserProfile
{
public int Id { get; set; } // NOT NULL
public string Name { get; set; } = ""; // NOT NULL
public string Email { get; set; } = ""; // NOT NULL
// Nullable: DB で NULL を格納できる列
public DateTime? Birthday { get; set; } // 生年月日(任意)
public decimal? Height { get; set; } // 身長(任意)
public int? Score { get; set; } // スコア(未受験なら null)
public bool? IsSubscribed { get; set; } // 3値論理(yes/no/未回答)
}
// 集計での扱い: LINQ は Nullable を自動的に除外して集計する
var profiles = new List<UserProfile>
{
new() { Id=1, Name="Alice", Score=85 },
new() { Id=2, Name="Bob", Score=null }, // 未受験
new() { Id=3, Name="Carol", Score=72 },
};
// Average/Max/Min は null を除外して計算(SQL の AVG(NULLIF) 相当)
// 戻り値は Nullable 型(double? / int?)
double? avgScore = profiles.Average(p => p.Score); // 78.5(85+72を2で割る)
int? maxScore = profiles.Max(p => p.Score); // 85
// すべて null のとき:
// nullable 版 Average/Max/Min は null を返す(例外は発生しない)
// 非 nullable 版(Average(IEnumerable<int>))は空シーケンスで InvalidOperationException
var allNull = profiles.Select(p => (int?)null);
double? nullAvg = allNull.Average(n => n); // null(例外なし)
int? nullMax = allNull.Max(n => n); // null(例外なし)
// Sum の nullable 版: すべて null のとき 0 を返す(null でない点に注意)
int? sumResult = allNull.Sum(n => n); // 0(null ではない)
フォーム入力の変換パターン
// ユーザー入力(string)を Nullable 値型に変換する
static int? ParseNullable(string? input)
{
if (string.IsNullOrWhiteSpace(input)) return null;
return int.TryParse(input.Trim(), out int value) ? value : null;
}
static decimal? ParseDecimalNullable(string? input)
{
if (string.IsNullOrWhiteSpace(input)) return null;
return decimal.TryParse(input.Trim(), out decimal value) ? value : null;
}
static DateTime? ParseDateNullable(string? input, string format = "yyyy-MM-dd")
{
if (string.IsNullOrWhiteSpace(input)) return null;
return DateTime.TryParseExact(input.Trim(), format,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var dt) ? dt : null;
}
// 使用例
string? ageInput = "28";
string? heightInput = ""; // 空文字 → null
string? dateInput = "invalid";
int? age = ParseNullable(ageInput); // 28
decimal? height = ParseDecimalNullable(heightInput); // null
DateTime? birth = ParseDateNullable(dateInput); // null(パース失敗)
Console.WriteLine($"年齢: {age ?? -1}"); // 28
Console.WriteLine($"身長: {height?.ToString("F1") ?? "未入力"}"); // "未入力"
Console.WriteLine($"誕生日: {birth?.ToString("yyyy年MM月dd日") ?? "不明"}"); // "不明"
3値論理(bool?)の活用
// bool? は「true / false / 不明(null)」の3値を表現できる
bool? hasConsented = null; // 同意確認未実施
// bool? の論理演算は SQL の3値論理に準拠
bool? t = true;
bool? f = false;
bool? u = null;
// AND演算
Console.WriteLine(t & f); // False
Console.WriteLine(t & u); // null(true AND 不明 → 不明)
Console.WriteLine(f & u); // False(false AND 不明 → false に確定)
// OR演算
Console.WriteLine(t | u); // True(true OR 不明 → true に確定)
Console.WriteLine(f | u); // null(false OR 不明 → 不明)
// 実用例: アンケート結果の集計
var responses = new bool?[] { true, null, false, true, null, true };
int yesCount = responses.Count(r => r == true);
int noCount = responses.Count(r => r == false);
int unanswered = responses.Count(r => r == null);
Console.WriteLine($"賛成: {yesCount}, 反対: {noCount}, 未回答: {unanswered}");
// 賛成: 3, 反対: 1, 未回答: 2
よくある質問
int? と int の変換はどうすればいいですか?int → int? は自動(int? a = 5;)。逆: int? → int は明示的キャスト (int)a(null なら InvalidOperationException)か a ?? 0 でデフォルト値を使います。a.Value も取り出せますが null のとき例外が出るので GetValueOrDefault() の方が安全です。null == null が true なのに null < 5 が false なのはなぜですか?==/!=)では null == null は true、null == 5 は false です。しかし大小比較(</>/<=/>=)のリフト演算子は、どちらかのオペランドが null なら常に false を返します。ソートで null を「最小値」として扱いたい場合は Nullable.Compare を使ってください。string? は Nullable<string> ですか?string は参照型なので Nullable<T>(値型専用)は使えません。string? は C# 8+ の「null 許容参照型」機能によるコンパイラ注釈で、実行時の型は単なる string(null を保持できる参照型)と同じです。コンパイラが静的解析で null の不整合を警告してくれる仕組みです。詳しくはnull許容参照型の基本を参照してください。?? と ?.?? はどちらを使えばいいですか??? は「null なら右辺を返す」、?. は「null なら null を返す(メンバーアクセスをスキップ)」です。よく使う組み合わせは obj?.Property ?? "default": obj が null なら null を返し、さらに ?? でデフォルト値を当てます。これで null チェックとデフォルト値設定を一行で書けます。Sum(p => p.Score) は null を 0 として合計します。Average(p => p.Score) は null を除外して平均を計算します(すべて null なら InvalidOperationException)。Max/Min も null を除外して最大/最小を返します。null だけのシーケンスに Max を呼ぶと例外になるので、Where(p => p.Score.HasValue) で先にフィルタするか、DefaultIfEmpty を組み合わせてください。まとめ
| 機能・概念 | ポイント |
|---|---|
Nullable<T>の実体 |
int? = Nullable<int>(構造体)。値型専用。HasValue/Value/GetValueOrDefault() |
| リフト演算子 | 算術演算: 片方が null → 結果は null。大小比較: 片方が null → 常に false |
| 等値比較 | null == null は true。大小比較とは異なるルール |
Nullable.Compare |
null を「最小値」として扱う比較。ソートの comparator に使える |
??(null 合体) |
左辺が null のとき右辺を返す。チェーン可。短絡評価 |
??=(null 合体代入, C# 8+) |
左辺が null のときだけ右辺を代入。遅延初期化パターンに最適 |
| パターンマッチング | is int n で null チェック + 値取り出しを同時に。switch 式でも使える |
| boxing の特殊挙動 | HasValue=true なら T としてboxing。null なら null に。GetType() は Int32 を返す |
| ジェネリクス制約 | where T : struct で T?(Nullable<T>)を返せる |
| DB 設計 | NULL 可能列は DateTime?/decimal?/bool? でマップ。LINQ 集計は null を自動除外(Sum のみ 0 扱い) |
null に関連するすべての演算子(?./?[]/??/??=)の詳細はNullReferenceException完全ガイドを、C# 8+ のコンパイラレベルの null 安全はnull許容参照型の基本を参照してください。