Перейти к основному содержимому

5.05. Справочник по LINQ

Разработчику Архитектору

Справочник по LINQ

📌 Основные концепции и архитектура LINQ

1.1. Что такое LINQ?

LINQ (Language Integrated Query) — это единая модель запросов к данным в C#, реализованная через:

  • Синтаксис выражений запросов (from ... where ... select)
  • Методы расширения (Where, Select, GroupBy, и др.)
  • Интерфейсы и обобщённые делегаты (IEnumerable<T>, IQueryable<T>, Func<...>, Expression<...>)
  • Провайдеры LINQ, реализующие семантику выполнения (например: Enumerable, Queryable, ParallelEnumerable, EntityFrameworkCore)

1.2. Два основных режима выполнения

РежимИнтерфейсПространство имёнИсполнениеТип делегатов
LINQ to ObjectsIEnumerable<T>System.Linq.EnumerableВыполняется в .NET CLR. Сразу на материализованных данных.Func<T, ...>
LINQ to Entities / IQueryableIQueryable<T>System.Linq.QueryableВыражение анализируется провайдером (например, EF Core) и транслируется (в SQL, REST и т.п.).Expression<Func<T, ...>>

⚠️ Важно:

  • AsEnumerable() — переводит IQueryable<T>IEnumerable<T>останавливает трансляцию провайдером, дальнейшие операции выполняются локально.
  • AsQueryable() — оборачивает IEnumerable<T> в IQueryable<T>, но если источник не поддерживает IQueryable, то в итоге запрос всё равно будет выполнен через Enumerable (с преобразованием ExpressionFunc внутри Queryable через Expression.Compile()).

1.3. Архитектура провайдера LINQ

  1. Expression Tree (Expression<TDelegate>) — неисполняемое дерево выражения; используется для интроспекции и трансляции.
    • Не может содержать:
      • Локальные переменные (кроме как через замыкание — но это ConstantExpression с объектом замыкания),
      • Вызовы произвольных методов (если только провайдер не поддерживает их маппинг, например EF.Functions.Like),
      • Конструкторы некоторых типов (new MyClass()NewExpression, но поддержка зависит от провайдера),
      • Некоторые лямбды со сложной логикой (например, рекурсия, try-catch, yield).
  2. Провайдер (IQueryProvider) — реализует Execute<T> и CreateQuery<T>. Пример: EntityQueryProvider, InMemoryQueryProvider.
  3. Источник (IQueryable<T>.Expression, IQueryable<T>.Provider) — вместе определяют, что и как будет выполнено.

1.4. Фундаментальные делегаты и сигнатуры

Все стандартные LINQ-методы опираются на несколько шаблонных делегатов:

НазначениеТип делегатаПример использования
Условие фильтрацииFunc<TSource, bool> или Expression<Func<TSource, bool>>Where(x => x.Id > 0)
Проекция (mapping)Func<TSource, TResult> / Expression<Func<TSource, TResult>>Select(x => x.Name)
Ключ группировкиFunc<TSource, TKey> / Expression<Func<TSource, TKey>>GroupBy(x => x.CategoryId)
Сравнение ключейIEqualityComparer<TKey>GroupBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
СортировкаIComparer<TKey>OrderBy через keySelector)OrderBy(x => x.Date, Comparer<DateTime>.Default)
АккумуляцияFunc<TAccumulate, TSource, TAccumulate>Aggregate(seed, (acc, x) => acc + x.Value)
Элемент + индексFunc<TSource, int, TResult>Select((item, i) => new { item, Index = i })
Элементы двух последовательностейFunc<TFirst, TSecond, TResult>Zip(xs, ys, (a, b) => a + b)

Примечание: в Queryable, если передать Func, он автоматически преобразуется в Expression через замыкание, но не транслируется провайдером — а выполняется локально после материализации (что часто приводит к ошибкам или неэффективности).

1.5. Отложенное (deferred) vs Немедленное (immediate) выполнение

ТипПоведениеПримеры
ОтложенноеВозвращает IEnumerable<T> / IQueryable<T>; логика ещё не выполнена. Создаётся итератор.Where, Select, OrderBy, GroupBy, Join, Zip, Take, Skip, Distinct, Cast, OfType
НемедленноеВыполняет перечисление и возвращает материализованный результат или скаляр.ToArray, ToList, ToDictionary, ToHashSet, Count, LongCount, Any, All, First, FirstOrDefault, Single, SingleOrDefault, Last, LastOrDefault, Min, Max, Sum, Average, Aggregate, ElementAt, Contains, SequenceEqual

⚠️ Опасность: многократный вызов отложенного запроса — многократное выполнение. Всегда материализуйте, если результат нужен многократно.
Решение: var cached = query.ToList();

1.6. Потокобезопасность

  • IEnumerable<T> сам по себе — не потокобезопасен.
  • Несколько потоков не могут одновременно MoveNext() по одному и тому же IEnumerator<T>.
  • IQueryable<T> — тоже не потокобезопасен (его Provider и Expression могут быть изменяемыми в некоторых провайдерах).
  • Некоторые источники (например ImmutableArray<T>, ReadOnlySpan<T>.ToArray().AsEnumerable()) безопасны для чтения, если данные не изменяются.

📌 Методы запросов (System.Linq.Enumerable / System.Linq.Queryable)

Все методы ниже имеют две реализации в .NET BCL:

  • public static IEnumerable<T> MethodName<T>(this IEnumerable<T> source, ...) → в Enumerable
  • public static IQueryable<T> MethodName<T>(this IQueryable<T> source, ...) → в Queryable

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

  • Для EnumerableFunc<...>
  • Для QueryableExpression<Func<...>> (кроме случаев с IEqualityComparer<T> и IComparer<T>, которые передаются как есть).

2.1. Фильтрация

