Углублённое изучение

LINQ в C#: примеры запросов к коллекциям

Обновлено: · 7 мин чтения

LINQ (Language Integrated Query) - набор средств C# для запросов к коллекциям, базам данных и XML единым синтаксисом. Фильтрация, сортировка, группировка и объединение данных записываются в несколько строк вместо вложенных циклов.

В статье собраны основные операторы с рабочими примерами: Where, Select, OrderBy, GroupBy, Join, First, Single, Any, All, Sum и Count, а также отложенное выполнение и два синтаксиса, query и method.

LINQ в C#: примеры запросов к коллекциям
Содержание
  1. Подготовка данных для примеров
  2. Where: фильтрация
  3. Select и SelectMany: проекция
  4. OrderBy, ThenBy и сортировка
  5. GroupBy: группировка
  6. Join и GroupJoin: объединение коллекций
  7. First, Single и их безопасные варианты
  8. Any, All, Contains, Count, Sum
  9. Отложенное выполнение
  10. Синтаксис запросов и синтаксис методов
  11. Производительность и распространённые ошибки
  12. Частые вопросы

Подготовка данных для примеров

Для LINQ нужен using System.Linq, а в проектах на современных шаблонах .NET он подключается автоматически через неявные using. Все примеры ниже используют две коллекции: список заказов и список клиентов. Они достаточно простые, чтобы результат запросов можно было проверить в уме.

record Customer(int Id, string Name, string City);
record Order(int Id, int CustomerId, string Product, decimal Price, int Qty);

var customers = new List<Customer>
{
    new(1, "Анна", "Москва"),
    new(2, "Борис", "Казань"),
    new(3, "Вера", "Москва"),
    new(4, "Глеб", "Тверь")
};

var orders = new List<Order>
{
    new(1, 1, "Ноутбук", 80000m, 1),
    new(2, 1, "Мышь", 1500m, 2),
    new(3, 2, "Монитор", 22000m, 1),
    new(4, 3, "Клавиатура", 4500m, 1),
    new(5, 3, "Мышь", 1500m, 3),
    new(6, 2, "Ноутбук", 80000m, 1)
};

LINQ работает с любым типом, реализующим IEnumerable<T>: массивами, списками, словарями, результатами других запросов. Для баз данных через Entity Framework используется IQueryable<T>, и запрос превращается в SQL. Общий принцип одинаков, но набор допустимых выражений в EF ограничен.

Where: фильтрация

Where оставляет только элементы, для которых условие истинно. Условие задаётся лямбда-выражением. Вторая перегрузка получает ещё и индекс элемента, это пригодится, если нужно, например, выбрать каждый второй.

var expensive = orders.Where(o => o.Price > 10000m);
foreach (var o in expensive)
    Console.WriteLine($"{o.Product}: {o.Price}");

var evenPositions = orders.Where((o, i) => i % 2 == 0);

var moscow = customers.Where(c => c.City == "Москва" && c.Name.StartsWith("А"));

Условия объединяются обычными операторами && и ||. Несколько вызовов Where подряд равнозначны одному с оператором И, но читаются иногда лучше. Если вам нужно только проверить тип элемента в неоднородной коллекции, подойдёт OfType<T>(): он фильтрует и приводит одновременно.

Select и SelectMany: проекция

Select преобразует каждый элемент в новую форму: берёт одно поле, считает значение или собирает анонимный тип. Это аналог выбора столбцов в SQL.

var names = customers.Select(c => c.Name);

var totals = orders.Select(o => new
{
    o.Id,
    o.Product,
    Total = o.Price * o.Qty
});

foreach (var t in totals)
    Console.WriteLine($"{t.Product}: {t.Total}");

Анонимные типы удобны внутри метода, но вернуть их из метода без dynamic или object нельзя. Для передачи результата выше по стеку заведите record. Метод SelectMany "разворачивает" вложенные коллекции в одну плоскую. Например, у каждого клиента есть список телефонов, а нужен единый список всех телефонов.

var words = new[] { "один два", "три четыре пять" };
var all = words.SelectMany(s => s.Split(' '));
Console.WriteLine(string.Join("|", all)); // один|два|три|четыре|пять

OrderBy, ThenBy и сортировка

OrderBy сортирует по возрастанию ключа, OrderByDescending - по убыванию. Для сортировки по второму критерию при равенстве первого используется ThenBy или ThenByDescending. Не вызывайте OrderBy дважды подряд: вторая сортировка отменит первую, ведь она тоже обрабатывает всю последовательность.

var sorted = orders
    .OrderBy(o => o.Product)
    .ThenByDescending(o => o.Price)
    .Select(o => $"{o.Product} {o.Price}");

