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, 3 — ToList() «фотографирует» результат в момент вызова. Вся разница между двумя ответами — в одном вызове. Именно на этом и строится вопрос.
Ловушка многократного перечисления
Второе следствие лени куда неприятнее: каждое перечисление запускает запрос заново. Посчитаем, сколько раз реально вызывается предикат.
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()— материализация: превращает рецепт в данные. Один раз и в нужном месте.