C# の LINQ パフォーマンスの注意点

LINQは便利だが、使い方を誤るとパフォーマンスが大きく低下することがある。ここでは注意すべきポイントと対策を解説する。

複数回の列挙を避ける

遅延評価のクエリを複数回列挙すると、その都度クエリが実行される。

var numbers = Enumerable.Range(1, 1000);

var query = numbers.Where(n => 
{
    Console.WriteLine($"Checking {n}");  // 1000回 × 2 = 2000回呼ばれる
    return n % 2 == 0;
});

int count = query.Count();    // 1回目の列挙
var list = query.ToList();    // 2回目の列挙

解決策は、一度 ToList()ToArray() で結果を確定させること。

var query = numbers.Where(n => n % 2 == 0).ToList();  // 1回だけ列挙

int count = query.Count;   // List.Count プロパティ
var list = query;          // すでにリスト

Count() vs Any()

要素の存在確認には Count() > 0 より Any() を使う。

Count() > 0

全要素を数える。O(n)。

Any()

最初の要素で即座に true を返す。O(1)。

var largeList = Enumerable.Range(1, 1000000);

// 悪い例:100万回ループ
bool hasElements1 = largeList.Count() > 0;

// 良い例:1回で終わる
bool hasElements2 = largeList.Any();

Where と FirstOrDefault の順序

検索条件を Where + First に分けると非効率になることがある。

var people = GetLargePeopleList();

// やや非効率
var alice1 = people.Where(p => p.Name == "Alice").FirstOrDefault();

// より効率的
var alice2 = people.FirstOrDefault(p => p.Name == "Alice");

実際にはどちらも遅延評価で同様に動作するが、後者の方が意図が明確で、将来的な最適化の恩恵を受けやすい。

ToList の適切なタイミング

ToList() を早すぎる段階で呼ぶと、中間結果がすべてメモリに載る。

// 悪い例:100万件のリストを作ってからフィルタ
var result1 = data.ToList().Where(x => x.IsActive).Take(10);

// 良い例:フィルタしてから必要な分だけ取得
var result2 = data.Where(x => x.IsActive).Take(10).ToList();

不要な OrderBy

結果の並び順が重要でない場合、OrderBy は省略する。

// 悪い例:並べ替え不要なのにソート
bool hasAdmin = users.OrderBy(u => u.Name).Any(u => u.Role == "Admin");

// 良い例:ソートなし
bool hasAdmin = users.Any(u => u.Role == "Admin");

ソートは O(n log n) のコストがかかる。

Contains vs HashSet

大量の値との照合には HashSet を使う。

var targetIds = new List<int> { 1, 2, 3, /* ... 1000個 */ };
var users = GetUsers();  // 10000件

// 悪い例:O(n × m) = 1000万回の比較
var result1 = users.Where(u => targetIds.Contains(u.Id));

// 良い例:O(n) = 1万回の比較
var targetIdSet = new HashSet<int>(targetIds);
var result2 = users.Where(u => targetIdSet.Contains(u.Id));

List.Contains は O(n)、HashSet.Contains は O(1) だ。

GroupBy の後の検索

GroupBy の結果を何度も検索するなら、ToDictionary に変換する。

var orders = GetOrders();

// 悪い例:顧客IDで検索するたびにグループを走査
var grouped = orders.GroupBy(o => o.CustomerId);
var customer1Orders = grouped.FirstOrDefault(g => g.Key == 1);

// 良い例:Dictionaryで O(1) 検索
var ordersByCustomer = orders.GroupBy(o => o.CustomerId)
                             .ToDictionary(g => g.Key, g => g.ToList());
var customer1Orders = ordersByCustomer.GetValueOrDefault(1);

Select での重い処理

Select 内で重い処理をすると、列挙のたびに実行される。

var items = GetItems();

// 悪い例:SelectでAPI呼び出し
var enriched = items.Select(item => 
{
    var detail = FetchDetailFromApi(item.Id);  // 毎回API呼び出し
    return new { item, detail };
});

// enriched を2回列挙するとAPIも2回呼ばれる

API呼び出しのような副作用のある処理は、事前に実行して結果をキャッシュすべきだ。

メモリ効率

大量データを扱う場合、すべてをメモリに載せないようにする。

// 悪い例:100万件を一度にメモリに載せる
var allData = File.ReadAllLines("huge.csv")
    .Select(ParseLine)
    .ToList();

// 良い例:ストリーミングで処理
var result = File.ReadLines("huge.csv")
    .Select(ParseLine)
    .Where(x => x.IsValid)
    .Take(100);

ReadLines は遅延評価で1行ずつ読み込む。ReadAllLines は全行を一度に読み込む。

パフォーマンス計測

最適化の前に、実際にボトルネックを特定することが重要だ。

var sw = Stopwatch.StartNew();

var result = expensiveQuery.ToList();

sw.Stop();
Console.WriteLine($"実行時間: {sw.ElapsedMilliseconds}ms");

推測で最適化するより、計測して問題箇所を特定してから対処する方が効果的だ。過度な最適化は可読性を損なうので、必要な場合にのみ行うべきである。