1. Введение
Представьте себе классическую задачу: у вас есть две коллекции, которые логически связаны между собой. Например, список категорий товаров и список самих товаров. Надо для каждой категории получить все товары этой категории. Или, например, есть список отделов компании и список сотрудников, а нужно вывести всех сотрудников для каждого отдела.
В SQL это называется "групповое соединение" (GROUP JOIN или, точнее, LEFT OUTER JOIN с группировкой). В LINQ для этого есть специальный оператор – GroupJoin. Это нечто среднее между обычным объединением (Join), где каждой левой записи соответствует ровно одна правая, и группировкой по ключу. GroupJoin связывает каждый элемент одной коллекции со всеми связанными элементами из второй коллекции в виде коллекции.
Аналогия
Если обычный Join — это как объединить пары “папа и сын” по фамилии, то GroupJoin — это построить дерево: к каждому папе приложить список всех его детей.
Схематично
Categories Products
+--------------+ +---------------------+
| Id | Name | | Name | CatId |
+----+---------+ +-----------+---------+
| 1 | Хлеб | ---> | Батон | 1 |
| 2 | Напитки | | Колбаса | 3 |
| 3 | Мясо | | Пепси | 2 |
| | | | Чай | 2 |
+----+---------+ +-----------+---------+
После GroupJoin:
- Хлеб — [Батон]
- Напитки — [Пепси, Чай]
- Мясо — [Колбаса]
2. Сигнатура метода и базовые понятия
Метод расширения
public static IEnumerable<TResult> GroupJoin<TOuter, TInner, TKey, TResult>(
this IEnumerable<TOuter> outer, // "Внешняя" коллекция (например, категории)
IEnumerable<TInner> inner, // "Внутренняя" коллекция (например, продукты)
Func<TOuter, TKey> outerKeySelector, // Как из внешнего элемента получить ключ
Func<TInner, TKey> innerKeySelector, // Как из внутреннего элемента получить ключ
Func<TOuter, IEnumerable<TInner>, TResult> resultSelector // Фабрика для создания результирующего объекта/записи
)
- outer: коллекция, по которой происходит обход, и к которой присоединяются элементы (например, категории).
- inner: коллекция, из которой выбираются присоединяемые элементы (например, продукты).
- outerKeySelector: лямбда, возвращающая ключ для элемента "слева".
- innerKeySelector: лямбда, возвращающая ключ для элемента "справа".
- resultSelector: функция, позволяет определить, как будет выглядеть результат для каждой пары (левый+группа правых).
3. Практический пример: категории и продукты
Допустим, у нас есть такие модели:
public class Category
{
public int Id { get; set; }
public string Name { get; set; }
}
public class Product
{
public string Name { get; set; }
public int CategoryId { get; set; }
}
Коллекции для примера:
var categories = new List<Category>
{
new Category { Id = 1, Name = "Хлеб" },
new Category { Id = 2, Name = "Напитки" },
new Category { Id = 3, Name = "Мясо" }
};
var products = new List<Product>
{
new Product { Name = "Батон", CategoryId = 1 },
new Product { Name = "Пепси", CategoryId = 2 },
new Product { Name = "Чай", CategoryId = 2 },
new Product { Name = "Колбаса", CategoryId = 3 }
};
Использование GroupJoin (Method Syntax)
var groupJoin = categories.GroupJoin(
products,
category => category.Id, // ключ категории
product => product.CategoryId, // ключ продукта
(category, prods) => new // строим результат на лету
{
CategoryName = category.Name,
Products = prods.Select(p => p.Name).ToList() // список имен продуктов этой категории
}
);
Как обходить результат:
foreach (var group in groupJoin)
{
Console.WriteLine($"Категория: {group.CategoryName}");
foreach (var product in group.Products)
{
Console.WriteLine($" - {product}");
}
}
Вывод:
Категория: Хлеб
- Батон
Категория: Напитки
- Пепси
- Чай
Категория: Мясо
- Колбаса
4. GroupJoin: Query Syntax (синтаксис запросов)
LINQ поддерживает синтаксис, похожий на SQL. Для group join используется ключевое слово join ... into ..., и такой запрос работает почти так же как пример выше.
var groupJoin2 = from c in categories
join p in products on c.Id equals p.CategoryId into prodGroup
select new
{
CategoryName = c.Name,
Products = prodGroup.Select(p => p.Name).ToList()
};
Это очень похоже на SQL-запрос с LEFT OUTER JOIN ... GROUP BY.
Визуальная схема: как работает GroupJoin
[Категория] [Product] Группировка (GroupJoin)
Хлеб --------> Батон => Хлеб: [Батон]
Напитки --------> Пепси => Напитки: [Пепси, Чай]
Напитки --------> Чай
Мясо --------> Колбаса => Мясо: [Колбаса]
Каждая категория получает свой “кармашек” (IEnumerable<Product>), в который попадают все продукты этой категории.
5. Особенности и подводные камни
GroupJoin vs. обычный Join
Разница между обычным Join и GroupJoin — в количестве результатов. Join возвращает по одной паре на каждое совпадение, а GroupJoin — по одному элементу для каждого элемента внешней коллекции, внутри которого — коллекция всех совпавших элементов.
Если в нашей схеме окажется категория без продуктов, с GroupJoin она всё равно появится, просто её коллекция продуктов будет пуста. Это поведение аналогично LEFT OUTER JOIN в SQL (левое внешнее соединение).
Вот пример с категорией без продуктов:
categories.Add(new Category { Id = 4, Name = "Сыры" });
var groupJoin3 = categories.GroupJoin(
products,
c => c.Id,
p => p.CategoryId,
(c, prods) => new
{
CategoryName = c.Name,
Products = prods.Select(p => p.Name).ToList()
});
foreach (var group in groupJoin3)
{
Console.WriteLine($"Категория: {group.CategoryName}");
if (group.Products.Count == 0)
Console.WriteLine(" (Нет продуктов)");
else
foreach (var product in group.Products)
Console.WriteLine($" - {product}");
}
Категория: Хлеб
- Батон
Категория: Напитки
- Пепси
- Чай
Категория: Мясо
- Колбаса
Категория: Сыры
(Нет продуктов)
Такой сценарий часто востребован в бизнес-приложениях: нужно показать все категории (или группы), даже если в какой-то из них нет элементов.
Реализация через GroupBy? Нет!
Очень многие путаются, пытаясь “эмулировать” GroupJoin с помощью двойной GroupBy. Не стоит — GroupJoin именно для этих целей и придуман, и делает “левое объединение” нативно.
6. Применение с реальными данными
Дополнительно к предыдущим лекциям, давайте добавим в наше учебное приложение возможность вывода отчёта: “По каждой категории — список её продуктов”. Эта задача часто нужна в интернет-магазинах, CRM, учетных или отчётных системах.
Добавим код в наше демо-приложение:
// Предположим, что у нас есть классы Category и Product и коллекции уже созданы
Console.WriteLine("ОТЧЕТ ПО КАТЕГОРИЯМ И ПРОДУКТАМ:");
var categoryReport = categories.GroupJoin(
products,
cat => cat.Id,
prod => prod.CategoryId,
(cat, prods) => new
{
cat.Name,
ProductNames = prods.Select(p => p.Name).ToList()
});
foreach (var row in categoryReport)
{
Console.WriteLine($"Категория: {row.Name}");
if (row.ProductNames.Count == 0)
Console.WriteLine(" (Нет товаров)");
else
foreach (var prodName in row.ProductNames)
Console.WriteLine($" - {prodName}");
}
7. Вложенные группировки и работа с агрегатами
GroupJoin можно комбинировать с агрегатными функциями для реализации более сложных отчетов.
Пример: Посчитать количество товаров в каждой категории
var reportWithCount = categories.GroupJoin(
products,
category => category.Id,
product => product.CategoryId,
(category, prods) => new
{
Category = category.Name,
Count = prods.Count() // Агрегатная функция!
});
foreach (var rec in reportWithCount)
{
Console.WriteLine($"{rec.Category}: {rec.Count} товаров");
}
Допустим, коллекции categories и products содержат следующие данные:
var categories = new[]
{
new { Id = 1, Name = "Фрукты" },
new { Id = 2, Name = "Овощи" },
new { Id = 3, Name = "Молочные продукты" }
};
var products = new[]
{
new { Id = 1, Name = "Яблоко", CategoryId = 1 },
new { Id = 2, Name = "Банан", CategoryId = 1 },
new { Id = 3, Name = "Морковь", CategoryId = 2 }
};
Вывод в консоль будет таким:
Категория: Фрукты — 2 товар(ов)
Категория: Овощи — 1 товар(ов)
Категория: Молочные продукты — 0 товар(ов)
8. GroupJoin и типичные ошибки начинающих
Частая ошибка — ожидать, что результатом GroupJoin будет плоская таблица пар, как у обычного Join. Под "плоской" здесь имеется в виду структура, где каждая строка представляет собой одну пару: один внешний элемент и один соответствующий внутренний (вроде SQL-таблицы после INNER JOIN).
Это особенно сбивает с толку тех, кто немного работал с базами данных: они ждут, что GroupJoin будет вести себя как LEFT JOIN, но возвращать пары в строках, а не группы. Однако GroupJoin возвращает элемент из внешней коллекции и коллекцию из связанных внутренних элементов — по сути, вложенную структуру.
Не забывайте "раскрывать" вложенные коллекции при необходимости — например, с помощью SelectMany, если хотите получить обычную последовательность пар.
Еще одна распространенная ошибка — забыть, что для элементов без совпадения подгруппа будет просто пустым списком. Это поведение по умолчанию, а не ошибка — но о нём важно помнить, чтобы не удивляться, почему в выводе "ничего не происходит".
Когда использовать GroupJoin, а когда нет?
Используйте GroupJoin:
- Когда у вас есть два набора данных (например, отделы и сотрудники, категории и продукты) и нужно отобразить их иерархически: для каждого “родителя” все его “дети”.
- Для формирования сложных отчетов, где важно показать все основные элементы даже если “потомков” нет.
- Когда требуется аналог SQL LEFT OUTER JOIN с группировкой по ключу.
Не используйте GroupJoin там, где надо просто пересечь коллекции или получить ровно одну пару на совпадение — для этого есть обычный Join.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