Console.WriteLine(string.Join("; ", sorted));

Сортировка LINQ устойчива: элементы с равными ключами сохраняют исходный порядок. Метод создаёт новую последовательность, исходный список не меняется, в отличие от List.Sort. О сортировке по свойству объекта есть отдельная статья: сортировка List по свойствам.

GroupBy: группировка

GroupBy собирает элементы в группы по ключу. Результат - последовательность объектов IGrouping, у каждого есть свойство Key и сами элементы. Это основа для отчётов вроде "сумма продаж по клиентам".

var byCustomer = orders
    .GroupBy(o => o.CustomerId)
    .Select(g => new
    {
        CustomerId = g.Key,
        Count = g.Count(),
        Sum = g.Sum(o => o.Price * o.Qty)
    });

foreach (var x in byCustomer)
    Console.WriteLine($"Клиент {x.CustomerId}: заказов {x.Count}, на {x.Sum}");

Внутри группы можно применять любые операторы: Count, Sum, Max, OrderBy. Если нужен словарь, где ключ - значение группировки, используйте ToLookup или ToDictionary после GroupBy. ToLookup удобен тем, что отсутствующий ключ возвращает пустую последовательность, а не исключение.

Join и GroupJoin: объединение коллекций

Join соединяет две последовательности по совпадению ключей, как INNER JOIN в SQL. Синтаксис требует четырёх аргументов: вторая коллекция, ключ первой, ключ второй и функция результата.

var report = orders.Join(
    customers,
    o => o.CustomerId,
    c => c.Id,
    (o, c) => new { c.Name, o.Product, o.Price });

foreach (var r in report)
    Console.WriteLine($"{r.Name} купил {r.Product}");

Клиент Глеб без заказов в результат не попадёт. Чтобы получить и таких клиентов (LEFT JOIN), применяют GroupJoin вместе с DefaultIfEmpty или просто сохраняют вложенные заказы в группе.

var withOrders = customers.GroupJoin(
    orders,
    c => c.Id,
    o => o.CustomerId,
    (c, os) => new { c.Name, Orders = os.Count() });

// Глеб: 0 заказов

First, Single и их безопасные варианты

Эти методы возвращают один элемент, и разница между ними важна. First берёт первый подходящий элемент и бросает исключение, если подходящих нет. FirstOrDefault вернёт null или значение по умолчанию. Single требует, чтобы подходящий элемент был ровно один: два и более, как и ноль, дают исключение. SingleOrDefault допускает ноль, но не допускает больше одного.

МетодНет элементовОдин элементНесколько элементов
Firstисключениеэлементпервый
FirstOrDefaultзначение по умолчаниюэлементпервый
Singleисключениеэлементисключение
SingleOrDefaultзначение по умолчаниюэлементисключение
var first = orders.First(o => o.Product == "Мышь");           // заказ 2
var none = orders.FirstOrDefault(o => o.Product == "Принтер"); // null
var one = customers.Single(c => c.Name == "Вера");              // единственная
var lastBig = orders.LastOrDefault(o => o.Price > 10000m);

Single подходит там, где дубликат означает ошибку данных, например при поиске по уникальному ключу. Для обычного поиска чаще берут FirstOrDefault. Минимум и максимум по свойству лучше искать через MaxBy и MinBy, о чём подробнее написано в статье про поиск минимума и максимума в LINQ.

Any, All, Contains, Count, Sum

Эти операторы возвращают одно значение, а не последовательность. Any проверяет, есть ли хоть один подходящий элемент, All - выполняется ли условие для всех. Any без условия говорит, пуста ли коллекция, и работает быстрее, чем сравнение Count() с нулём, потому что остановится на первом элементе.

bool hasLaptop = orders.Any(o => o.Product == "Ноутбук");   // true
bool allPaid = orders.All(o => o.Price > 1000m);            // true
bool isEmpty = !orders.Any();                                // false

int count = orders.Count(o => o.CustomerId == 1);            // 2
decimal total = orders.Sum(o => o.Price * o.Qty);            // 194000
decimal avg = orders.Average(o => o.Price);
decimal max = orders.Max(o => o.Price);

bool has = new[] { 1, 2, 3 }.Contains(2);

Для пустой последовательности Sum вернёт 0, а Max, Min и Average бросят исключение. Если данные могут отсутствовать, используйте DefaultIfEmpty или проверку Any. Отдельно полезны Distinct, Take, Skip и Zip: первые два вместе с Skip образуют постраничный вывод Skip((page - 1) * size).Take(size).

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

