.NET Tutorial
Middle6 мин чтенияВопрос 8 из 25

Как работает LINQ: отложенное выполнение, деревья выражений?

Отложенное выполнение на примере

C#
var numbers = new List<int> { 1, 2, 3, 4, 5 };

var query = numbers
    .Where(n => { Console.WriteLine($"Where {n}"); return n % 2 == 1; })
    .Select(n => { Console.WriteLine($"Select {n}"); return n * 10; });

Console.WriteLine("Запрос построен, ничего не выполнено");
numbers.Add(7);                         // изменим источник ДО перечисления

foreach (var x in query) Console.WriteLine($"Результат {x}");
Вывод
Запрос построен, ничего не выполнено
Where 1
Select 1
Результат 10
Where 2
Where 3
Select 3
Результат 30
Where 4
Where 5
Select 5
Результат 50
Where 7
Select 7
Результат 70

Видно два важных свойства:

  1. Элементы проходят цепочку по одному — нет промежуточных списков.
  2. Добавленный после построения запроса элемент 7 попал в результат: запрос выполняется над источником в момент перечисления.
ListWhereIteratorSelectIteratorforeachMoveNext()MoveNext()взять 11 (нечётное — подходит)Current = 1Current = 10MoveNext()MoveNext()взять 22 (чётное — пропустить)взять 33Current = 3Current = 30
ListWhereIteratorSelectIteratorforeachMoveNext()MoveNext()взять 11 (нечётное — подходит)Current = 1Current = 10MoveNext()MoveNext()взять 22 (чётное — пропустить)взять 33Current = 3Current = 30
Потоковая обработка: каждый элемент «протягивается» через цепочку

Как это устроено: yield return

Простейшая реализация Where:

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

Как и async, yield return компилятор превращает в конечный автомат — класс, реализующий IEnumerable<T> и IEnumerator<T>. Код метода не выполняется до первого вызова MoveNext().

Какие операторы что делают

Категория Операторы Поведение
Отложенные потоковые Where, Select, SelectMany, Take, Skip, Concat, Zip, Cast, OfType Обрабатывают по одному элементу
Отложенные буферизующие OrderBy, ThenBy, GroupBy, Reverse, Join (внутренняя сторона), Distinct При первом MoveNext() читают весь источник (или его часть)
Немедленные: агрегаты Count, Sum, Min, Max, Average, Aggregate, Any, All, Contains Выполняют запрос сразу, возвращают значение
Немедленные: элемент First, Single, Last, ElementAt (+ OrDefault) Выполняют до нахождения элемента
Материализация ToList, ToArray, ToDictionary, ToHashSet, ToLookup Выполняют и сохраняют результат

Ловушки отложенного выполнения

1. Повторное перечисление

C#
IEnumerable<User> active = users.Where(u => IsActive(u));   // IsActive — дорогой вызов

if (active.Any())                        // перечисление №1
    Console.WriteLine(active.Count());   // перечисление №2
foreach (var u in active) { }            // перечисление №3 — IsActive вызван трижды для каждого!

var activeList = users.Where(u => IsActive(u)).ToList();  // решение: материализовать один раз

С IQueryable каждое перечисление — отдельный SQL-запрос.

2. Замыкание захватывает переменную

C#
int minAge = 18;
var adults = people.Where(p => p.Age >= minAge);
minAge = 65;                        // изменили ПОСЛЕ построения
var result = adults.ToList();       // фильтр по 65, а не по 18!

3. Выход за время жизни ресурса

C#
IEnumerable<Order> GetOrders()
{
    using var db = new AppDbContext();
    return db.Orders.Where(o => o.IsPaid);   // запрос не выполнен...
}                                             // ...а контекст уже уничтожен
GetOrders().ToList();                          // ObjectDisposedException

Деревья выражений

Если лямбда присваивается типу Expression<TDelegate>, компилятор генерирует не IL, а код, строящий объектную модель выражения:

C#
Expression<Func<Product, bool>> expr = p => p.Price > 100 && p.Category == "Books";

// Компилятор генерирует примерно это:
var p = Expression.Parameter(typeof(Product), "p");
var body = Expression.AndAlso(
    Expression.GreaterThan(Expression.Property(p, "Price"), Expression.Constant(100m)),
    Expression.Equal(Expression.Property(p, "Category"), Expression.Constant("Books")));
var lambda = Expression.Lambda<Func<Product, bool>>(body, p);

Lambda (p)

AndAlso

GreaterThan

Equal

Property: p.Price

Constant: 100

Property: p.Category

Constant: 'Books'

Lambda (p)

AndAlso

GreaterThan

Equal

Property: p.Price

Constant: 100

Property: p.Category

Constant: 'Books'

Дерево для p => p.Price > 100 && p.Category == "Books"

Что можно сделать с деревом:

  • Транслировать — EF Core обходит дерево визитором (ExpressionVisitor) и строит SQL: WHERE Price > 100 AND Category = 'Books'.
  • Скомпилировать — expr.Compile() создаёт делегат (дорогая операция, результат стоит кэшировать).
  • Анализировать — получить имя свойства из x => x.Name (так работают nameof-подобные API, FluentValidation, AutoMapper).
  • Строить динамически — например, фильтры из UI:
Динамический фильтр по имени свойства
static Expression<Func<T, bool>> PropertyEquals<T>(string propertyName, object value)
{
    var param = Expression.Parameter(typeof(T), "x");
    var property = Expression.Property(param, propertyName);
    var constant = Expression.Constant(Convert.ChangeType(value, property.Type));
    return Expression.Lambda<Func<T, bool>>(Expression.Equal(property, constant), param);
}

var filter = PropertyEquals<Product>("Category", "Books");
var books = await db.Products.Where(filter).ToListAsync();   // уйдёт в SQL

Вопросы на засыпку

Почему OrderBy не может работать потоково?

Чтобы отдать первый элемент отсортированной последовательности, нужно увидеть все элементы. Поэтому OrderBy при первом MoveNext() читает весь источник в буфер и сортирует. Бесконечную последовательность отсортировать нельзя.

Чем First отличается от Single?

First возвращает первый подходящий и прекращает перебор. Single проверяет, что элемент ровно один, — ему нужно убедиться, что второго нет, поэтому он читает дальше. В SQL: First → TOP 1, Single → TOP 2 с проверкой.

Можно ли выполнить Expression без EF Core?

Да: expr.Compile() превращает дерево в делегат через генерацию IL. В .NET это относительно дорого (микросекунды–миллисекунды), поэтому скомпилированные делегаты кэшируют. В NativeAOT используется интерпретатор выражений.

Что такое ToLookup и чем отличается от GroupBy?

GroupBy — отложенный, ToLookup — немедленный и возвращает структуру «ключ → набор значений», доступную по индексатору (lookup[key]), причём для отсутствующего ключа — пустая последовательность, а не исключение.

Итог

LINQ — это ленивые цепочки итераторов (для объектов) или деревья выражений (для провайдеров). Понимать, когда выполняется запрос и сколько раз, критически важно для корректности и производительности. Материализуйте результат один раз, помните о замыканиях и времени жизни источника.