C# Linq で Null を回避する書き方とパフォーマンス(業務SEのコピペで動くやつ)

みなさんこんにちは!ヒロポンです!

今回は業務SE現場でガチで踏みやすいやつ!!の話。

「DataTable から List<T> に積んで Linq でフィルタかけたら、NullReferenceException で落ちた」「Where 書いたのに件数が合わない」「First()FirstOrDefault() を取り違えて夜間バッチが止まった」みたいな、Linq + Null の事故って業務SEなら誰しも一回はやりますよね??

俺も2年目くらいの流通系の基幹システム保守で、DataTable.AsEnumerable().Select(r => r.Field<string>("name").Trim()) みたいなクエリを書いて、name が DBNull の行で実行時例外で全件処理が止まる事故をやらかして、半日デバッガで追った経験があります。

この記事では VS2019 / .NET Framework 4.7.2 / C# 7.3 環境で、Linq + Null の事故を消す 5つのイディオム と、大量データ時の Linq vs for ループの性能比較 を、コード6本でまとめます。後ろの「現場メモ」で、業務系チームでルール化した時の話も書いてる。

3行で結論:

  • 5つのイディオム(Where / OfType<T>() / DefaultIfEmpty / ?? / ?. を場面で使い分ければ Linq + Null は怖くない(Null 排除が主の4つ + 集計時のフォールバック1つ)
  • 大量データ(10万件超) はホットパスだけ for に戻すと2〜3倍速い
  • First()FirstOrDefault() の取り違え が一番事故る、原則 FirstOrDefault() 寄せが安全
目次

イディオム1: Where(x => x != null) で Null を弾く

最基本のやつ。参照型シーケンスから Null を排除するだけならこれが一番速いし読みやすい:

// ✅ Where で Null 排除
var names = users
    .Where(u => u != null)
    .Select(u => u.Name)
    .ToList();

ハマりポイントとして、u.Name が Null になり得る場合は Where の段階で u != null && u.Name != null まで繋ぐ。Null チェックを Select 側で書くと遅延評価で例外位置がズレるので、フィルタは Where に寄せるのが鉄板です。

ん?普通に1行で済むよね??って思うかもだけど、業務系で 4〜5 個プロパティの Null チェックを連鎖させると Where の述語が長くなってツラいので、その時は次の OfType?. 連鎖に切り替えます。

イディオム2: OfType<T>() で Null + 型絞り込みを同時にやる

OfType<T>()「参照型シーケンスから Null を弾きつつ、型 T のものだけ残す」 という二刀流。書き味がいい感じになる:

// ✅ OfType で Null 排除 + 型絞り込み
object[] mixed = { "alice", null, 42, "bob", null, "carol" };
var strings = mixed.OfType<string>().ToList();
// → ["alice", "bob", "carol"]

OfType<T> は内部で is T チェックをかけるので、Null は弾かれる(null is string は false になるため)。object[]IEnumerable<object> から特定の型だけ抜きたい時に強い。

ただし欠点として、内部で is チェックが入る分、純粋に Null だけ弾きたい場面では Where(x => x != null) の方がやや高速です。「Null も弾きたいし型も絞りたい」場合の OfType、「Null だけ弾きたい」場合の Where、で使い分けるといい感じになる。

イディオム3: DefaultIfEmpty() で空シーケンス時のフォールバック

シーケンス全体が空(要素0件)になる場合、AggregateSum 系で例外を出さずにフォールバックさせたい時のやつ:

// ✅ DefaultIfEmpty で空シーケンス時のフォールバック
var prices = invoices
    .Where(i => i.Region == "東京")
    .Select(i => i.Amount)
    .DefaultIfEmpty(0)
    .Sum();
// 該当行ゼロでも 0 が返る(例外なし)

var maxPrice = invoices
    .Where(i => i.Region == "東京")
    .Select(i => i.Amount)
    .DefaultIfEmpty(0)
    .Max();
// 該当行ゼロでも 0 が返る(Max() の InvalidOperationException 回避)

Max() / Min() は空シーケンスで InvalidOperationException を投げるので、空が起こり得るクエリには DefaultIfEmpty(0) を挟むのが定石。Sum() は空でも 0 を返すから問題ないけど、書き方を揃えるために挟むチームもあります。

ただし欠点としてDefaultIfEmpty() を挟むと中間で要素が1個追加されるので、後続で Count() を取る時に意味が変わります。集計用途専用と考えて、件数取得には使わないのが原則っす。

イディオム4: ?? 演算子で Field<T>() の Null を補完

DataTable + Linq の組み合わせで一番よく使うやつ。Field<string>() が DBNull 行で null を返すので、?? で穴埋めする:

// ✅ Field<T> + ?? で Null 補完
var names = dt.AsEnumerable()
    .Select(r => r.Field<string>("name") ?? "(unknown)")
    .ToList();

// 値型の場合は Field<int?> で受けて ?? で穴埋め
var ages = dt.AsEnumerable()
    .Select(r => r.Field<int?>("age") ?? 0)
    .ToList();

業務系の DataTable 処理で、生 SQL から取ってきたデータを Linq でループする時のテンプレ。Field<T>("col") で取ると DBNull が自動で null になるので、後段の ?? がいい感じに効きます。

ただし欠点として、値型(int, DateTime 等)は Field<int> で受けると DBNull で StrongTypingException または InvalidCastException が飛ぶので、Field<int?> のように Null 許容型 で受けてから ?? する。値型を非 Null で受けて落ちる事故、業務SEあるあるです。

イディオム5: ?. 連鎖で null 中継を許容する

オブジェクトのプロパティを掘る時、途中で Null が混じる可能性がある場合の鉄板。C# 6 以降で使える ?. を連鎖させる:

// ✅ ?. 連鎖で Null 中継
var firstItemName = order?.Items?.FirstOrDefault()?.Name ?? "(no item)";

// Linq との組み合わせ
var deliveredCount = orders
    ?.Where(o => o?.Status == "delivered")
    ?.Count() ?? 0;

?. は「左辺が Null なら右辺を評価せず Null を返す」演算子。連鎖で書くと、途中の Null で例外が出ずにそのまま結果が Null になる。最後に ?? でフォールバックを置くのが定石パターンっす。

Where の述語内で o?.Status == "delivered" を書くと、o が Null の時に false 扱いになって自然に弾かれます。Null フィルタを述語に内包できるので、業務系の階層構造データ(OrderItemsProduct 等)を扱う時に効きます。

ただし欠点として?. を多用すると「どこで Null になったか」がスタックトレースで追えなくなる。Null が想定外の場面(プログラムバグで起きる Null)では ?. を入れずに普通に例外を投げさせた方が、原因特定が早いケースもある。「データ起因の Null」と「バグ起因の Null」を切り分けて、前者だけ ?. で許容するのが現場の運用です。

パフォーマンス: 10万件規模で Linq vs for ループ

ここが業務系の本番現場で意外と効くところ。Linq は読みやすさで勝つけど、ホットパスのループ核では for + 手書きが2〜3倍速いケースが普通にあります。

計測条件(数字を出す前に):

  • 環境: VS2019 / .NET Framework 4.7.2 / C# 7.3 / x64 Release ビルド
  • データ: List<Item> 10万件、うち約5%が null、残りは Price > 0
  • 計測: Stopwatch で5回実行した中央値(GC 強制起動を毎回行いウォームアップ後)
  • マシン: 一般的な業務SE現場の開発機相当(具体型番は割愛)

厳密なベンチマークなら BenchmarkDotNet を使うべきですが、業務系の現場で「大体の体感差」を測るなら Stopwatch 5回中央値で十分。本記事の数字も同じ取り方です。

// ✅ 10万件のホットパスで Linq → for に書き換え
// Linq 版(800ms 前後)
var sumLinq = items
    .Where(i => i != null && i.Price > 0)
    .Sum(i => i.Price);

// for 版(280ms 前後)
decimal sumFor = 0;
for (int idx = 0; idx < items.Count; idx++)
{
    var item = items[idx];
    if (item == null) continue;
    if (item.Price <= 0) continue;
    sumFor += item.Price;
}

俺の体感だと、10万件以上のループで Where + Sum を Linq で書くと、for ベタ書きの2〜3倍の時間がかかる。原因は Linq の中間 IEnumerable / イテレータコストと、ラムダ式のクロージャ捕捉。1万件未満なら誤差レベルだから可読性で Linq のままで OK だけど、バッチの心臓部だけ for に切り替えるハイブリッド運用が業務SE現場の現実解です。

業務系の判断軸:

  • 画面側・件数少なめ(〜1万件) → Linq でいい感じに書いて読みやすさ優先
  • バッチ・夜間処理・ホットパス(10万件超) → for ループ + 手書き null チェックに書き換え
  • 計測してから判断 → 思い込みで全部 for にすると保守性が落ちる

ハマりポイント3つ(実体験)

1. First()FirstOrDefault() の取り違えで夜間バッチ停止(30分溶かした)

該当データなしのリージョンで First() を呼んでしまい、InvalidOperationException で夜間バッチが止まった事件。レビューで気づくべきだったやつ。30分溶かした翌朝、First() 全件 grep して FirstOrDefault() に置換するリファクタを入れました。「常に1件以上ある」を保証できない箇所は FirstOrDefault 寄せが鉄則っす。

2. Linq 遅延評価で例外位置がズレた(半日デバッガで追った)

Where で組んだクエリを別関数に渡して、その関数内の ToList()NullReferenceException が飛んだ。スタックトレースが「クエリを組んだ場所」じゃなく「ToList した場所」を指してたので原因特定に半日デバッガで追った。Linq の遅延評価は便利だけど、Null 例外と組み合わさると一発で迷子になります。フィルタは Where に閉じ込めて、Select 側に Null 危険を残さないのがコツ。

3. Single() で複数件マッチして例外(夕方の運用報告で気づいた)

「ID で1件取る」つもりで Single() を書いたが、テスト時に意図せず複数行が引っかかって InvalidOperationException。データ起因の重複だったので、運用フェーズで初めて発覚しました。夕方になって運用部隊から「画面が一部の ID で開けない」って報告が来てようやく気付いた。Single() は「重複を許さない」アサーションのつもりで使う、それ以外は FirstOrDefault()、というルールに揃えました。

著者の現場メモ — 業務系チームでの Linq + Null ルール

流通系の基幹システム保守チームで、過去コードを grep -r "First()" . してチェックしたら、80箇所近く出てきたんですよね。書き方がバラバラで、First() 直書き・FirstOrDefault() ?? null の謎パターン・Where(x => x != null).First() の冗長パターン、全部出てきた。

んで、後輩と一緒に 3行ルール にまとめた:

  1. First() 禁止、原則 FirstOrDefault() 寄せ(重複アサーションで Single() を使う場面以外は全部)
  2. Field<T>() は値型なら Field<int?> で受けて ?? 補完(非 Null 受けで InvalidCastException 禁止)
  3. 10万件超のループ核は for + 手書き null チェック(計測した上で書き換え)

このルール化で、夜間バッチの停止事故が激減した。Linq の便利さを守りつつ、Null 起因の事故ゾーンだけ手書きに寄せるっていう線引きが、業務SE現場の現実解だと思ってます。

まとめ

状況 推奨イディオム
参照型シーケンスから Null 排除 Where(x => x != null)
object シーケンスから Null + 型絞り込み OfType<T>()
集計で空シーケンス回避 DefaultIfEmpty(0)
Field<T>() の DBNull 補完 ?? "(default)" 演算子
階層プロパティの Null 中継 ?. 連鎖
1件取得 FirstOrDefault()First() 禁止)
10万件超ホットパス for ループ + 手書き null チェック

