LINQ и отложенное выполнение

«Что выведет этот код?» — интервьюер показывает четыре строчки с Where, и половина кандидатов отвечает неверно. Всё дело в том, что LINQ-запрос — это не результат, а рецепт.

Отложенное выполнение (deferred execution) — LINQ-оператор вроде Where или Select не считает ничего сразу. Он возвращает объект-запрос, который начнёт перебирать источник только тогда, когда его перечислят: в foreach, в ToList(), Count(), Sum(), First().

Классический вопрос: что напечатается?

using System;
using System.Collections.Generic;
using System.Linq;

class Program
{
    static void Main()
    {
        var numbers = new List<int> { 1, 2, 3 };

        var query = numbers.Where(n => n > 1);   // запрос создан

        numbers.Add(4);                          // источник изменился

        Console.WriteLine(string.Join(", ", query));
    }
}

Результат:

2, 3, 4

Четвёрка добавилась после создания запроса — и всё равно попала в вывод. Потому что query не хранит результат. Он хранит ссылку на numbers и предикат. Реальный перебор начался внутри string.Join, когда список уже содержал четыре элемента.

Если бы мы написали var query = numbers.Where(n => n > 1).ToList();, вывод был бы 2, 3ToList() «фотографирует» результат в момент вызова. Вся разница между двумя ответами — в одном вызове. Именно на этом и строится вопрос.

Ловушка многократного перечисления

Второе следствие лени куда неприятнее: каждое перечисление запускает запрос заново. Посчитаем, сколько раз реально вызывается предикат.

using System;
using System.Linq;

class Program
{
    static int calls = 0;

    static bool IsEven(int n)
    {
        calls++;
        return n % 2 == 0;
    }

    static void Main()
    {
        var evens = Enumerable.Range(1, 7).Where(IsEven);

        Console.WriteLine($"Запрос создан. Вызовов предиката: {calls}");
        Console.WriteLine($"Count = {evens.Count()}, вызовов: {calls}");
        Console.WriteLine($"Sum   = {evens.Sum()},  вызовов: {calls}");

        var materialized = evens.ToList();
        Console.WriteLine($"После ToList() вызовов: {calls}");
        Console.WriteLine($"Count по списку = {materialized.Count}, вызовов: {calls}");
    }
}

Результат:

Запрос создан. Вызовов предиката: 0
Count = 3, вызовов: 7
Sum   = 12,  вызовов: 14
После ToList() вызовов: 21
Count по списку = 3, вызовов: 21

Смотрите: на создание запроса — ноль вызовов. Каждый Count() и Sum() прогоняет источник целиком, добавляя по семь вызовов. А после ToList() счётчик замирает — список это уже данные, а не рецепт.

С предикатом-инкрементом это выглядит забавно. В боевом коде на месте IsEven оказывается запрос к базе, HTTP-вызов или чтение файла — и «безобидный» код вида «сначала проверим query.Any(), потом посчитаем query.Count(), потом пройдёмся foreach» тихо делает три похода в базу вместо одного. Именно это и подсвечивает ReSharper предупреждением Possible multiple enumeration of IEnumerable.

Как это работает под капотом: yield return

Магии нет — есть итераторы. Любой метод с yield return компилятор превращает в скрытый класс — конечный автомат с состоянием и методом MoveNext(). Вызов метода лишь создаёт этот объект; тело начинает исполняться при первом MoveNext(), то есть при первой итерации foreach. Напишем свой Where и убедимся:

using System;
using System.Collections.Generic;

class Program
{
    static IEnumerable<int> MyWhere(IEnumerable<int> source, Func<int, bool> predicate)
    {
        Console.WriteLine("  [MyWhere] тело метода наконец-то запустилось");
        foreach (var item in source)
        {
            Console.WriteLine($"  [MyWhere] проверяю {item}");
            if (predicate(item))
                yield return item;
        }
    }

    static void Main()
    {
        var query = MyWhere(new[] { 1, 2, 3, 4 }, n => n % 2 == 0);
        Console.WriteLine("Запрос создан, но ничего ещё не посчитано");

        foreach (var x in query)
            Console.WriteLine($"Получили {x}");
    }
}

Результат:

Запрос создан, но ничего ещё не посчитано
  [MyWhere] тело метода наконец-то запустилось
  [MyWhere] проверяю 1
  [MyWhere] проверяю 2
Получили 2
  [MyWhere] проверяю 3
  [MyWhere] проверяю 4
Получили 4

Обратите внимание на чередование строк: элементы текут через конвейер по одному, а не пачкой. Это называется streaming (потоковая обработка). Благодаря ей collection.Where(...).First() остановится на первом подходящем элементе и не станет перебирать миллион оставшихся. И благодаря ей же Enumerable.Range(1, int.MaxValue).Where(...).Take(5) отработает мгновенно.

Три вида операторов — их важно различать

ТипОператорыПоведение
Отложенные, потоковыеWhere, Select, Take, Skip, Concatтянут элементы по одному, память O(1)
Отложенные, буферизующиеOrderBy, GroupBy, Reverse, Distinctждут первого запроса, но затем читают весь источник целиком
НемедленныеToList, ToArray, ToDictionary, Count, Sum, First, Anyвыполняют запрос прямо сейчас и возвращают значение

Вопрос-подкол, который любят задавать: «Является ли OrderBy отложенным?» Да, является — но потоковым он быть не может: чтобы отдать первый элемент отсортированной последовательности, нужно прочитать все.

Частые ошибки на собеседовании

  • «Запрос выполнится на строке с Where». Нет. Он выполнится там, где вы его перечислите.
  • Многократное перечисление. Если IEnumerable используется в коде больше одного раза — материализуйте его: var items = query.ToList();.
  • ToList() на каждый чих. Обратная крайность: .Where(...).ToList().Where(...).ToList() ломает потоковость, тянет в память лишнее и обычно означает, что человек «лечит» непонятый им симптом.
  • Вернуть IEnumerable из метода, где открывался ресурс. Метод с using (var connection = ...), возвращающий ленивый запрос, отдаст наружу рецепт, а соединение к моменту перечисления уже закроется — получите ObjectDisposedException. Лечится вызовом ToList() внутри using.
  • Изменять коллекцию во время перечисления. Добавить в источник до перебора можно (первый пример), а вот Add внутри foreach по этому же списку выбросит InvalidOperationException: «Collection was modified».

Итоги-шпаргалка

  • LINQ-запрос — это объект-рецепт, а не результат. Выполнение начинается при перечислении.
  • Изменения источника, сделанные после построения запроса, попадут в результат.
  • Каждое перечисление — новый прогон. Два Count() подряд = два прохода (и два похода в базу).
  • Механика — итераторы: yield return компилируется в конечный автомат с MoveNext().
  • Where/Select — потоковые; OrderBy/GroupBy — буферизующие; ToList/Count/First — немедленные.
  • ToList() — материализация: превращает рецепт в данные. Один раз и в нужном месте.
Проверьте себя
1. var q = list.Where(x => x > 1); затем list.Add(10); затем foreach по q. Попадёт ли 10 в результат?
AДа: запрос выполняется в момент foreach и видит уже обновлённый список
BНет: Where скопировал элементы в момент создания запроса
CНет: будет выброшено InvalidOperationException, потому что коллекция изменилась
DЗависит от того, объявлен ли q как IEnumerable или как var
2. Какой из операторов НЕ является отложенным, то есть выполняет запрос немедленно?
ASelect
BOrderBy
CCount
DTake