「あれ、null チェック入れたのに、まだ空文字にならへんぞ…??」
DB から拾った値を ?? "(未設定)" で埋めたはずやのに、画面には空でも「(未設定)」でもない、なんか変な値が出てる。
金曜の夕方。客先の島で隣の後輩に「これ何すか」って言われて、俺も一瞬固まったやつです。
どうも、ヒロポンです。
今回は C# の ?? 演算子の使い分けを、?.(null 条件演算子)と ??= もまとめて整理します。if (x != null) でチマチマ書いてた null チェックを、短く安全に置き換えたい業務SE向けの内容です。
先に言っておくと、この3つは全部「null なら〇〇する」系。でも担当がそれぞれ違うんですよね。
そして DataAdapter で拾った DBNull だけは、この3つ全部すり抜けます。ここが一番ハマるとこ。順番に見ていきます。
💡 Nullable 値型(
int?)そのものの入門 は別記事 C# の Nullable 値型(int?)入門 にまとめてます。あっちは「型」の話、今回は「??/?./??=という3つの演算子をどう使い分けるか」の話。合わせて読むと穴が埋まるんで、別タブで開いて後で読んでくださいな。
結論: 3つは「null なら何をするか」の担当が違う。DBNull だけ別枠
先に答えから。使い分けはこれだけ覚えとけば大体いけます。
??(null 合体演算子)→ null なら既定値に置き換える?.(null 条件演算子)→ null なら触らず、そのまま null を返して抜ける??=(null 合体代入演算子)→ null の時だけ代入する(初期化に使う)
で、DataAdapter や DataRow から拾った値が DB 上で NULL だった場合。
これは null じゃなくて DBNull.Value っていう別のオブジェクトになってます。だから上の3つは全部「null じゃない」と判断してスルーする。
ここだけは演算子じゃ潰せない。そう覚えておくと事故りません。
表にするとこんな感じで整理できます。

「DBNull を潰せる」の行が全部 × なの、地味に大事なんで頭の隅に置いといてください。
なぜ ?? が DBNull を潰せないのか
理由はシンプル。DBNull.Value が null じゃないからです。
SqlDataAdapter で DataTable を埋めて、row["price"] みたいに列を取る。DB 側が NULL の場合は System.DBNull 型のシングルトン(DBNull.Value)が返ります。
これは「値が無い」ことを表すれっきとしたオブジェクトであって、参照としては null じゃない。
?? は左辺が null かどうかしか見てない。だから DBNull.Value は「null じゃない、中身あるやん」と判断してそのまま返す。
結果、(string)row["name"] ?? "(未設定)" みたいに書いても既定値に化けてくれない、という現象になります。
冒頭の後輩が踏んでたのもこれ。「null チェックしたのに効かない」の正体はだいたい DBNull です。対処は後半のハマりポイントで書きます。
最短対処: コピペで動く3つの書き方
まずは if で書いてた null チェックを演算子に置き換えるとこから。before / after で見た方が早いです。