МетодСигнатура (упрощённо)ОписаниеОсобенности
Where<TSource>Where(Func<TSource, bool> predicate) Where(Func<TSource, int, bool> predicate)Фильтрует элементы по предикату. Вторая перегрузка передаёт индекс (от 0).- Отложенное выполнение. - Может бросить ArgumentNullException, если source или predicate == null. - В QueryableExpression анализируется и может быть транслирован (например, в WHERE SQL).
OfType<TResult>OfType<TResult>()Фильтрует и преобразует элементы к указанному типу (включая null для ссылочных типов, если элемент несовместим).- Не требует source как IEnumerable<T> — работает на IEnumerable (non-generic). - Использует is + as: эквивалент x => x is TResult ? (TResult)x : default. - Не бросает InvalidCastException.
Cast<TResult>Cast<TResult>()Принудительно преобразует каждый элемент к TResult.- Бросает InvalidCastException, если элемент несовместим. - Эквивалент x => (TResult)x. - Работает на IEnumerable.
Take<TSource>Take(int count) Take(Range range) (.NET 8+)Возвращает первые count элементов. Range позволяет: Take(..3), Take(2..), Take(^3..).- Отложенное. - count < 0ArgumentOutOfRangeException.
Skip<TSource>Skip(int count) Skip(Range range) (.NET 8+)Пропускает первые count элементов.Аналогично Take.
TakeWhile<TSource>TakeWhile(Func<TSource, bool> predicate) TakeWhile(Func<TSource, int, bool> predicate)Берёт элементы пока предикат истинен. Остановка при первом false.- Отложенное. - Не проверяет оставшиеся элементы после первой неудачи.
SkipWhile<TSource>SkipWhile(Func<TSource, bool> predicate) SkipWhile(Func<TSource, int, bool> predicate)Пропускает элементы пока предикат истинен, затем возвращает оставшиеся.Аналогично TakeWhile.

Пример (индекс в Where):

var odds = numbers.Where((x, i) => i % 2 == 1); // элементы с нечётными индексами

Пример (Range в .NET 8):

var mid = list.Take(1..^1); // без первого и последнего
var lastThree = list.Take(^3..);

2.2. Проекция (Select, SelectMany)

МетодСигнатураОписаниеОсобенности
Select<TSource, TResult>Select(Func<TSource, TResult> selector) Select(Func<TSource, int, TResult> selector)Преобразует каждый элемент. Вторая перегрузка включает индекс.- Отложенное. - Может проецировать в анонимные типы, кортежи, DTO. - В QueryableExpression транслируется в SELECT (включая вычислимые поля).
SelectMany<TSource, TCollection, TResult>SelectMany(Func<TSource, IEnumerable<TCollection>> collectionSelector, Func<TSource, TCollection, TResult> resultSelector) SelectMany(Func<TSource, IEnumerable<TResult>> selector)"Сглаживает" иерархию: из TSource → IEnumerable<TCollection> делает плоскую последовательность. Эквивалент from x in source from y in x.Items select y.- Первая перегрузка: селектор коллекции + селектор результата (позволяет использовать оба значения). - Вторая перегрузка: сразу → TResult. - Используется для имитации JOIN или обхода коллекций внутри элементов (orders.SelectMany(o => o.Items)). - В Queryable — транслируется в CROSS APPLY (SQL Server) или LATERAL JOIN (PostgreSQL).

Пример (связь с синтаксисом запроса):

// Методы:
customers.SelectMany(c => c.Orders, (c, o) => new { c.Name, o.Total });

// Запрос:
from c in customers
from o in c.Orders
select new { c.Name, o.Total };

⚠️ Внимание:

  • SelectMany не фильтрует null. Если collectionSelector вернёт null, будет InvalidOperationException ("Value cannot be null").
  • Решение: c => c.Orders ?? Enumerable.Empty<Order>().

2.3. Сортировка (OrderBy, ThenBy, Reverse)

МетодСигнатураОписаниеОсобенности
OrderBy<TSource, TKey>OrderBy(Func<TSource, TKey> keySelector) OrderBy(Func<TSource, TKey> keySelector, IComparer<TKey> comparer)Первичная сортировка по возрастанию.- Отложенное. - Возвращает IOrderedEnumerable<TSource> / IOrderedQueryable<TSource> — тип, поддерживающий ThenBy.
OrderByDescendingАналогичноСортировка по убыванию.
ThenBy<TSource, TKey>ThenBy(Func<TSource, TKey> keySelector) ThenBy(Func<TSource, TKey> keySelector, IComparer<TKey> comparer)Дополнительный уровень сортировки (для IOrderedEnumerable<T>).Может быть вызван цепочкой: .OrderBy(...).ThenBy(...).ThenBy(...)
ThenByDescendingАналогичноДоп. сортировка по убыванию.
Reverse<TSource>Reverse()Меняет порядок элементов на обратный.- Не сохраняет стабильность сортировки, если исходная последовательность неупорядочена. - В Enumerable реализован через буфер (материализует до конца при первом MoveNext). - Не используется в Queryable провайдерами напрямую — может вызвать клиентскую оценку.

Пример (многоуровневая сортировка):

var sorted = people
.OrderBy(p => p.Department)
.ThenBy(p => p.Salary)
.ThenByDescending(p => p.HireDate);

О сравнении:

  • IComparer<TKey> позволяет задать:
    • Пользовательский порядок (new CustomComparer()),
    • Culture-aware сортировку (StringComparer.Create(culture, ignoreCase)),
    • Comparer<T>.Default (использует IComparable<T> или IComparable).
  • Если comparer == null, используется Comparer<TKey>.Default.

2.4. Операции над множествами (Distinct, Union, Intersect, Except, Concat)

