Группировка по нескольким колонкам в LINQ: примеры и best practices

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Как сгруппировать данные по двум и более полям с помощью 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

Блоги

Рекомендаций по книгам