customer?.Name の部分が、customer が null なら short-circuit(短絡)して null を返す。その null を ?? "(未設定)" が拾って既定値に変える。
この合わせ技でいい感じに1行にまとまるんで、一番よく使う型です。
では1個ずつ。
① ?? — null なら既定値に置き換える
一番シンプル。左辺が null なら右辺を返すだけ。
string input = GetUserInput(); // null が返りうる
string name = input ?? "名無し"; // null なら "名無し"
// 連鎖もできる(左から評価して最初の非 null を返す)
string display = nickname ?? realName ?? "(未設定)";
?? は右辺の評価が短絡します。つまり左辺が null じゃなければ右辺は見に行かない。だから重い処理を右辺に置いても、普段は走らない。
// cache が null の時だけ LoadFromDb() が走る
var data = cache ?? LoadFromDb();
Nullable 値型(int? とか)にも使えます。int? を int に落とす時の定番。
int? maybeAge = row.Field<int?>("age");
int age = maybeAge ?? 0; // null なら 0。これで int になる
② ?. — null なら触らず null を返して抜ける
?. は、左辺が null ならそこで短絡して null を返す。null のメンバにアクセスして NullReferenceException を吐く、あの事故を防ぐやつです。
// order が null なら customerName も null(例外は出ない)
string customerName = order?.Customer?.Name;
// メソッド呼び出しにも使える
order?.Save(); // order が null なら Save() は呼ばれない
チェーンの途中が null なら、そこから先は全部飛ばす。order が null なら .Customer も .Name も評価されません。
この「途中で切れたら残り全部スキップ」が ?. の肝。ハマりポイントにも直結するんで後で詳しく書きます。
インデクサ(配列アクセス)にも使えて、こっちは ?[] になります。
string first = list?[0]; // list が null なら null
③ ??= — null の時だけ代入する
??= は「左辺が null の時だけ右辺を代入する」演算子。x = x ?? y の短縮形です。フィールドの遅延初期化でよく使います。
private List<string> _cache;
public List<string> GetCache()
{
_cache ??= LoadCache(); // _cache が null の時だけ LoadCache()
return _cache;
}
_cache が既に埋まってれば LoadCache() は走らない。2回目以降はそのまま返るんで、いい感じにキャッシュとして機能します。
ただしこの ??= は C# 8.0 で入った新しいやつ。ここが 7.3 現場の罠になるんで、次で書きます。
ハマりポイント: 知らないと一晩飛ぶやつ
ハマり1: DataAdapter で拾った DBNull は ?? で潰れない
結論のとこで触れたやつ。DataRow から取った値が DB で NULL だと DBNull.Value なので、?? はスルーします。
// これは効かない。DBNull.Value は null じゃないので "(未設定)" に化けない
string name = (string)row["name"] ?? "(未設定)";
// ↑ row["name"] が DBNull.Value だと (string) キャストで InvalidCastException になることも
俺もこれで30分溶かしました。プレビュー画面に「System.DBNull」って文字がそのまま出ててポカーンとしたやつ。
正解は3つ。現場で一番ラクなのは DataRowExtensions.Field<T>() で受けることです。これは DBNull を null に変換してくれるんで、そのあと ?? がちゃんと効くようになります!!
// ◎ おすすめ: Field<T> は DBNull を null に変換してくれる
string name = row.Field<string>("name") ?? "(未設定)";
// ○ 明示的に判定する古典パターン
string name2 = row["name"] == DBNull.Value ? "(未設定)" : (string)row["name"];
// ○ Convert.IsDBNull でもいい
string name3 = Convert.IsDBNull(row["name"]) ? "(未設定)" : (string)row["name"];
実行結果:

Field<T>() を通すと DBNull の存在をほぼ意識せず、いい感じに ?? で書ける。DataAdapter を触る現場ならこれを標準にしとくと事故が減ります。
DBNull 側のハンドリングをもっと深掘りしたい人は SQL Server の DBNull を C# で安全にハンドリングする5つのイディオム に別パターンをまとめてます。別タブで開いて後で読んでくださいな。
ハマり2: ??= は C# 8.0。7.3 現場だと CS8370 で怒られる
??= を書いたら、いきなりこう怒られたことないですか??
CS8370: 機能 'Null 合体割り当て' は C# 7.3 では使用できません。
言語バージョン 8.0 以上を使用してください。
エラー再現:

これは ??= が C# 8.0 で追加された演算子だから。VS2019 で .NET Framework 4.7.2 を選ぶと、既定の言語バージョンは C# 7.3 です。だから ??= はそのままだと通りません。
抜け道は2つ。
ひとつは、csproj に言語バージョンを明示する。??= はランタイム非依存の構文糖(コンパイル時に if (x == null) x = y; 相当に展開されるだけ)なので、.NET Framework 4.7.2 のままでも C# 8.0 を指定すれば動きます!!
<PropertyGroup>
<LangVersion>8.0</LangVersion>
</PropertyGroup>
もうひとつは、素直に旧構文で書く。
// ??= が使えない現場はこれで等価
_cache = _cache ?? LoadCache();
現場のルールで LangVersion を勝手に上げられへんこともある。そういう時は下のパターンで逃げるのが無難です。
ハマり3: ?. の戻り値は値型だと int? になる。int に代入できない
?. は「左辺が null なら null を返す」必要があるんで、メンバが値型でも戻り値が Nullable に持ち上がります。
List<int> list = null;
int count = list?.Count; // ← CS0266: int? を int に暗黙変換できない
エラー再現:

