みなさんこんにちは!ヒロポンです!
今回は業務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仲間いたら、どんどんシェア待ってるぜ!!
執筆者
バイブス父さん — 業務 SE 7 年 (正社員 2 / フリーランス 5)。 現職は SEO 直轄部の AI アドバイザー兼 PL、 副業で中小 SIer の CTO。 SES 複数社・フリーランスエージェント複数経由の経験ベースで「業務 SE 視点」 の技術 + キャリア記事を書いています。
🐦 X: @hiro_progra0524 (日々の現場メモ更新中)
📝 About Me で経歴詳細を見る


コメント