C# の LINQ 遅延評価の仕組み

LINQ の重要な特徴の一つが遅延評価(Deferred Execution)だ。クエリを定義した時点では実行されず、結果を実際に使うときに初めて実行される。

遅延評価の基本

次のコードを見てみよう。

int[] numbers = { 1, 2, 3, 4, 5 };

// この時点ではまだ実行されない
var query = numbers.Where(n => {
    Console.WriteLine($"Checking {n}");
    return n > 2;
});

Console.WriteLine("Query defined");

// ここで初めて実行される
foreach (var n in query)
{
    Console.WriteLine($"Result: {n}");
}

出力結果は以下のようになる。

Query defined
Checking 1
Checking 2
Checking 3
Result: 3
Checking 4
Result: 4
Checking 5
Result: 5

「Query defined」が最初に出力されていることから、Where を呼んだ時点では何も実行されていないことがわかる。foreach でループを回して初めて、各要素のチェックが行われる。

なぜ遅延評価なのか

遅延評価にはいくつかのメリットがある。

メモリ効率

中間結果を保持しない。巨大なデータでも、必要な要素だけを順次処理できる。

柔軟性

クエリを定義してから実行までの間に、元のデータを変更できる。クエリは常に最新のデータに対して実行される。

後者の性質は注意が必要でもある。

var list = new List<int> { 1, 2, 3 };

var query = list.Where(n => n > 1);

list.Add(4);  // クエリ定義後にデータを追加

foreach (var n in query)
{
    Console.WriteLine(n);  // 2, 3, 4(4も含まれる)
}

クエリ定義後に追加した 4 も結果に含まれる。これは意図した動作かもしれないし、バグの原因になるかもしれない。

即時評価に変換する

遅延評価を避けたい場合は、ToList()ToArray() を使って即時評価に変換できる。

var list = new List<int> { 1, 2, 3 };

// ToList() でその時点の結果を確定
var results = list.Where(n => n > 1).ToList();

list.Add(4);

foreach (var n in results)
{
    Console.WriteLine(n);  // 2, 3(4は含まれない)
}

ToList() を呼んだ時点でクエリが実行され、結果が List<int> として確定する。その後に元のデータを変更しても、結果には影響しない。

即時評価されるメソッド

一部の LINQ メソッドは即時評価される。

ToList(), ToArray(), ToDictionary()
Count(), Sum(), Average(), Max(), Min()
First(), Last(), Single()
Any(), All(), Contains()

これらのメソッドは呼び出した時点で結果を計算する必要があるため、遅延評価できない。

int[] numbers = { 1, 2, 3, 4, 5 };

// 即時評価:この時点で配列全体を走査
int count = numbers.Where(n => n > 2).Count();

複数回の列挙に注意

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

int[] numbers = { 1, 2, 3, 4, 5 };

var query = numbers.Where(n => {
    Console.WriteLine($"Checking {n}");
    return n > 2;
});

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

// 2回目の列挙(また最初から実行される)
var list = query.ToList();

パフォーマンスが重要な場面では、一度 ToList() で確定させてから複数回使うべきだ。遅延評価は強力だが、その動作を理解していないとバグや性能問題の原因になる。