Каким образом обеспечивается отложенное выполнение (deferred execution) при работе с источниками данных в C#?

«Каким образом обеспечивается отложенное выполнение (deferred execution) при работе с источниками данных в C#?» — вопрос из категории C# Core, который задают на 25% собеседований C# Разработчик. Ниже — развёрнутый ответ с разбором ключевых моментов.

Ответ

Отложенное выполнение — это фундаментальная концепция LINQ, при которой запрос не выполняется в момент его объявления, а лишь определяется. Выполнение откладывается до момента фактического перечисления результатов (итерирования). Это достигается за счёт использования интерфейсов IEnumerable<T> и IQueryable<T> и оператора yield return.

Ключевые моменты:

  1. IEnumerable<T> (для объектов в памяти): Запросы LINQ to Objects (Where, Select, OrderBy) строят цепочку итераторов. Каждый элемент вычисляется "лениво" в момент обращения к нему в цикле foreach или при вызове материализующего метода.

    var numbers = new List<int> { 1, 2, 3, 4, 5 };
    // 1. ОПРЕДЕЛЕНИЕ ЗАПРОСА (выполнения нет)
    var query = numbers.Where(n =>
    {
        Console.WriteLine($"Проверяем число {n}");
        return n % 2 == 0;
    }).Select(n => n * 10);
    
    Console.WriteLine("Запрос определён.");
    // 2. ВЫПОЛНЕНИЕ при перечислении
    foreach (var item in query) // Здесь начнётся фильтрация и проекция!
    {
        Console.WriteLine($"Результат: {item}");
    }
    // Вывод:
    // Запрос определён.
    // Проверяем число 1
    // Проверяем число 2
    // Результат: 20
    // Проверяем число 3
    // Проверяем число 4
    // Результат: 40
    // Проверяем число 5
  2. IQueryable<T> (для внешних источников, например, БД через EF Core): Запрос не выполняется в памяти приложения, а транслируется в выражение (Expression Tree). Это выражение преобразуется в SQL (или другой язык запросов) и выполняется на стороне сервера БД только при материализации.

    // EF Core пример
    var dbQuery = dbContext.Users
                        .Where(u => u.IsActive) // Строится Expression Tree
                        .OrderBy(u => u.Name); // Всё ещё не выполняется
    
    // ВЫПОЛНЕНИЕ происходит здесь:
    var activeUsersList = await dbQuery.ToListAsync(); // Генерируется и выполняется SQL: SELECT * FROM Users WHERE IsActive = 1 ORDER BY Name
  3. Материализация (принудительное выполнение): Методы, которые возвращают не IEnumerable/IQueryable, а конкретную коллекцию или скалярное значение, вызывают немедленное выполнение запроса:

    • ToList(), ToArray(), ToDictionary()
    • Count(), First(), Single(), Max(), Any()
    • foreach цикл.

Преимущества отложенного выполнения:

  • Эффективность: Данные обрабатываются "по требованию", можно работать с бесконечными последовательностями.
  • Актуальность: При каждом перечислении запрос выполняется заново, получая свежие данные из источника.
  • Композиция: Запросы можно динамически строить и комбинировать перед выполнением.

Как это работает внутри? Методы-расширения LINQ, такие как Where, используют yield return для создания итератора:

public static IEnumerable<T> MyWhere<T>(this IEnumerable<T> source, Func<T, bool> predicate)
{
    foreach (T item in source)
    {
        if (predicate(item))
        {
            yield return item; // Выполнение прерывается здесь и возобновляется при следующем MoveNext()
        }
    }
}