Как сгруппировать данные по двум и более полям с помощью LINQ в C#?
Привет, друг! Если ты когда-нибудь писал запросы к базам данных на SQL, то наверняка помнишь конструкцию GROUP BY с несколькими колонками. Она позволяет разбить данные на логические корзины — например, сгруппировать продажи сначала по городу, а внутри — по категории товара. В C# для этой же цели используется GroupBy из LINQ. Но вот загвоздка: стандартный GroupBy принимает только один ключ. Как быть, если нужно сгруппировать по нескольким колонкам?
Не пугайся! Решение элегантное и простое — мы создаём анонимный тип, который объединяет нужные поля в один составной ключ. В этой статье я покажу тебе не только базовый синтаксис, но и реальные примеры с кодом, которые ты сможешь сразу применить в своих проектах. Поехали!
Проблема: один ключ — это слишком мало
Представь, что у тебя есть список студентов:
public class Student
{
public string Name { get; set; }
public string Course { get; set; }
public int Grade { get; set; }
}
List<Student> students = new List<Student>
{
new Student { Name = "Анна", Course = "Математика", Grade = 85 },
new Student { Name = "Борис", Course = "Математика", Grade = 90 },
new Student { Name = "Анна", Course = "Физика", Grade = 78 },
new Student { Name = "Борис", Course = "Физика", Grade = 82 },
new Student { Name = "Анна", Course = "Математика", Grade = 95 }
};
Ты хочешь узнать среднюю оценку каждого студента по каждому курсу. Если использовать GroupBy только по имени, то данные Анны по математике и физике смешаются. Если только по курсу — потеряется привязка к студенту. Нужна группировка по двум колонкам: Name + Course.
Решение: анонимный тип как составной ключ
В C# мы можем создать временный объект с нужными полями прямо на месте:
var grouped = students
.GroupBy(s => new { s.Name, s.Course })
.Select(g => new
{
g.Key.Name,
g.Key.Course,
AverageGrade = g.Average(st => st.Grade)
});
foreach (var item in grouped)
{
Console.WriteLine($"{item.Name} | {item.Course} | Средний балл: {item.AverageGrade:F2}");
}
Вывод:
Анна | Математика | Средний балл: 90.00
Борис | Математика | Средний балл: 90.00
Анна | Физика | Средний балл: 78.00
Борис | Физика | Средний балл: 82.00
Обрати внимание: new { s.Name, s.Course } — это и есть анонимный тип. LINQ автоматически сравнивает объекты по всем их свойствам (Value equality).
Работа с IGrouping и вложенные коллекции
Иногда нужно не агрегировать данные, а сохранить все исходные элементы внутри группы. Для этого используется интерфейс IGrouping<TKey, TElement>. Каждая группа содержит ключ и коллекцию элементов:
var groups = students.GroupBy(s => new { s.Name, s.Course });
foreach (var group in groups)
{
Console.WriteLine($"Студент: {group.Key.Name}, Курс: {group.Key.Course}");
foreach (var student in group)
{
Console.WriteLine($" Оценка: {student.Grade}");
}
}
Этот подход идеален, когда тебе нужно, например, вывести детализированный отчёт.
Синтаксис query vs fluent — что выбрать?
LINQ поддерживает два стиля написания запросов. Давай посмотрим на оба:
1. Fluent (метод-синтаксис)
var result = students
.GroupBy(s => new { s.Name, s.Course })
.Select(g => new { g.Key.Name, g.Key.Course, Count = g.Count() });
2. Query (синтаксис SQL-подобных выражений)
var result = from student in students
group student by new { student.Name, student.Course } into g
select new { g.Key.Name, g.Key.Course, Count = g.Count() };
Оба варианта делают одно и то же. Лично я предпочитаю fluent — он лаконичнее и лучше комбинируется с другими методами LINQ. Но query-синтаксис может быть привычнее для тех, кто пришёл из SQL.
Продвинутый сценарий: группировка с условиями
А что, если нужно сгруппировать не по точному значению, а по диапазону? Например, разбить студентов на группы по возрасту: 18-20, 21-23, 24+.
Для этого мы можем создать свой класс-ключ и переопределить Equals и GetHashCode:
public class AgeGroup
{
public int Min { get; set; }
public int Max { get; set; }
public override bool Equals(object obj) => obj is AgeGroup other && Min == other.Min && Max == other.Max;
public override int GetHashCode() => HashCode.Combine(Min, Max);
}
// Использование:
var ageGroups = students.GroupBy(s =>
{
if (s.Age <= 20) return new AgeGroup { Min = 18, Max = 20 };
if (s.Age <= 23) return new AgeGroup { Min = 21, Max = 23 };
return new AgeGroup { Min = 24, Max = 30 };
});
Этот приём открывает безграничные возможности для нестандартной группировки.
Производительность: что нужно знать?
При работе с большими коллекциями (10k+ элементов) GroupBy с анонимным типом работает быстро, но есть нюансы:
- Анонимные типы — value types? Нет, это ссылочные типы. Но для сравнения используется структурное равенство (по всем полям), поэтому overhead минимален.
- Dictionary под капотом:
GroupByиспользует хэш-таблицу, сложность O(n). - Избегай ToList() до группировки: если источник — база данных (Entity Framework), то
GroupByнужно отправлять на сервер SQL как есть, иначе загрузишь все данные в память.
Альтернативный подход: кортежи (Tuple)
Начиная с C# 7, можно использовать кортежи вместо анонимных типов:
var grouped = students
.GroupBy(s => (s.Name, s.Course))
.Select(g => new
{
Name = g.Key.Name,
Course = g.Key.Course,
AverageGrade = g.Average(st => st.Grade)
});
Кортежи — это value