Большинство операторов LINQ не вычисляют результат при вызове. Where, Select, OrderBy лишь описывают запрос, а сам перебор происходит, когда вы проходите по результату циклом foreach или вызываете ToList, ToArray, Count, First и подобные методы. Это называют отложенным выполнением.

var numbers = new List<int> { 1, 2, 3 };
var query = numbers.Where(n => n > 1);   // здесь ничего не вычислено

numbers.Add(10);                          // меняем источник после

Console.WriteLine(string.Join(",", query)); // 2,3,10

var snapshot = numbers.Where(n => n > 1).ToList(); // фиксируем результат

У такого поведения два следствия. Во-первых, запрос видит данные на момент перебора, а не на момент объявления. Во-вторых, повторный перебор выполняет запрос заново: если внутри лежит дорогая операция или запрос к базе, она повторится. Когда результат нужен несколько раз, сохраните его через ToList или ToArray. С другой стороны, отложенность даёт ленивую обработку: First на бесконечной последовательности остановится после первого найденного элемента, а Take(5) не обработает остальные.

Частая ошибка - изменять коллекцию внутри foreach по запросу к ней же. Это приведёт к InvalidOperationException. Другая ловушка связана с замыканиями на переменные цикла, она разобрана в статье про захват переменных в циклах.

Синтаксис запросов и синтаксис методов

У LINQ два равноправных стиля. Синтаксис методов - цепочка вызовов с лямбдами, как во всех примерах выше. Синтаксис запросов похож на SQL: from, where, select, orderby, group, join. Компилятор переводит второй вариант в первый, поэтому разницы в скорости нет.

// синтаксис запросов
var q1 = from o in orders
         where o.Price > 10000m
         orderby o.Price descending
         select new { o.Product, o.Price };

// те же действия методами
var q2 = orders
    .Where(o => o.Price > 10000m)
    .OrderByDescending(o => o.Price)
    .Select(o => new { o.Product, o.Price });

// join в синтаксисе запросов
var q3 = from o in orders
         join c in customers on o.CustomerId equals c.Id
         select new { c.Name, o.Product };

Запросы с несколькими источниками, join и промежуточными переменными через let часто читаются лучше в синтаксисе запросов. Остальные операторы, такие как First, Any, Sum, Skip, Take и многие другие, существуют только как методы, поэтому в реальном коде стили смешивают: запрос в скобках, а к нему вызов .ToList() или .Count(). Большинство разработчиков предпочитают методы за единообразие.

Лямбда-выражения в параметрах LINQ - это делегаты Func, поэтому полезно понимать, как они работают. Эта тема разобрана в материале о передаче функции как параметра.

Производительность и распространённые ошибки

  • Многократный перебор отложенного запроса. Сохраняйте результат в список, если используете его не один раз.
  • Count() > 0 вместо Any(). Первый вариант обходит всю последовательность, второй останавливается на первом элементе.
  • Вызов ToList слишком рано в запросах EF. Фильтр после него выполняется уже в памяти, а не в базе, и загружаются лишние строки.
  • Single там, где допустимы дубликаты. Используйте First или FirstOrDefault.
  • Тяжёлые вычисления внутри Where. Условие вызывается для каждого элемента, выносите постоянные части за пределы лямбды.

LINQ удобен, но не бесплатен. Каждая лямбда и промежуточный итератор требуют памяти, и в узких местах с миллионами элементов обычный цикл for может оказаться в несколько раз быстрее. Измеряйте до оптимизации и не отказывайтесь от читаемого запроса без причины.

Частые вопросы

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

First бросает InvalidOperationException, если подходящих элементов нет. FirstOrDefault возвращает значение по умолчанию: null для классов и 0 для чисел.

Когда нужен Single?

Когда по условию должен найтись ровно один элемент и всё остальное считается ошибкой данных. Например, поиск записи по уникальному идентификатору.

Изменяет ли LINQ исходную коллекцию?

Нет. Операторы LINQ возвращают новую последовательность, исходный список остаётся прежним. Исключение - сам код, который вы пишете в лямбде с побочными эффектами, такого стоит избегать.

В чём разница между IEnumerable и IQueryable?

IEnumerable выполняет запрос в памяти, IQueryable строит дерево выражений, которое провайдер, например Entity Framework, превращает в SQL. Для баз данных фильтры должны оставаться в IQueryable.

Что быстрее: Where с Count или Count с условием?

Разницы почти нет: Count(условие) делает один проход без промежуточного итератора и чуть экономнее. Выбирайте то, что понятнее.

Как избежать повторного выполнения запроса?

Вызовите ToList() или ToArray() и работайте с полученной коллекцией. Так запрос выполнится один раз, а дальнейшие операции пойдут по готовым данным.