動くコード図鑑技術記事現場の渡り方キャリア論すべての記事About
技術記事

C# の ?? / ?. / ??= 演算子 使い分けガイド(DBNull・Nullable 値型で事故らない書き方)

バイブス父さん
現役の業務SE
2026年7月10日15 min read
C# の ?? / ?. / ??= 演算子 使い分けガイド(DBNull・Nullable 値型で事故らない書き方)

「あれ、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 の時だけ代入する(初期化に使う)

で、DataAdapterDataRow から拾った値が DB 上で NULL だった場合。

これは null じゃなくて DBNull.Value っていう別のオブジェクトになってます。だから上の3つは全部「null じゃない」と判断してスルーする。

ここだけは演算子じゃ潰せない。そう覚えておくと事故りません。

表にするとこんな感じで整理できます。

C# の ?? / ?. / ??= 3演算子を、機能・導入バージョン・C# 7.3 対応・DBNull を潰せるか・主な用途で比較した表

「DBNull を潰せる」の行が全部 × なの、地味に大事なんで頭の隅に置いといてください。

なぜ ?? が DBNull を潰せないのか

理由はシンプル。DBNull.Valuenull じゃないからです。

SqlDataAdapterDataTable を埋めて、row["price"] みたいに列を取る。DB 側が NULL の場合は System.DBNull 型のシングルトン(DBNull.Value)が返ります。

これは「値が無い」ことを表すれっきとしたオブジェクトであって、参照としては null じゃない。

?? は左辺が null かどうかしか見てない。だから DBNull.Value は「null じゃない、中身あるやん」と判断してそのまま返す。

結果、(string)row["name"] ?? "(未設定)" みたいに書いても既定値に化けてくれない、という現象になります。

冒頭の後輩が踏んでたのもこれ。「null チェックしたのに効かない」の正体はだいたい DBNull です。対処は後半のハマりポイントで書きます。

最短対処: コピペで動く3つの書き方

まずは if で書いてた null チェックを演算子に置き換えるとこから。before / after で見た方が早いです。

if で customer と Name を二重に null チェックしていたコードを、?. と ?? で1行にまとめる 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"];

実行結果:

DBNull.Value は null ではなく、row.Field<string>() で受けると null に変換されて ?? が効くことを実測した実行結果

Field<T>() を通すと DBNull の存在をほぼ意識せず、いい感じに ?? で書ける。DataAdapter を触る現場ならこれを標準にしとくと事故が減ります。

DBNull 側のハンドリングをもっと深掘りしたい人は SQL Server の DBNull を C# で安全にハンドリングする5つのイディオム に別パターンをまとめてます。別タブで開いて後で読んでくださいな。

ハマり2: ??= は C# 8.0。7.3 現場だと CS8370 で怒られる

??= を書いたら、いきなりこう怒られたことないですか??

CS8370: 機能 'Null 合体割り当て' は C# 7.3 では使用できません。
言語バージョン 8.0 以上を使用してください。

エラー再現:

??= を LangVersion 7.3 でビルドすると CS8370 が出ることを実機で再現したエラー画面

これは ??= が 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 に暗黙変換できない

エラー再現:

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 か旧構文で

そして DataAdapterDBNull.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 で経歴詳細を見る


この記事のコードと手順は ぜんぶ動作検証済み。 安心して現場で試してくれ。
バイブス父さん

現役の業務SE。C# / SQL Server 保守の現場から、コードも人もキャリアも全部書く。 実体験ベース。

運営者について