みなさんこんにちは!ヒロポンです!
今回は業務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件)になる場合、Aggregate や Sum 系で例外を出さずにフォールバックさせたい時のやつ:
// ✅ 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 フィルタを述語に内包できるので、業務系の階層構造データ(Order → Items → Product 等)を扱う時に効きます。
ただし欠点として、?. を多用すると「どこで 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を使うべきですが、業務系の現場で「大体の体感差」を測るならStopwatch5回中央値で十分。本記事の数字も同じ取り方です。
// ✅ 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行ルール にまとめた:
First()禁止、原則FirstOrDefault()寄せ(重複アサーションでSingle()を使う場面以外は全部)Field<T>()は値型ならField<int?>で受けて??補完(非 Null 受けでInvalidCastException禁止)- 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現場の現実解。Where と OfType と ?? と ?. の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. 本当です。Where や Select で組み立てたクエリは 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 周りの隣接トピックも貼っておきます。
関連記事
- C# でリストの重複を一意にする3つの書き方(Distinct / GroupBy / HashSet) — Linq の集計・絞り込みで
Distinctを使う時に効く - C# DataAdapter.Update() で DBNull 例外が出た時の最短対処 —
DataAdapterでField<T>の Null 補完が必要になった時に効く - C# WinForms の Form.ShowDialog と Form.Show の違いと使い分け完全ガイド — Linq 結果を画面側に渡す前段の WinForms 制御を整える時に効く
以上!
同じ罠でハマってる業務SE仲間いたら、どんどんシェア待ってるぜ!!


コメント