C#のLINQ(Language Integrated Query)は、コレクション操作を宣言的に記述できる強力な機能だ。SQLライクな構文でデータのフィルタリング、変換、集計を行える点は、C#の最大の強みの一つと言っても過言ではない。しかし、LINQの内部動作を正しく理解していないと、パフォーマンス上の深刻な問題を引き起こすことがあります。
筆者は過去に、LINQの誤用が原因で本番環境のレスポンスタイムが10倍に悪化したケースを経験した。原因は遅延評価の仕組みを理解せずに、同じクエリを複数回列挙していたことだった。本記事では、LINQの遅延評価メカニズムを深く理解し、パフォーマンスを最大化するための実践的なテクニックを紹介します。
LINQのメソッドは大きく分けて「遅延評価」と「即時評価」の2種類に分類されます。この区別を正確に把握することが、LINQを使いこなす第一歩だ。
// 遅延評価の例:Whereはイテレーション時に初めて実行される
var numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// この時点では何も実行されない
var evenNumbers = numbers.Where(n => n % 2 == 0);
// リストに要素を追加
numbers.Add(12);
// ここで初めてフィルタリングが実行される(12も含まれる)
foreach (var n in evenNumbers)
{
Console.WriteLine(n); // 2, 4, 6, 8, 10, 12
}
// 即時評価メソッドの例
var list = numbers.Where(n => n > 5).ToList(); // 即時評価
var array = numbers.Where(n => n > 5).ToArray(); // 即時評価
var count = numbers.Count(n => n > 5); // 即時評価
var first = numbers.First(n => n > 5); // 即時評価
var sum = numbers.Where(n => n > 5).Sum(); // 即時評価
遅延評価の最も危険な落とし穴は、同じIEnumerableを複数回列挙してしまうケースだ。データソースがデータベースやAPIの場合、列挙するたびにクエリが再実行されます。
// 悪い例:多重列挙が発生する
public void ProcessData(IEnumerable<Order> orders)
{
// 1回目の列挙
if (!orders.Any())
return;
// 2回目の列挙
var totalAmount = orders.Sum(o => o.Amount);
// 3回目の列挙
var averageAmount = orders.Average(o => o.Amount);
Console.WriteLine($"合計: {totalAmount}, 平均: {averageAmount}");
}
// 良い例:一度だけマテリアライズする
public void ProcessData(IEnumerable<Order> orders)
{
var orderList = orders.ToList(); // ここで1回だけ列挙
if (orderList.Count == 0)
return;
var totalAmount = orderList.Sum(o => o.Amount);
var averageAmount = orderList.Average(o => o.Amount);
Console.WriteLine($"合計: {totalAmount}, 平均: {averageAmount}");
}
LINQのメソッドチェーンの順序は、パフォーマンスに大きく影響します。フィルタリングは変換より先に行うのが鉄則だ。
var products = GetAllProducts(); // 10万件のデータ
// 悪い例:全件に対してSelectを実行してからフィルタリング
var result1 = products
.Select(p => new ProductDto {
Name = p.Name,
Price = p.Price,
Category = GetCategoryName(p.CategoryId) // 重い処理
})
.Where(dto => dto.Price > 1000)
.Take(10)
.ToList();
// 良い例:先にフィルタリングしてからSelect
var result2 = products
.Where(p => p.Price > 1000)
.Take(10)
.Select(p => new ProductDto {
Name = p.Name,
Price = p.Price,
Category = GetCategoryName(p.CategoryId)
})
.ToList();
Containsを使ったフィルタリングでは、データソースのコレクション型がパフォーマンスを大きく左右します。リストでの検索はO(n)だが、HashSetではO(1)で済む。
var targetIds = new List<int> { 1, 5, 10, 15, 20 };
var allItems = GetLargeDataSet(); // 100万件
// 悪い例:Listに対するContainsはO(n)
var filtered1 = allItems
.Where(item => targetIds.Contains(item.Id))
.ToList();
// 良い例:HashSetに変換してO(1)で検索
var targetIdSet = new HashSet<int>(targetIds);
var filtered2 = allItems
.Where(item => targetIdSet.Contains(item.Id))
.ToList();
LINQにはメソッド構文とクエリ構文の2つの書き方があります。筆者の経験では、単純なフィルタリングや変換にはメソッド構文、複数のデータソースをJoinする場合やlet句を使う場合にはクエリ構文が可読性に優れます。
// メソッド構文:シンプルな操作に向いている
var result = orders
.Where(o => o.Status == OrderStatus.Completed)
.OrderByDescending(o => o.OrderDate)
.Select(o => o.CustomerName)
.Distinct()
.ToList();
// クエリ構文:複雑なJoinやlet句を使う場合に向いている
var result2 = (
from o in orders
join c in customers on o.CustomerId equals c.Id
let discountedPrice = o.TotalPrice * (1 - c.DiscountRate)
where discountedPrice > 5000
orderby discountedPrice descending
select new { c.Name, o.OrderDate, DiscountedPrice = discountedPrice }
).ToList();
LINQはC#開発者にとって最も強力なツールの一つだが、その威力を最大限発揮するには内部メカニズムの理解が不可欠です。遅延評価の仕組みを正確に把握し、多重列挙を避け、メソッドチェーンの順序を最適化します。これらの基本を押さえるだけで、LINQのパフォーマンスは劇的に向上します。パフォーマンスが気になる箇所ではBenchmarkDotNetを使った計測を習慣にすることを強く推奨します。