1. Введение
Иногда нам приходится работать с двумя разными коллекциями, которые связаны между собой каким-либо общим признаком. Например, у нас есть список заказов и отдельная коллекция покупателей. Как определить, какой заказ принадлежит какому покупателю? Для этого нужно, чтобы обе коллекции были связаны по общему идентификатору — например, по userId.
В реляционных базах данных такую задачу решает операция JOIN, которая объединяет строки из разных таблиц по совпадающим ключам. В LINQ есть аналогичный оператор — он тоже называется Join.
Представьте две таблицы: в первой — список читателей библиотеки с их идентификаторами, а во второй — список заказов книг, где у каждого заказа указан id читателя. С помощью join мы "склеиваем" эти таблицы по id, и на выходе получаем пары «читатель + его заказ».
Кстати, если вы думали, что join — это только для "баз данных", то вы много потеряли! В программировании объединять коллекции приходится часто, особенно когда работаешь с внешними системами или структурированными файлами (JSON, XML и прочими табличками Excel из бухгалтерии).
2. Сигнатура метода Join и принцип работы
Вот как выглядит метод Join:
public static IEnumerable<TResult> Join<TOuter, TInner, TKey, TResult>(
this IEnumerable<TOuter> outer, // первая коллекция (внешняя)
IEnumerable<TInner> inner, // вторая коллекция (внутренняя)
Func<TOuter, TKey> outerKeySelector, // как получить ключ из элемента внешней коллекции
Func<TInner, TKey> innerKeySelector, // как получить ключ из внутренней
Func<TOuter, TInner, TResult> resultSelector) // функция генерации результата (новый элемент)
На первый взгляд может показаться громоздким, но сейчас всё разложим по полочкам.
Как это работает:
Для каждого элемента из первой коллекции (outer) LINQ ищет подходящие (по ключу) элементы из второй (inner). Когда ключи совпали, вызывается resultSelector, и результат сохраняется в итоговую коллекцию.
3. Пример: Объединяем покупателей и их заказы
Заведём пару классов и коллекций. Эта идея — логичное продолжение нашего учебного приложения (пусть это будет магазинчик, который мы разрабатываем по ходу курса).
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = "";
}
public class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public string Product { get; set; } = "";
}
// Коллекция покупателей
var customers = new List<Customer>
{
new Customer { Id = 1, Name = "Вася" },
new Customer { Id = 2, Name = "Петя" },
new Customer { Id = 3, Name = "Маша" },
};
// Коллекция заказов
var orders = new List<Order>
{
new Order { Id = 101, CustomerId = 2, Product = "Книга" },
new Order { Id = 102, CustomerId = 1, Product = "Ручка" },
new Order { Id = 103, CustomerId = 2, Product = "Тетрадь" },
new Order { Id = 104, CustomerId = 3, Product = "Ластик" },
};
Теперь мы хотим получить всех покупателей с их заказами. Например, вывести: "Петя заказал Книга", "Петя заказал Тетрадь" и т.п.
Применяем Join
// Объединяем покупателей с заказами по customer.Id и order.CustomerId
var query = customers.Join(
orders,
customer => customer.Id, // Как получить ключ из покупателя
order => order.CustomerId, // Как получить ключ из заказа
(customer, order) => new // Что делать с совпавшими парами (создаём новый объект)
{
CustomerName = customer.Name,
Product = order.Product
}
);
// Выводим результат
foreach (var item in query)
{
Console.WriteLine($"{item.CustomerName} заказал {item.Product}");
}
Что будет выведено:
Вася заказал Ручка
Петя заказал Книга
Петя заказал Тетрадь
Маша заказала Ластик
Обратите внимание: если у покупателя несколько заказов, он появится в списке несколько раз — по одному на каждый заказ. Именно так и должно быть!
4. Таблица: Сравнение Join и GroupBy + SelectMany
| Операция | Результат | Сценарий использования |
|---|---|---|
|
Плоский список пар | Классический SQL JOIN. Каждая пара (совпадение) — отдельная строка. |
|
Группы с подколлекциями | Хотите получить "покупатель + все его заказы" в виде структуры "один — ко многим". |
Новички часто используют Join там, где нужна структура «покупатель → список заказов». Но Join работает построчно и не группирует данные. Для этого есть GroupJoin — о нём в следующей лекции. Или же использовать GroupBy.
5. Join в Query Syntax (SQL-стиле LINQ)
LINQ поддерживает два синтаксиса: метод-цепочки (Method Syntax, например Join(...)) и так называемый SQL-стиль (Query Syntax), который визуально напоминает обычные SQL-запросы.
Для некоторых разработчиков — особенно тех, кто раньше работал с базами данных — такой синтаксис бывает более наглядным:
var query2 =
from customer in customers
join order in orders
on customer.Id equals order.CustomerId
select new
{
CustomerName = customer.Name,
Product = order.Product
};
foreach (var item in query2)
{
Console.WriteLine($"{item.CustomerName} заказал {item.Product}");
}
Обратите внимание:
В Query Syntax используется ключевое слово equals — оператор == здесь не работает!
Это частый подвох на собеседованиях, особенно для начинающих 😉
6. Важные детали и подводные камни
Иногда возникает ситуация, когда не все элементы из коллекций попадают в итоговый результат. Это связано с тем, что метод Join реализует так называемый «inner join», то есть он объединяет только те элементы, у которых совпадают ключи. Если у какого-то покупателя нет заказов, его просто не будет в итоговом списке. Аналогично, если есть заказ с CustomerId, которого нет среди покупателей, такой заказ тоже не попадёт в результат.
Но что делать, если нужно получить всех покупателей, даже тех, у кого заказов нет? Для таких случаев существует «левый» join, который в LINQ реализуется с помощью комбинации GroupJoin и SelectMany. Об этом мы поговорим в следующей лекции. В классическом же Join обязательно, чтобы обе стороны были представлены — иначе совпадения не произойдёт, и элемент выпадет из результата.
7. Работа с несколькими ключами (composite key)
Иногда объединять коллекции нужно не по одному, а по нескольким полям. Например: соединить товары и их продажи по "Код-товара + Год-продажи".
В LINQ это решается с помощью создания анонимных объектов в качестве ключей:
var sales = ...; // продажи
var products = ...; // товары
var query = products.Join(
sales,
prod => new { prod.Code, prod.Year },
sale => new { sale.ProductCode, sale.Year },
(prod, sale) => new { prod.Name, sale.Amount }
);
Важно, чтобы типы и имена свойств в этих анонимных объектах совпадали. Если они отличаются, совпадений найдено не будет — даже если значения по смыслу одинаковы.
8. Как выглядит объединённая коллекция — схема
Вот схема, иллюстрирующая работу join (очень упрощённо):
flowchart LR
subgraph Customers
A1["Вася (Id=1)"]
A2["Петя (Id=2)"]
A3["Маша (Id=3)"]
end
subgraph Orders
B1["Ручка (CustomerId=1)"]
B2["Книга (CustomerId=2)"]
B3["Тетрадь (CustomerId=2)"]
B4["Ластик (CustomerId=3)"]
end
A1-->|Id=1|B1
A2-->|Id=2|B2
A2-->|Id=2|B3
A3-->|Id=3|B4
Из схемы видно, что каждый покупатель соединяется со всеми своими заказами — при условии совпадения ключей.
9. Кратко об ошибках и особенностях
Одна из самых частых ошибок — перепутать порядок параметров, особенно если типы ключей в обеих коллекциях совпадают. При этом компилятор или LINQ не выдадут ошибку, но результат может оказаться пустым или неожиданным.
Ещё одна распространённая ловушка — попытка с помощью Join сделать «левое» объединение, чтобы в результате остались все покупатели, даже если у них нет заказов. Однако классический Join работает только с совпадающими парами и не включает элементы без соответствий.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