МетодСигнатураОписаниеОсобенности
Distinct<TSource>Distinct() Distinct(IEqualityComparer<TSource> comparer)Удаляет дубликаты.- Отложенное (в Enumerable) → но первый вызов MoveNext() материализует ВСЕ элементы в HashSet. - Порядок: первое вхождение сохраняется.
Union<TSource>Union(IEnumerable<TSource> second) Union(IEnumerable<TSource> second, IEqualityComparer<TSource> comparer)Объединение двух последовательностей без дубликатов.- Аналог Distinct(source.Concat(second)), но оптимизирован (один проход). - Не сохраняет порядок second относительно source (элементы source идут первыми, затем новые из second).
Intersect<TSource>Intersect(IEnumerable<TSource> second, ...)Пересечение: элементы, присутствующие в обеих последовательностях.- В Enumerable: second материализуется в HashSet, затем фильтруется source. - Порядок — как в source.
Except<TSource>Except(IEnumerable<TSource> second, ...)Разность: элементы из source, отсутствующие в second.Аналогично Intersect.
Concat<TSource>Concat(IEnumerable<TSource> second)Простое объединение (с дубликатами).- Отложенное, потоковое: не материализует source или second заранее. - Может соединять бесконечные последовательности (Enumerable.Repeat(1).Concat(Enumerable.Repeat(2))).
Append<TSource>Append(TSource element)Добавляет один элемент в конец.Эквивалент source.Concat(Enumerable.Repeat(element, 1)), но эффективнее.
Prepend<TSource>Prepend(TSource element)Добавляет один элемент в начало.

⚠️ Производительность:

  • Union/Intersect/Except требуют O(n + m) времени и O(min(n, m)) памяти (для HashSet).
  • Избегайте вызова Distinct() после Union/Intersect/Except — они уже возвращают уникальные элементы.

2.5. Агрегация (Count, LongCount, Min, Max, Sum, Average, Aggregate)

МетодСигнатураОписаниеОсобенности
Count<TSource>Count() Count(Func<TSource, bool> predicate)Количество элементов (или удовлетворяющих условию).- Немедленное. - Для ICollection<T> / ICollection использует .Count напрямую (O(1)). - Для остальных — перечисление (O(n)).
LongCount<TSource>АналогичноВозвращает long. Используйте при ожидании > 2 млрд элементов.
Any<TSource>Any() Any(Func<TSource, bool> predicate)Проверка наличия хотя бы одного элемента (или удовлетворяющего условию).- Для ICollection<T> — проверка Count > 0. - Иначе — MoveNext() один раз → очень эффективно.
All<TSource>All(Func<TSource, bool> predicate)Все элементы удовлетворяют предикату?- Остановка при первом false.
Min<TSource>Min() Min<TResult>(Func<TSource, TResult> selector)Минимальный элемент или значение проекции.- Для пустой последовательности:

  • Min() на int/double/DateTimeInvalidOperationException
  • Min() на int?/double?null (начиная с .NET 6)
  • MinBy() (см. ниже) безопаснее для объектов. |
| Max<TSource> | Аналогично | | |
| MinBy<TSource, TKey> | MinBy(Func<TSource, TKey> keySelector) MinBy(..., IComparer<TKey> comparer) (.NET 6+) | Возвращает элемент, имеющий минимальное значение ключа. | - Для пустой последовательности → InvalidOperationException.

  • Может вернуть любой из элементов с минимальным ключом (не гарантирован первый). |
    | MaxBy<TSource, TKey> | Аналогично | | |
    | Sum<TSource> | Sum(Func<TSource, int>) (и для long, float, double, decimal) | Сумма проекций. | - На пустой последовательности → 0 (всегда). |
    | Average<TSource> | Аналогично Sum, но возвращает double/float | Среднее значение. | - Пустая последовательность → InvalidOperationException. |
    | Aggregate<TSource> | Aggregate(Func<TSource, TSource, TSource> func) Aggregate<TAccumulate>(TAccumulate seed, Func<TAccumulate, TSource, TAccumulate> func) Aggregate<TAccumulate, TResult>(seed, func, resultSelector) | Обобщённая свёртка. | - Первая перегрузка: первый элемент — seed, затем func(acc, next). Пустая послед-ть → исключение.
  • Вторая: явный seed. Пустая → возвращает seed.
  • Третья: позволяет преобразовать аккумулятор в результат (например, (count, sum) → avg = sum / count). |

Пример (Aggregate для среднего без Average):

var avg = list.Aggregate(
seed: (Count: 0, Sum: 0.0),
func: (acc, x) => (acc.Count + 1, acc.Sum + x),
resultSelector: acc => acc.Count == 0 ? 0.0 : acc.Sum / acc.Count
);

2.6. Группировка (GroupBy, ToLookup)

МетодСигнатура (упрощённо)ОписаниеОсобенности
GroupBy<TSource, TKey>GroupBy(Func<TSource, TKey> keySelector) GroupBy(keySelector, IEqualityComparer<TKey> comparer)Группирует элементы по ключу. Возвращает IEnumerable<IGrouping<TKey, TSource>>.- Отложенное. - Каждая группа — это IGrouping<TKey, T>: реализует IEnumerable<T> + имеет свойство Key. - Ключи вычисляются один раз на элемент. - Порядок групп — порядок первого вхождения ключа в source.
GroupBy<TSource, TKey, TElement>GroupBy(keySelector, elementSelector) GroupBy(keySelector, elementSelector, comparer)Позволяет преобразовать элементы внутри групп (проекция).Эквивалент: source.GroupBy(k => k.Key).Select(g => new { g.Key, Elements = g.Select(e => e.Projection) })
GroupBy<TSource, TKey, TResult>GroupBy(keySelector, resultSelector) GroupBy(keySelector, elementSelector, resultSelector) GroupBy(..., comparer)Вместо IGrouping возвращает кастомный результат на группу (например, агрегат).- resultSelector: Func<TKey, IEnumerable<TElement>, TResult> - Позволяет делать агрегацию внутри групп без дополнительного Select:   GroupBy(x => x.Category, (key, items) => new { Category = key, Count = items.Count() })
ToLookup<TSource, TKey>ToLookup(Func<TSource, TKey> keySelector) ToLookup(keySelector, elementSelector) ToLookup(..., comparer)Создаёт неизменяемый ILookup<TKey, TElement> — словарь групп.- Немедленное выполнение (материализует все данные). - ILookup — похож на Dictionary<TKey, IEnumerable<T>>, но:   • всегда возвращает IEnumerable<T> (даже для отсутствующего ключа — пустая последовательность),   • неизменяем,   • допускает дубликаты ключей (в отличие от Dictionary). - Подходит для многократных запросов по ключам.

