LINQ в C#: примеры запросов к коллекциям
LINQ (Language Integrated Query) - набор средств C# для запросов к коллекциям, базам данных и XML единым синтаксисом. Фильтрация, сортировка, группировка и объединение данных записываются в несколько строк вместо вложенных циклов.
В статье собраны основные операторы с рабочими примерами: Where, Select, OrderBy, GroupBy, Join, First, Single, Any, All, Sum и Count, а также отложенное выполнение и два синтаксиса, query и method.

Содержание
- Подготовка данных для примеров
- Where: фильтрация
- Select и SelectMany: проекция
- OrderBy, ThenBy и сортировка
- GroupBy: группировка
- Join и GroupJoin: объединение коллекций
- First, Single и их безопасные варианты
- Any, All, Contains, Count, Sum
- Отложенное выполнение
- Синтаксис запросов и синтаксис методов
- Производительность и распространённые ошибки
- Частые вопросы
Подготовка данных для примеров
Для 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() и работайте с полученной коллекцией. Так запрос выполнится один раз, а дальнейшие операции пойдут по готовым данным.





