Перейти к содержимому
Теория · Лямбды и LINQ · урок 9 из 10

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

Конвейер из прошлых уроков устроен хитрее, чем кажется. Пока в конце ленты никто не берёт коробку, он стоит. Протянули руку — лента сдвинулась ровно на одну коробку, и все станции обработали только её.

Запрос LINQ работает так же. Строка с Where или Select ничего не вычисляет: она только собирает конвейер. Посмотрите, что из этого следует:

C#
List<int> nums = [1, 2, 3];
var big = nums.Where(n => n > 1);
nums.Add(4);
Console.WriteLine(string.Join(" ", big)); // 2 3 4

Число 4 добавили уже после строки с Where, а запрос его увидел. Когда выполнялась строка с Where, никакого отбора не было: запрос запомнил список и лямбду. Отбор случился в последней строке, когда string.Join начал перебирать запрос.

Запрос выполняется при переборе

Такое поведение называется отложенным выполнением: запрос LINQ выполняется не там, где написан, а когда его результат перебирают. Запрос перебирается в foreach, в string.Join, в методах вроде Count() и First(), а ещё в ToList() и ToArray().

В программе ниже лямбда для Select печатает строку при каждом вызове. Стрелка → nums.Select(…) у doubled значит, что переменная ведёт к запросу, а не к готовым числам. Нажимайте «Следующий шаг» и следите, когда появляются строки Select.

ВживуюКогда работает Selectшаг 1 из 13 · рамок: 1

Main создаёт список из двух чисел.

Лямбда выполняется только тогда, когда foreach просит следующий элемент

Пошаговый прогон показывает три вещи:

  • Query ready печатается раньше Select 1, Select 2 и Select 3: строка с Select лямбду не вызывала;
  • элементы идут по одному: лямбда обработала 1, foreach напечатал 2, и только потом очередь дошла до второго элемента;
  • число 3, добавленное после запроса, тоже прошло через лямбду: к моменту перебора оно уже лежало в списке.

Каждый перебор — заново

Запрос не запоминает свой результат. При каждом переборе он заново проходит по списку и заново вызывает лямбды. Если в программе выше перебрать doubled вторым foreach, строки Select 1, Select 2 и Select 3 напечатаются ещё раз.

Здесь аналогия с конвейером заканчивается. Коробка, снятая с ленты, уходит навсегда. А запрос можно перебрать сколько угодно раз, и каждый раз конвейер запустится с начала.

Снимок через ToList

Чтобы закрепить результат, запрос выполняют сразу:

C#
List<int> nums = [1, 2, 3];
var big = nums.Where(n => n > 1);
List<int> snapshot = big.ToList();
nums.Add(4);
Console.WriteLine(string.Join(" ", big));      // 2 3 4
Console.WriteLine(string.Join(" ", snapshot)); // 2 3

ToList() перебирает запрос сразу и складывает результат в новый список. Это снимок: после nums.Add(4) запрос big видит 4, а snapshot остался прежним.

Вызывайте ToList() или ToArray(), когда:

  • нужен результат на этот момент, а исходный список потом изменится;
  • результат придётся перебрать несколько раз, а лямбды не должны выполняться заново.

Отложенное выполнение на собеседовании

Этот вопрос часто задают на собеседовании: когда выполняется запрос LINQ? Ответ из двух частей:

  • Where, Select и OrderBy только строят запрос, а выполняется он при переборе;
  • Count, Sum, First, Any, ToList и ToArray сразу начинают перебор, поэтому запрос выполняется в той же строке.

Отложенное выполнение — последняя тема курса. Потренироваться отвечать на такие вопросы можно в симуляторе собеседований IT Abyss.

Частая ошибка

Список меняли внутри foreach, который перебирает запрос по этому же списку:

C#
List<int> nums = [1, 2, 3];
foreach (int n in nums.Where(x => x > 1))
{
    nums.Add(n * 10); // ✗
}
TEXT
Unhandled exception. System.InvalidOperationException: Collection was modified; enumeration operation may not execute.

Программа компилируется, но останавливается с исключением. Запрос отложенный, поэтому Where читает nums прямо во время цикла, а список изменили посреди перебора. Это та же ошибка, что возникает, если менять список в foreach по нему самому.

Исправление — снимок: foreach (int n in nums.Where(x => x > 1).ToList()). Цикл перебирает готовый список, и в nums без ошибки добавляются 20 и 30.

Главное

  • Запрос LINQ выполняется не там, где написан, а когда его результат перебирают.
  • Запрос видит список таким, каким тот стал к моменту перебора, и при каждом переборе выполняется заново.
  • ToList() и ToArray() выполняют запрос сразу и сохраняют снимок, который не меняется вслед за списком.
  • Менять список, пока по нему перебирается запрос, нельзя: программа остановится с исключением InvalidOperationException.