Пример (GroupByDictionary):

var dict = people
.GroupBy(p => p.Department)
.ToDictionary(g => g.Key, g => g.ToList());

Пример (ToLookup для многократного доступа):

var lookup = orders.ToLookup(o => o.CustomerId);
foreach (var id in customerIds)
{
var ordersForCustomer = lookup[id]; // всегда IEnumerable<Order>, даже если 0
}

⚠️ Важно:

  • GroupBy в Queryable (EF Core) генерирует SQL GROUP BY, но если после него использовать методы вроде Count(), Sum(), Min() без проекции через Select, EF может выполнить запрос и агрегацию на клиенте (N+1 или materialization warning).
  • Безопасно:
    .GroupBy(x => x.Category)
    .Select(g => new { g.Key, Count = g.Count() }) // → SELECT Category, COUNT(*) GROUP BY Category

2.7. Соединения (Join, GroupJoin)

МетодСигнатураОписаниеОсобенности
Join<TOuter, TInner, TKey, TResult>Join(IEnumerable<TInner> inner,   Func<TOuter, TKey> outerKeySelector,   Func<TInner, TKey> innerKeySelector,   Func<TOuter, TInner, TResult> resultSelector,   IEqualityComparer<TKey> comparer = null)Эквивалент SQL INNER JOIN.- Отложенное. - Реализация в Enumerable: материализует inner в Lookup<TKey, TInner>, затем для каждого outer ищет совпадения. - Порядок: как в outer. - Повторяющиеся ключи в inner → несколько результатов на один outer.
GroupJoin<TOuter, TInner, TKey, TResult>GroupJoin(..., Func<TOuter, IEnumerable<TInner>, TResult> resultSelector, ...)Эквивалент SQL LEFT JOIN + группировка правой части.- Для каждого outer возвращает IEnumerable<TInner> (возможно, пустой). - Основа для реализации LEFT JOIN:   csharp &nbsp;&nbsp;outer.GroupJoin(inner, o => o.Id, i => i.OuterId, &nbsp;&nbsp;&nbsp;&nbsp;(o, inners) => new { o, Inners = inners.DefaultIfEmpty() }) &nbsp;&nbsp;

Пример (LEFT JOIN через GroupJoin + SelectMany):

var leftJoin = customers
.GroupJoin(orders, c => c.Id, o => o.CustomerId,
(c, os) => new { Customer = c, Orders = os })
.SelectMany(x => x.Orders.DefaultIfEmpty(),
(x, o) => new { x.Customer.Name, OrderDate = o?.Date });

Это даёт плоский результат, как в SQL LEFT JOIN.

⚠️ В Queryable:

  • JoinINNER JOIN
  • GroupJoinLEFT JOIN (в EF Core 5+)
  • Сложные условия (например, ON a.X = b.Y AND a.Z > 5) требуют Where после Join, либо использования SelectMany + Where.

2.8. Элементы и позиции (First, Last, Single, ElementAt, и др.)

МетодСигнатураОписаниеОсобенности
First<TSource>First() First(Func<TSource, bool> predicate)Первый элемент (или первый удовлетворяющий условию).- Немедленное. - Пустая послед-ть / нет совпадений → InvalidOperationException.
FirstOrDefault<TSource>АналогичноТо же, но возвращает default(T) при отсутствии.- Для ссылочных типов и Nullable<T>null.
Last<TSource>Last() Last(Func<TSource, bool> predicate)Последний элемент (или последний по условию).- В Enumerable: если source не IList<T>, материализует ВСЁ (O(n), O(n) памяти). - Избегайте на больших или бесконечных послед-тях.
LastOrDefault<TSource>АналогичноВозвращает default(T) при отсутствии.
Single<TSource>Single() Single(predicate)Ровно один элемент (или один по условию).- Проверяет, что элементов ровно один. 0 или ≥2 → InvalidOperationException. - Безопасен для получения уникального результата (например, по PK).
SingleOrDefault<TSource>АналогичноВозвращает default(T), если 0 элементов.
ElementAt<TSource>ElementAt(int index)Элемент по индексу.- Для IList<T> → O(1) (list[index]). - Иначе — перебор до index → O(n). - index < 0 или ≥ CountArgumentOutOfRangeException.
ElementAtOrDefault<TSource>АналогичноВозвращает default(T) при выходе за границы.
DefaultIfEmpty<TSource>DefaultIfEmpty() DefaultIfEmpty(TSource defaultValue)Возвращает последовательность из одного default(T) / defaultValue, если source пуста.- Отложенное. - Используется для эмуляции LEFT JOIN, когда правая часть может быть пустой.
SingleOrDefault + ?? throwПаттерн «найти или исключение»:csharp var item = list.SingleOrDefault(x => x.Id == id) ?? throw new NotFoundException();

Пример (безопасный Last для IQueryable):

// Плохо (может привести к клиентской оценке):
var last = context.Orders.OrderBy(o => o.Date).Last();

// Лучше:
var last = context.Orders.OrderByDescending(o => o.Date).FirstOrDefault();

2.9. Генерация и преобразование последовательностей

