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仲間いたら、どんどんシェア待ってるぜ!!

執筆者

バイブス父さん — 業務 SE 7 年 (正社員 2 / フリーランス 5)。 現職は SEO 直轄部の AI アドバイザー兼 PL、 副業で中小 SIer の CTO。 SES 複数社・フリーランスエージェント複数経由の経験ベースで「業務 SE 視点」 の技術 + キャリア記事を書いています。

🐦 X: @hiro_progra0524 (日々の現場メモ更新中)
📝 About Me で経歴詳細を見る

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

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

コメント

コメントする

CAPTCHA


目次