list?.Count は、list が null なら null を返さなあかん。でも int は null を持てない。だからコンパイラは戻り値を int? に格上げする。
結果 int の変数には入らず CS0266 になる、という流れです。
参照型メンバなら元々 null を持てるんで、この持ち上げは起きません。値型メンバの時だけ意識すればいい。対処はさっきの ?? との合わせ技。
int count = list?.Count ?? 0; // null なら 0。これで int に収まる
「?. を値型に使ったら ?? で受ける」。これセットで覚えとくと詰まりません。
Nullable 値型まわりの挙動をもっと丁寧に追いたい人は C# の Nullable 値型(int?)入門 をどうぞ。
ハマり4: ?. の短絡は「引数すら評価しない」
?. の短絡、ここが盲点。左辺が null だと、その先のメソッドの引数も評価されません。
ILogger logger = null;
logger?.Log(BuildMessage()); // logger が null なら BuildMessage() は呼ばれない
logger が null なら .Log(...) ごと丸ごと飛ぶんで、中の BuildMessage() も走らない。
これ自体は「重いログ生成を無駄に走らせない」っていう良い性質なんですが、BuildMessage() に副作用(カウンタを進める、状態を変える等)があると事故ります。
ログのメッセージ組み立てるついでにカウンタ回してたら、logger が null の環境だけカウントがズレる。そういうバグ。
ん?なんでこの環境だけ数字合わへんの??ってなって、原因が ?. の短絡だと気づくまでが長い。
副作用のある式は ?. の右側に置かない。これだけ守っとけば大丈夫です。
現場メモ: 障害対応で時間を溶かさないために
ここからは現場メモ。俺が業務系の現場で null まわりを触ってて、結局これに落ち着いたって話をひとつ。
DataAdapter で DataTable を埋めて画面にバインドする、みたいなレガシーな作りの保守案件。null 由来のバグが出るたびに if (row["x"] != DBNull.Value) を各所にベタ書きしてました。
これが数十箇所に散らばると、どこか1箇所抜けてて本番でだけ落ちる。
で、方針を変えた。DB から値を取り出す層で全部 row.Field<T>() に寄せたんです。
そうすると DBNull がその層で null に統一されるんで、上の業務ロジックは ?? 既定値 と ?. だけで素直に書ける。「DBNull を意識する場所を1箇所に閉じ込める」っていう考え方です。
演算子そのものは C# 6 / 8 の基本機能で難しくない。でもどこで DBNull を null に変換するかを決めておかないと、?? を撒き散らしても穴が残る。
ここが業務SE的な勘所やなと思ってます。
まとめ
3つの演算子、担当はこれだけ。
??→ null なら既定値。int?をintに落とす時の定番?.→ null なら短絡して抜ける。値型メンバは戻り値がint?に持ち上がるんで??で受ける??=→ null の時だけ代入。C# 8.0 なので 7.3 現場は LangVersion か旧構文で
そして DataAdapter の DBNull.Value は3つ全部すり抜ける。取り出す層で row.Field<T>() に寄せて、null に統一してから演算子を使う。これで大体の null バグは踏まなくなります。
言語仕様のこういう細かい挙動を1個ずつ潰しておくと、レビューで指摘される回数が減る。本番で「なんでここだけ落ちるん」に付き合う時間も減る。
地味やけど、保守の現場だと効いてきます。
よくある質問
?? と ?. はどっちを先に覚えればいいですか?
先に ??(既定値の穴埋め)です。使用頻度が高いし、int? を int に落とす場面で毎日使う。?. はそのあと、深い階層を安全に触りたくなった時で十分です。
?? の右辺に重い処理を書いても大丈夫ですか?
大丈夫です。?? は左辺が null じゃなければ右辺を評価しません(短絡)。cache ?? LoadFromDb() は cache が埋まってれば LoadFromDb() を呼ばないんで、既定値としてメソッドを置くのは普通に使う書き方です。
DBNull を毎回 Field() で受けるのは冗長じゃないですか?
DB から取り出す層に閉じ込めれば冗長になりません。取得メソッドの中で row.Field<T>() に統一して、上の業務ロジックには null に変換済みの値だけ渡す。そうすれば呼び出し側は DBNull を意識せず ?? / ?. だけで書けます。
?. と ?? を1つの式に混ぜても読みにくくならないですか?
obj?.Prop ?? 既定値 くらいの2段なら問題ないです。ただし a?.b?.c?.d ?? x?.y ?? z みたいに3段4段と重なると読めなくなるんで、その時は一旦変数に受けて分割する方が保守しやすいです。
次に読むべき記事





以上!
同じ DBNull で溶かした経験ある人、けっこう多いと思うんで、どんどんシェア待ってるぜ!!
執筆者
バイブス父さん — 業務 SE 7 年(正社員 2 / フリーランス 5)。現職は SEO 直轄部の AI アドバイザー兼 PL、副業で中小 SIer の CTO。SIer 正社員から独立し、複数のフリーランスエージェント経由で現場を渡り歩いた経験ベースで「業務 SE 視点」の技術 + キャリア記事を書いています。
🐦 X: @hiro_progra0524(日々の現場メモ更新中)
📝 About Me で経歴詳細を見る