МетодСигнатураОписаниеОсобенности
Empty<TSource>()Empty<T>()Возвращает пустую IEnumerable<T>.- Singleton-реализация (один и тот же экземпляр T[0]). - Используется для инициализации, объединения, fallback.
Range(int start, int count)Range(0, 10)Последовательность целых: start, start+1, ..., start+count-1.- count < 0ArgumentOutOfRangeException. - Может использоваться для генерации индексов.
Repeat<TSource>(TSource element, int count)Repeat("x", 3)Повторяет элемент count раз.- count < 0 → исключение. - count == 0 → пустая послед-ть.
Zip<TFirst, TSecond, TResult>Zip(IEnumerable<TSecond> second, Func<TFirst, TSecond, TResult> resultSelector) (.NET Core 3+) Zip(first, second) → возвращает IEnumerable<(TFirst, TSecond)> (.NET 6+)Параллельное объединение по позиции («молния»).- Останавливается по короткой последовательности. - В .NET 6+ перегрузка без селектора возвращает кортежи. - Аналог Python zip().
Chunk<TSource>(int size)Chunk(100) (.NET 6+)Разбивает последовательность на чанки (массивы) указанного размера.- Последний чанк может быть короче. - Немедленное (материализует чанк при запросе). - Используется для пакетной обработки (bulk-операции в БД).
TakeLast<TSource>(int count)TakeLast(5) (.NET 6+)Последние count элементов.- В Enumerable: материализует буфер размера count (O(count) памяти), один проход. - Не эквивалент Reverse().Take(count).Reverse() (тот требует O(n) памяти).
SkipLast<TSource>(int count)SkipLast(5) (.NET 6+)Все элементы, кроме последних count.Аналогично TakeLast — эффективен (буфер фикс. размера).
DistinctBy<TSource, TKey>DistinctBy(x => x.Id) (.NET 6+)Удаление дубликатов по ключу.- Аналог GroupBy(key).Select(g => g.First()), но оптимизирован (один проход, HashSet<TKey>). - Безопасен для больших объектов (не хранит сами элементы, только ключи).
UnionBy, IntersectBy, ExceptByUnionBy(second, x => x.Id) (.NET 7+)Версии Union/Intersect/Except с селектором ключа.- Позволяют сравнивать по свойству, не реализуя IEquatable<T>. - Эффективны (используют HashSet<TKey>).

Пример (Chunk для пакетной вставки):

foreach (var chunk in items.Chunk(1000))
{
context.Orders.AddRange(chunk);
await context.SaveChangesAsync();
}

Пример (Zip с кортежами в .NET 6+):

var pairs = names.Zip(ages); // IEnumerable<(string, int)>
foreach (var (name, age) in pairs) { ... }

📌 Свёртка, итерация, и расширенные методы из System.Interactive

Важно: Методы из System.Interactive (пакет System.Interactive) не входят в стандартную поставку .NET, но широко используются в продвинутых сценариях (потоковая обработка, реактивное программирование, сложные трансформации). Ниже — только те, что имеют практическую ценность для справочника и не дублируют Enumerable.

4.1. Свёртка и поэлементная аккумуляция

МетодИзСигнатураОписаниеПример использования
Aggregate<TSource>EnumerableУже рассмотрен в части 2.5.
Scan<TSource>System.InteractiveScan(Func<TSource, TSource, TSource> accumulator) Scan<TAccumulate>(seed, Func<TAccumulate, TSource, TAccumulate> accumulator)Возвращает промежуточные состояния свёртки (в отличие от Aggregate, который возвращает только конечный результат).csharp var sums = new[] {1,2,3}.Scan(0, (acc, x) => acc + x); // [1,3,6]
ScanBy<TSource, TKey>System.Interactive.Async (редко)Аналогично, но по ключу (редко используется).

⚠️ Scan — отложенное, но требует хранения состояния между элементами → не thread-safe.
Полезно для: кумулятивных сумм, скользящих окон (в комбинации с Skip, Take), отладки пайплайнов.

4.2. Последовательности с окнами и сдвигами

МетодИзСигнатураОписаниеОсобенности
Buffer<TSource>(int count)System.InteractiveBuffer(3)Группирует элементы по count, возвращает IEnumerable<IList<TSource>>. Последняя группа может быть неполной.Эквивалент Chunk, но возвращает IList<T>, а не T[].
Buffer<TSource>(int count, int skip)System.InteractiveBuffer(3, 1)Скользящее окно: сдвиг на skip, размер окна count.csharp [1,2,3,4].Buffer(2,1) → [[1,2],[2,3],[3,4]]
Pairwise<TSource>System.InteractivePairwise()Возвращает (current, next) для соседних элементов.csharp [1,2,3].Pairwise() → [(1,2), (2,3)] - Пустая или одиночная послед-ть → пустой результат.
StartWith<TSource>(params TSource[])System.InteractiveStartWith(0)Добавляет элементы в начало последовательности.Эквивалент new[] { x }.Concat(source), но чище.
EndWith<TSource>System.InteractiveEndWith(999)Добавляет в конец.Эквивалент source.Append(x), но принимает params.

4.3. Условная и отложенная логика

МетодИзСигнатураОписание
If<TSource>(bool condition, Func<IEnumerable<TSource>> thenSource)System.InteractiveУсловное включение последовательности.
Switch<TSource>(Func<int, IEnumerable<TSource>> selector)System.InteractiveВыбор источника по индексу (редко).
Defer<TSource>(Func<IEnumerable<TSource>> factory)System.InteractiveОткладывает создание источника до первого MoveNext(). Полезно для отложенной инициализации (например, чтение из файла только при использовании).

Пример (Defer для ленивой инициализации):

var lazyLines = Defer(() => File.ReadLines("log.txt"));
// Файл не открыт, пока не начнётся перечисление.

4.4. Итерация без материализации (ForEachне метод LINQ!)

⚠️ Важно:

  • .ForEach() отсутствует в IEnumerable<T> в стандартной библиотеке.
  • Он есть у List<T>, но это метод экземпляра, не расширение LINQ.
  • Попытка написать source.ForEach(Console.WriteLine) не скомпилируется, если source не List<T>.

Рекомендуемый подход:

foreach (var item in source)
{
Console.WriteLine(item);
}

Если нужно в цепочке (например, для логгирования):

public static IEnumerable<T> Inspect<T>(this IEnumerable<T> source, Action<T> action)
{
foreach (var item in source)
{
action(item);
yield return item;
}
}

// Использование:
var result = data
.Where(x => x > 0)
.Inspect(x => Console.WriteLine($"Filtered: {x}"))
.Select(x => x * 2);

Такой метод безопасен и сохраняет отложенность.


📌 Особенности IQueryable<T> и Expression Trees

5.1. Структура Expression<TDelegate>

Expression<Func<T, bool>> — это не делегат, а дерево объектов, описывающее код:

Expression<Func<int, bool>> expr = x => x > 5;

// Структура:
// Lambda
// └─ Body: BinaryExpression (GreaterThan)
// ├─ Left: ParameterExpression (x)
// └─ Right: ConstantExpression (5)

