1. Вступ
Коли ви працюєте лише з «пласким» списком об’єктів (наприклад, зі списком наших товарів чи користувачів із попередніх лекцій), усе досить просто: фільтруємо, трансформуємо, сортуємо. Але в реальних бізнес‑застосунках, на співбесідах (і навіть у домашніх завданнях!) часто трапляються колекції, які всередині містять інші колекції.
Наприклад, є клас User, який має список замовлень. Або, скажімо, клас Product, у якого є список відгуків. А тепер завдання: отримати список усіх замовлень усіх користувачів? Або зібрати всі відгуки до всіх товарів? А якщо потрібно отримати список усіх товарів у всіх замовленнях?
У таких ситуаціях звичайного Select і навіть магії C# іноді не вистачає. Тут на сцену виходить герой — оператор SelectMany, який перетворює «список списків» просто на «список».
Аналогія з кухні
Уявіть, що у вас є коробка, у якій ще коробки, а в них — печиво, і ваше завдання — висипати все печиво в одну велику миску. Ви б не брали кожну коробку й не висипали з неї по одному печиву — ви одразу висипали б усе печиво з усіх коробок до миски.
Саме це й робить SelectMany: він «розплющує» колекцію, перетворюючи «коробку з коробками» на одну «миску» з вмістом.
2. Згадаємо приклад застосунку
У минулих лекціях ми створювали простий застосунок для роботи з користувачами та продуктами. Для цієї лекції додамо колекції «вкладених» елементів.
Припустімо, маємо класи:
public class User
{
public string Name { get; set; }
public List<Order> Orders { get; set; }
}
public class Order
{
public int Id { get; set; }
public List<Product> Products { get; set; }
}
public class Product
{
public string Name { get; set; }
}
Припустімо, маємо список користувачів, у кожного з яких є список замовлень, а в кожного замовлення — список продуктів.
Як отримати всі продукти всіх замовлень усіх користувачів?
Припустімо, нам потрібно скласти загальний перелік усіх продуктів — без вкладеності, просто в одну «миску». Якщо спробувати застосувати звичайний Select, що отримаємо?
List<User> users = ...; // припустімо, що десь уже є дані
var allOrders = users.Select(u => u.Orders);
Тип змінної allOrders буде… IEnumerable<List<Order>>. Маємо «список списків», тобто багато колекцій замовлень. А ми хочемо отримати один плаский список усіх замовлень.
Можемо спробувати ще раз:
var allProducts = users.Select(u => u.Orders)
.Select(oList => oList.Select(o => o.Products));
Що тепер? Тепер у нас «список списків списків»! Виходить, ми лише ускладнюємо структуру, а не спрощуємо її.
3. Рішення: оператор SelectMany
Ось тут і криється вся сила SelectMany. Його завдання — «вирівняти» (flatten) вкладені колекції в одну.
Загальний синтаксис:
collection.SelectMany(item => item.КолекціяВсередині)
У нашому випадку:
var allOrders = users.SelectMany(u => u.Orders);
// Тип: IEnumerable<Order>
Тепер у нас просто послідовність усіх замовлень усіх користувачів.
Погляньмо ще глибше: усі продукти всіх замовлень усіх користувачів.
var allProducts = users
.SelectMany(u => u.Orders)
.SelectMany(o => o.Products);
// Тип: IEnumerable<Product>
А якщо в один рядок?
Так, можна й так:
var allProducts = users.SelectMany(u => u.Orders.SelectMany(o => o.Products));
Цей ланцюжок краще читати по частинах, особливо якщо ви — не робот.
4. Розберімо на великому прикладі
Створімо тестові дані й наочно подивімося, як працює SelectMany.
var users = new List<User>
{
new User
{
Name = "Аліса",
Orders = new List<Order>
{
new Order
{
Id = 1,
Products = new List<Product>
{
new Product { Name = "Печиво" },
new Product { Name = "Молоко" },
}
},
new Order
{
Id = 2,
Products = new List<Product>
{
new Product { Name = "Шоколад" }
}
}
}
},
new User
{
Name = "Боб",
Orders = new List<Order>
{
new Order
{
Id = 3,
Products = new List<Product>
{
new Product { Name = "Кава" },
new Product { Name = "Чай" }
}
}
}
}
};
«Висипаємо» всі продукти в одну миску
var allProducts = users
.SelectMany(u => u.Orders)
.SelectMany(o => o.Products);
foreach (var product in allProducts)
{
Console.WriteLine(product.Name);
}
Результат роботи програми:
Печиво
Молоко
Шоколад
Кава
Чай
Що відбувається на кожному етапі?
- users.SelectMany(u => u.Orders) — беремо всіх користувачів і «розгортаємо» їхні замовлення в одну колекцію замовлень (зараз у нас 3 замовлення).
- .SelectMany(o => o.Products) — розгортаємо список продуктів з усіх замовлень.
Увага: якби ми використали звичайний Select, отримали б колекцію колекцій (наприклад, IEnumerable<List<Product>>). А за допомогою SelectMany отримуємо «пласку» колекцію продуктів: IEnumerable<Product>.
5. Як це виглядає на схемі
До SelectMany
users (List<User>)
└── User #1
│ └── Orders (List<Order>)
│ ├── Order #1 -> Products (List<Product>)
│ └── Order #2 -> Products (List<Product>)
└── User #2
└── Orders (List<Order>)
└── Order #3 -> Products (List<Product>)
Після першого SelectMany(u => u.Orders)
IEnumerable<Order>: [Order #1, Order #2, Order #3]
Після другого SelectMany(o => o.Products)
IEnumerable<Product>: [Печиво, Молоко, Шоколад, Кава, Чай]
6. Порівняння: Select і SelectMany
| Select | SelectMany | |
|---|---|---|
| Повертає | Колекцію «колекцій» | Пласку колекцію |
| Приклад | → |
→ |
| Використовується | Якщо потрібно зберегти структуру вкладеності | Якщо потрібна одна пласка послідовність |
Таблиця: коли який оператор використовувати
| Що треба зробити | Що використовувати | Результат |
|---|---|---|
| Отримати список списків (не «розплющувати») | Select | |
| Отримати одну «пласку» послідовність | SelectMany | |
| Отримати пари «батько — дочірній елемент» | SelectMany із селектором результату | |
| Лише трансформувати елементи «пласкої» колекції | Select | |
7. Корисні нюанси
Синтаксис Query Syntax (для прихильників «SQL‑стилю»)
Для тих, хто особливо цінує виразність, LINQ дозволяє писати запити й у синтаксисі, схожому на SQL:
var allProducts = from user in users
from order in user.Orders
from product in order.Products
select product;
Цей код робить те саме, що й ланцюжок із SelectMany, тільки виглядає як багаторівневі «from».
«Розплющення» матриці
Класичний приклад застосування SelectMany — робота з двовимірним масивом або списком списків.
List<List<int>> matrix = new List<List<int>>
{
new List<int> { 1, 2, 3 },
new List<int> { 4, 5 },
new List<int> { 6 }
};
Як отримати один список усіх чисел?
var flat = matrix.SelectMany(row => row);
foreach (var value in flat)
{
Console.Write(value + " "); // 1 2 3 4 5 6
}
8. Розширений синтаксис
Іноді, використовуючи перевантаження SelectMany, можна повертати не лише елемент внутрішньої колекції, а й інформацію із зовнішньої.
Ось приклад: потрібно отримати пари «Імʼя користувача — Назва продукту» для кожного продукту в кожному замовленні.
var userProductPairs = users.SelectMany(
user => user.Orders.SelectMany(
order => order.Products,
(order, product) => new { UserName = user.Name, ProductName = product.Name }
)
);
foreach (var pair in userProductPairs)
{
Console.WriteLine($"{pair.UserName} замовив {pair.ProductName}");
}
Тут у селекторі результату ви маєте доступ і до елемента зовнішньої колекції, і до внутрішнього. Це зручно, щоб не «загубити» інформацію з зовнішніх рівнів.
9. Практичні сценарії
Багато замовлень, багато товарів
Ви — розробник інтернет‑магазину. Хочете порахувати, скільки унікальних товарів купили всі клієнти? Ось:
var uniqueProductNames = users
.SelectMany(user => user.Orders)
.SelectMany(order => order.Products)
.Select(product => product.Name)
.Distinct();
foreach (var name in uniqueProductNames)
{
Console.WriteLine(name);
}
Формування пласкої колекції з ієрархії
У вас є список відділів компанії, у кожного відділу — співробітники. Потрібно отримати список усіх співробітників по компанії:
var allEmployees = departments.SelectMany(d => d.Employees);
Робота з рядками: «розплющення» масиву слів на символи
string[] words = { "hello", "world" };
var allChars = words.SelectMany(w => w.ToCharArray());
foreach (var c in allChars)
{
Console.Write(c + " "); // h e l l o w o r l d
}
10. Типові помилки та нюанси
Тепер, коли ви надихнулися силою SelectMany, саме час попередити про кілька помилок, що часто трапляються.
Найпопулярніша — очікувати, що Select дасть «пласку» колекцію. Насправді він завжди повертає колекцію колекцій, якщо внутрішня функція повертає колекцію. У результаті доводиться писати подвійні цикли або плутатися в типах.
Друга помилка — не помічати, що «розплющення» призводить до втрати інформації про зовнішній елемент: наприклад, якщо ви працюєте лише з продуктами, ви не дізнаєтеся, у якому замовленні чи в якого користувача вони були, якщо не використовуєте перевантаження із селектором результату.
Третій нюанс — якщо ваші внутрішні списки можуть бути null, перед використанням SelectMany додайте захист: або фільтрацію, або заміну на порожній список, інакше отримаєте NullReferenceException навіть швидше, ніж ви натиснете F5.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