C# Linq の Null 回避は、「読みやすさで Linq、性能で for」 のハイブリッド運用が業務SE現場の現実解。WhereOfType???. の4つを場面で使い分ければ、NullReferenceException の事故はだいぶ減ります。

よくある質問

Q1. Where(x => x != null)OfType<T>() はどっちを使うべき?

A. 参照型のシーケンスから Null を弾くだけなら Where のほうが軽量です。OfType は内部で is チェックを行うため、Null 排除と同時に型絞り込みもしたい時(例えば object シーケンスから string だけ抜き出す)に効きます。両方とも書き味は良いので、用途に合わせて選んでください。

Q2. First()FirstOrDefault() を取り違えたら何が起きる?

A. First() は要素ゼロなら InvalidOperationException を投げます。FirstOrDefault() は要素ゼロならデフォルト値(参照型なら null、値型なら 0)を返します。「1件以上ある」と保証できる場面以外は FirstOrDefault のほうが事故りにくい。Single() / SingleOrDefault() も同じ系統です。

Q3. Linq の遅延評価で Null 例外が出る場所がズレるって本当?

A. 本当です。WhereSelect で組み立てたクエリは ToList() / ToArray() / foreach の評価時にしか走らないので、Null 参照が起きるのは「クエリを組んだ場所」じゃなく「結果を使う場所」になります。スタックトレースの起点が直感とズレるので、Linq でハマったら遅延評価をまず疑ってください。

Q4. .NET Framework 4.7.2 で Nullable Reference Types は使えますか?

A. 使えません。Nullable Reference Types(C# 8 機能)は .NET Standard 2.1 / .NET Core 3.0 以降が前提で、.NET Framework 4.7.2 + C# 7.3 では構文エラーになります。業務系の保守現場では従来通り Where + OfType + ?? + ?. の組み合わせで運用するのが現実的です。

Q5. Linq と for ループ、性能的にはどっちが速いですか?

A. 10万件規模を超えると for + 手書き null チェックが Linq より2〜3倍速いことがあります。Linq は読みやすさ重視で、ホットパスやバッチ処理のループ核には for を使うのが業務系の判断軸。1万件未満なら誤差レベルなので、可読性優先で Linq のままで OK です。

ここまでで C# Linq + Null の主要イディオムと性能・ハマりどころは押さえた。DataTable / DBNull 周りの隣接トピックも貼っておきます。

関連記事

以上!

同じ罠でハマってる業務SE仲間いたら、どんどんシェア待ってるぜ!!


この記事が気に入ったら
いいねしてね!

どんどんシェア待ってるぜ!!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次