Поддерживаемые узлы (основные):

  • ParameterExpression — параметры (x)
  • ConstantExpression — константы (5, "test", DateTime.Now — но вычисляется при построении!)
  • MemberExpression — свойства/поля (x.Name, x.Age)
  • MethodCallExpression — вызовы (x.ToString(), EF.Functions.Like(...))
  • NewExpressionnew MyClass(...)
  • NewArrayExpressionnew[] { x, y }
  • ConditionalExpressiona ? b : c
  • UnaryExpression!, -, Convert, Quote
  • BinaryExpression+, -, ==, &&, ||, <, >, и т.д.

Запрещено в Expression для провайдеров (например, EF Core):

  • Локальные переменные (за исключением как ConstantExpression через замыкание — но значение "захватывается" при построении).
  • Методы, не имеющие маппинга (например, x.Name.ToUpper() → в EF Core 5+ работает, но в EF6 — нет; x.Name.Trim() → зависит от провайдера).
  • Конструкторы с логикой (только инициализация полей/свойств).
  • Invoke на других Expression (редко поддерживается).
  • Block, Loop, TryCatch — не поддерживаются в деревьях выражений уровня 1 (только в Expression Trees v2, но не в Expression<T>).

5.2. Параметризация и замыкания

var threshold = 10;
var query = db.Orders.Where(o => o.Total > threshold);

Компилятор преобразует это в:

var captured = new { threshold };
Expression<Func<Order, bool>> = o => o.Total > captured.threshold;

Провайдер (например, EF Core) анализирует captured.threshold и параметризует запрос:

SELECT * FROM Orders WHERE Total > @__threshold_0

✅ Безопасно от SQL-инъекций.
❌ Но: если threshold — результат вычисления, оно выполняется на клиенте при построении выражения.

5.3. Компиляция выражений

  • Expression.Compile()Func<T> (выполняется в CLR)
  • Expression.Compile(true) → интерпретируемая версия (медленнее, но меньше накладных расходов на JIT)
  • Expression.CompileToMethod() → генерация в DynamicMethod (устаревшее)

Используется внутри Queryable, когда IQueryable оборачивает IEnumerable (например, AsQueryable() над списком).

5.4. Отладка Expression

  • expr.ToString()"x => (x.Total > 10)"
  • expr.DebugView (в отладчике VS) → подробное дерево
  • Библиотеки: ExpressionTreeToString, LinqKit (ToString() улучшенный)

📌 Настройки и параметры выполнения (в т.ч. EF Core)

КатегорияМетод / ПараметрПрименениеОписание
ОтслеживаниеAsNoTracking()IQueryable<T>Отключает change tracking → +производительность, −возможность SaveChanges().
AsTracking() / AsTracking(QueryTrackingBehavior)Переключает обратно; TrackAll, TrackOnlyRoot.
Разделение запросовAsSplitQuery()EF Core 5+Генерирует несколько SQL-запросов вместо JOIN → избегает cartesian explosion.
AsSingleQuery()Возвращает поведение по умолчанию (JOIN).
ТегированиеTagWith(string)EF Core 5+Добавляет комментарий в SQL → упрощает поиск в логах.
TagWithCallSite()Автоматически добавляет путь к файлу/строке.
ToQueryString()Получить SQL-запрос до выполнения (для отладки).
ОтменаWithCancellation(CancellationToken)EF Core 6+Передаёт токен в ExecuteReaderAsync и т.д.
Поведение при ошибкахIgnoreQueryFilters()Игнорирует глобальные фильтры (HasQueryFilter).
IgnoreAutoIncludes()EF Core 5+Игнорирует AutoInclude.
МатериализацияAsEnumerable()Переключает на LINQ to Objects → останавливает трансляцию в SQL.
AsAsyncEnumerable()EF Core 6+Асинхронная версия для IAsyncEnumerable<T>.
Специфичные функцииEF.Functions.Like()WHERE Name LIKE '%test%'
EF.Functions.Collate()Задаёт collation в запросе.
EF.Property<T>(obj, "PropertyName")Доступ к теневым свойствам.

Пример комплексного запроса:

var orders = context.Orders
.TagWith("Dashboard: RecentOrders")
.Where(o => o.Date >= DateTime.UtcNow.AddDays(-7))
.Include(o => o.Customer)
.AsSplitQuery()
.AsNoTracking()
.OrderByDescending(o => o.Date)
.Take(100)
.ToList();

📌 Типичные ловушки и рекомендации по производительности

7.1. Общие ошибки и антипаттерны

ПроблемаПримерРискРешение
1N+1 запросовorders.Select(o => new { o.Id, Customer = db.Customers.Find(o.CustomerId) })Сетевой оверхед, таймаутыИспользовать Include, Join, или Select с проекцией до ToList()
2Клиентская оценка (Client Evaluation).Where(o => o.Total.ToString().Contains("9"))Полная загрузка таблицы в памятьИзбегать .ToString(), .ToLower() на стороне клиента; использовать EF.Functions.Like(o.Total.ToString(), "%9%") если провайдер поддерживает, или переписать на `o.Total % 10 == 9
3Материализация в середине цепочкиdb.Orders.ToList().Where(o => o.Total > 100)Загрузка всей таблицыУбрать ToList() до Where
4Многократное выполнение отложенного запросаvar q = db.Orders.Where(...); var c1 = q.Count(); var c2 = q.Average(o => o.Total);Два одинаковых запроса к БДМатериализовать: var list = q.ToList();
5Использование First() вместо FirstOrDefault() для optional данныхdb.Users.First(u => u.Email == email) при отсутствии → исключениеCrash при валидном сценарииИспользовать FirstOrDefault() ?? throw, если семантика «найти или ошибка» явно нужна
6Last() на IQueryable<T> без OrderBydb.Orders.Last()Неопределённое поведение (SQL без ORDER BY + TOP 1)Всегда: OrderBy(...).Last() → но лучше OrderByDescending(...).First()
7Захват изменяемых переменных в замыканииcsharp<br>var filters = new List<Expression<Func<Order, bool>>>();<br>for (int i = 0; i < 3; i++)<br>&nbsp;&nbsp;filters.Add(o => o.Id == i);<br>Все условия будут o.Id == 3Копировать в локальную переменную: var j = i; filters.Add(o => o.Id == j);
8GroupByToList() без проекцииdb.Orders.GroupBy(o => o.CustomerId).ToList()EF пытается загрузить группу как IGrouping<int, Order> → клиентская оценкаПроектировать сразу: .Select(g => new { g.Key, Count = g.Count() })

7.2. Производительность: время и память

МетодВремя (среднее)ПамятьКомментарий
Count() на ICollection<T>O(1)O(1)Использует свойство Count
Count() на IEnumerable<T>O(n)O(1)Перечисление
Any()O(1)O(1)MoveNext() один раз
ToList() / ToArray()O(n)O(n)Аллокация массива
Distinct() / Union() / Intersect()O(n + m)O(min(n, m))HashSet
OrderBy()O(n log n)O(n)Стабильная сортировка (Timsort / IntroSort)
GroupBy() (Enumerable)O(n)O(n)Lookup<TKey, T> — словарь списков
GroupBy() (Queryable)Зависит от SQLGROUP BY с индексом → быстро
Skip(n).Take(m) на IQueryable<T>O(1) в SQL (OFFSET FETCH)Но без ORDER BY — нестабильно
ElementAt(n) на IList<T>O(1)O(1)Индексатор
ElementAt(n) на IEnumerable<T>O(n)O(1)Перебор
Last() на IEnumerable<T>O(n)O(1) или O(n)Если не IList<T> — буферизация всего (в текущей реализации — нет, но проход до конца с запоминанием последнего → O(1) памяти, O(n) времени) ✅
Reverse()O(n)O(n)Буферизация

✅ Начиная с .NET Core 3.0, Last() на IEnumerable<T> не буферизует всю последовательность, а просто проходит итератор, сохраняя текущий элемент — память O(1).
⚠️ Но Last(predicate) всё ещё требует O(n) времени и O(1) памяти — корректно.

7.3. Рекомендации по оптимизации

  • Индексируйте поля, используемые в Where, OrderBy, GroupBy, Join.
  • Всегда используйте проекции (Select) до ToList(), чтобы передавать по сети только нужные столбцы.
  • Избегайте Select(x => new { x, x.Relation }) без Include — приведёт к N+1 или ошибке.
  • Для пагинации:
    .OrderBy(x => x.Id) // обязательно!
    .Skip(pageSize * (page - 1))
    .Take(pageSize)
  • Используйте AsNoTracking() для read-only сценариев (отчёты, API-ответы).
  • При работе с большими объёмами — Chunk() или курсоры (EF Core: ExecuteSqlRaw + DbDataReader).
  • Не используйте ToList().ForEach(...) — это материализация + side effects в LINQ-стиле.
  • Избегайте async внутри Select/Where — они не awaitятся. Используйте SelectAwait из System.Linq.Async, если нужна асинхронная проекция.

📌 LINQ в асинхронном контексте

8.1. Асинхронные методы в EF Core (и других провайдерах)

СинхронныйАсинхронныйПримечание
ToList()ToListAsync(ct)
ToArray()ToArrayAsync(ct)
Count()CountAsync(ct)
Any()AnyAsync(ct)
First()FirstAsync(ct)
Single()SingleAsync(ct)
ForEachAsync(Action<T>)ForEachAsync(ct)Расширение EF Core: перечисляет и применяет действие (не возвращает значение)

⚠️ Нет асинхронных версий для:

  • Where, Select, OrderBy и других отложенных операций — они не выполняют IO, только строят выражение.
  • Асинхронность начинается только с материализующих методов.

8.2. IAsyncEnumerable<T> (.NET Core 3.0+)

Позволяет потоковую обработку результатов без полной материализации:

await foreach (var order in context.Orders
.Where(o => o.Total > 1000)
.AsAsyncEnumerable())
{
await ProcessAsync(order);
}

Преимущества:

  • Память O(1) (один элемент за раз).
  • Совместимо с yield return в кастомных асинхронных источниках.
  • Поддерживается EF Core 6+, Npgsql, MongoDB.Driver.

Ограничения:

  • Нельзя использовать Skip, Take, Count внутри IAsyncEnumerable (без материализации).
  • Не все провайдеры поддерживают серверную пагинацию в потоке (EF Core — да, через курсоры в SQL Server 2012+).

8.3. Библиотека System.Linq.Async

Пакет System.Linq.Async (от Reactive Extensions) добавляет асинхронные аналоги LINQ:

МетодОписание
WhereAwait, SelectAwait, OrderByAwaitАсинхронные предикаты/селекторы (Func<T, Task<bool>>)
ToAsyncEnumerable()Конвертирует IEnumerable<Task<T>>IAsyncEnumerable<T>
Merge()Параллельное выполнение асинхронных операций

Пример:

var results = await urls
.ToAsyncEnumerable()
.SelectAwait(async url => await httpClient.GetStringAsync(url))
.WhereAwait(async html => await IsRelevantAsync(html))
.ToListAsync();

⚠️ Используйте осторожно: асинхронные делегаты в LINQ нарушают принцип «чистых функций» и усложняют композицию. Предпочтительнее — сначала получить данные, потом обработать асинхронно.


📌 LINQ для нестандартных источников

9.1. LINQ to XML (System.Xml.Linq)

Работает с XDocument, XElement, XAttribute.

Метод / КонструкцияОписаниеПример
XDocument.Load("file.xml")Загрузка XML
doc.Descendants("book")Все элементы <book> на любом уровне
elem.Elements("author")Дочерние <author>
elem.Attributes("id")Атрибуты id
elem.ValueТекстовое содержимое
elem.Attribute("id")?.ValueЗначение атрибута
new XElement("book", new XAttribute("id", 1), "Title")Конструирование

LINQ-запросы:

var expensiveBooks = doc
.Descendants("book")
.Where(b => (decimal)b.Element("price") > 30)
.Select(b => new {
Id = (int)b.Attribute("id"),
Title = b.Element("title").Value,
Price = (decimal)b.Element("price")
});

⚠️ Безопасность: используйте явные приведения ((string), (int?)) — они возвращают null при отсутствии элемента/атрибута. Прямой .Value на nullNullReferenceException.

9.2. LINQ to JSON (через System.Text.Json + JsonDocument)

Нет встроенного IQueryable, но можно комбинировать с Select, Where:

using var doc = JsonDocument.Parse(json);
var items = doc.RootElement
.EnumerateArray()
.Where(e => e.TryGetProperty("price", out var p) && p.GetDouble() > 100)
.Select(e => new {
Name = e.GetProperty("name").GetString(),
Price = e.GetProperty("price").GetDouble()
});

Альтернатива: библиотеки вроде Newtonsoft.Json.Linq (JArray, JObject):

var jArray = JArray.Parse(json);
var expensive = jArray
.Where(t => t["price"]?.Value<decimal>() > 100)
.Select(t => new { Name = t["name"]?.ToString(), Price = t["price"]?.Value<decimal>() });

⚠️ Производительность: JsonDocument — быстрее и безопаснее по памяти (IDisposable), JObject — удобнее для мутаций.

9.3. LINQ к Parquet, CSV, Avro (через Microsoft.Data.Analysis, CsvHelper, Parquet.Net)

Нет прямой поддержки IQueryable, но можно:

  1. Прочитать в IEnumerable<T> (лениво):

    var records = csvReader.GetRecords<Order>();
    var filtered = records.Where(o => o.Total > 1000);
  2. Или загрузить в DataFrame (Microsoft.Data.Analysis) и использовать Filter, Select, GroupBy — но это не LINQ, а методы DataFrame API.

  3. Для Parquet:

    using var parquetReader = await ParquetReader.CreateFromFileStreamAsync(stream);
    var group = await parquetReader.ReadNextRowGroupAsync();
    var column = group.Columns[0].Data.Cast<int>();
    var query = column.Where(x => x > 100);

Вывод: для columnar-форматов (Parquet) LINQ применяется после извлечения колонок как IEnumerable<T>.

9.4. Пользовательские IQueryable-провайдеры (кратко)

Можно реализовать свой провайдер:

  1. Реализовать IQueryable<T> и IQueryProvider.
  2. В Execute — анализировать Expression, генерировать запрос (SQL, REST, gRPC), выполнять, маппить.
  3. Примеры:
    • Elasticsearch.Net (через NEST — частично),
    • MongoDB.Driver (Find(), но не IQueryable по умолчанию; есть AsQueryable()),
    • OData-клиенты (Microsoft.OData.ClientDataServiceQuery<T> реализует IQueryable<T>).

Пример (MongoDB):

var collection = db.GetCollection<Order>("orders");
var query = collection.AsQueryable()
.Where(o => o.CustomerId == 123 && o.Total > 1000)
.OrderByDescending(o => o.Date)
.Take(10);
// → транслируется в MongoDB find + sort + limit

⚠️ Не все операторы поддерживаются: GroupBy, Join, Skip без OrderBy могут вызвать клиентскую оценку.


📌 Параллельные LINQ — ParallelEnumerable

Пространство имён: System.Linq. Требует using System.Linq; (да, то же, но методы в ParallelEnumerable).

Синхронный (Enumerable)Параллельный (ParallelEnumerable)Условия эффективности
AsParallel()Начало PLINQ-цепочки
WhereWhereCPU-bound, > 1000 элементов, функция не блокирует
SelectSelectТо же
OrderByOrderByДорого: O(n log n) + merge; лучше AsOrdered() после
AggregateAggregate(seedFactory, updateAccumulator, mergeAccumulators, resultSelector)Требует ассоциативной и идемпотентной операции

Особенности:

  • AsParallel()ParallelQuery<T>
  • AsSequential() → возврат к IEnumerable<T>
  • AsOrdered() — сохраняет порядок (снижает производительность)
  • WithDegreeOfParallelism(int) — ограничение потоков
  • WithCancellation(ct) — поддержка отмены
  • WithMergeOptions(ParallelMergeOptions)AutoBuffered (по умолчанию), FullyBuffered, NotBuffered

Пример:

var result = data
.AsParallel()
.WithDegreeOfParallelism(Environment.ProcessorCount)
.Where(x => ExpensiveFilter(x))
.Select(x => HeavyComputation(x))
.ToArray(); // барьер синхронизации

⚠️ Ловушки:

  • Параллельная агрегация требует ассоциативной операции (например, +, *, Min, Max — да; Average — нет, нужен (count, sum)).
  • Side effects в Select/Where → гонки.
  • Маленькие наборы (< 1000) — накладные расходы на потоки перевешивают выгоду.

📌 Приложение: Сводная таблица всех методов LINQ (.NET 8)

Всего 56 стандартных методов в System.Linq.Enumerable (не считая перегрузок). Ниже — классификация.

КатегорияМетоды (без перегрузок)Кол-воПримечание
ФильтрацияWhere, OfType, Cast, Take, Skip, TakeWhile, SkipWhile, TakeLast, SkipLast9TakeLast/SkipLast — .NET 6+
ПроекцияSelect, SelectMany2
СортировкаOrderBy, OrderByDescending, ThenBy, ThenByDescending, Reverse5Reverse — не стабильный
МножестваDistinct, Union, Intersect, Except, Concat, Append, Prepend, DistinctBy, UnionBy, IntersectBy, ExceptBy11*By — .NET 6+/7+
АгрегацияCount, LongCount, Any, All, Min, Max, MinBy, MaxBy, Sum, Average, Aggregate11MinBy/MaxBy — .NET 6+
ГруппировкаGroupBy, ToLookup2
СоединенияJoin, GroupJoin2
ЭлементыFirst, FirstOrDefault, Last, LastOrDefault, Single, SingleOrDefault, ElementAt, ElementAtOrDefault, DefaultIfEmpty9
ГенерацияEmpty, Range, Repeat, Zip, Chunk5Chunk — .NET 6+