1. Wprowadzenie
Kiedy pracujesz tylko z "płaską" listą obiektów (np. z listą naszych produktów albo użytkowników z poprzednich wykładów), wszystko jest dość proste: filtrujesz, przekształcasz, sortujesz. Ale w prawdziwych aplikacjach biznesowych, na rozmowach kwalifikacyjnych (a nawet w zadaniach domowych!) często pojawiają się kolekcje, które w środku mają inne kolekcje.
Na przykład mamy klasę User, która ma listę zamówień. Albo, powiedzmy, klasę Product, która ma listę opinii. No i zadanie: jak dostać listę wszystkich zamówień wszystkich użytkowników? Albo zebrać wszystkie opinie do wszystkich produktów? A jeśli trzeba dostać listę wszystkich produktów we wszystkich zamówieniach?
W takich sytuacjach zwykły Select i nawet magiczna moc C# czasem nie wystarcza. Tu na scenę wchodzi bohater — operator SelectMany, który zamienia "listę list" w po prostu "listę".
Analogicznie w kuchni
Gdybyś miał pudełko, w którym są jeszcze pudełka, a w nich ciastka, i twoim zadaniem byłoby wysypać wszystkie ciastka do jednej dużej miski. Nie brałbyś każdego pudełka i nie wysypywał z niego po jednym ciastku, tylko od razu wziąłbyś wszystkie pudełka i wszystkie ciastka — do miski.
Właśnie tym zajmuje się SelectMany: on "spłaszcza" kolekcję, zamieniając "pudełko z pudełkami" w jedną "miskę" z zawartością.
2. Przypomnijmy sobie przykład aplikacji
W poprzednich wykładach tworzyliśmy prostą aplikację do pracy z użytkownikami i produktami. Do tego wykładu dodamy kolekcje "zagnieżdżonych" elementów.
Załóżmy, że mamy klasy:
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; }
}
Załóżmy, że mamy listę użytkowników, z których każdy ma listę zamówień, a każde zamówienie — listę produktów.
Jak dostać wszystkie produkty ze wszystkich zamówień wszystkich użytkowników?
Załóżmy, że musimy zrobić ogólną listę wszystkich produktów — bez zagnieżdżenia, po prostu do jednej "miski". Jeśli spróbujemy użyć zwykłego Select, co wyjdzie?
List<User> users = ...; // załóżmy, że gdzieś już są dane
var allOrders = users.Select(u => u.Orders);
Typ zmiennej allOrders będzie... IEnumerable<List<Order>>. Dostaliśmy "listę list", czyli wiele kolekcji zamówień. Chcielibyśmy dostać jedną płaską listę wszystkich zamówień.
Można spróbować jeszcze raz:
var allProducts = users.Select(u => u.Orders)
.Select(oList => oList.Select(o => o.Products));
I co teraz? Teraz mamy "listę list list"! Wychodzi na to, że tylko komplikujemy strukturę, zamiast ją upraszczać.
3. Rozwiązanie: operator SelectMany
I tu właśnie tkwi cała moc SelectMany. Jego zadanie to "wyprostować" (flatten) zagnieżdżone kolekcje do jednej.
Ogólna składnia:
collection.SelectMany(item => item.KolekcjaWewnatrz)
W naszym przypadku:
var allOrders = users.SelectMany(u => u.Orders);
// Typ: IEnumerable<Order>
Teraz mamy po prostu sekwencję wszystkich zamówień wszystkich użytkowników.
Idźmy jeszcze głębiej! Wszystkie produkty ze wszystkich zamówień wszystkich użytkowników:
var allProducts = users
.SelectMany(u => u.Orders)
.SelectMany(o => o.Products);
// Typ: IEnumerable<Product>
A jeśli w jednej linii?
Tak, można i tak:
var allProducts = users.SelectMany(u => u.Orders.SelectMany(o => o.Products));
Taki łańcuch lepiej czytać po kawałku, zwłaszcza jeśli nie jesteś robotem.
4. Rozkładamy na dużym przykładzie
Stwórzmy dane testowe i zobaczmy na żywo, jak działa SelectMany.
var users = new List<User>
{
new User
{
Name = "Alicja",
Orders = new List<Order>
{
new Order
{
Id = 1,
Products = new List<Product>
{
new Product { Name = "Ciastko" },
new Product { Name = "Mleko" },
}
},
new Order
{
Id = 2,
Products = new List<Product>
{
new Product { Name = "Czekolada" }
}
}
}
},
new User
{
Name = "Bob",
Orders = new List<Order>
{
new Order
{
Id = 3,
Products = new List<Product>
{
new Product { Name = "Kawa" },
new Product { Name = "Herbata" }
}
}
}
}
};
"Wysypujemy" wszystkie produkty do jednej miski
var allProducts = users
.SelectMany(u => u.Orders)
.SelectMany(o => o.Products);
foreach (var product in allProducts)
{
Console.WriteLine(product.Name);
}
Wynik działania programu:
Ciastko
Mleko
Czekolada
Kawa
Herbata
Co się dzieje na każdym etapie?
- users.SelectMany(u => u.Orders) — bierzemy wszystkich użytkowników i "rozwijamy" ich zamówienia do jednej kolekcji zamówień (teraz mamy 3 zamówienia).
- .SelectMany(o => o.Products) — rozwijamy listę produktów ze wszystkich zamówień.
Uwaga: Gdybyśmy użyli zwykłego Select, dostalibyśmy kolekcję kolekcji (np. IEnumerable<List<Product>>). A dzięki SelectMany dostajemy "płaską" kolekcję produktów: IEnumerable<Product>.
5. Jak to wygląda na schemacie
Przed 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>)
Po pierwszym SelectMany(u => u.Orders)
IEnumerable<Order>: [Order #1, Order #2, Order #3]
Po drugim SelectMany(o => o.Products)
IEnumerable<Product>: [Ciastko, Mleko, Czekolada, Kawa, Herbata]
6. Porównanie: Select i SelectMany
| Select | SelectMany | |
|---|---|---|
| Zwraca | Kolekcję "kolekcji" | Płaską kolekcję |
| Przykład | → |
→ |
| Używamy | Gdy chcemy zachować strukturę zagnieżdżenia | Gdy potrzebujemy jednej płaskiej sekwencji |
Tabela: kiedy używać którego operatora
| Co trzeba zrobić | Czego użyć | Wynik |
|---|---|---|
| Dostać listę list (nie "spłaszczać") | Select | |
| Dostać jedną "płaską" sekwencję | SelectMany | |
| Dostać pary "rodzic-dziecko" | SelectMany z selektorem wyniku | |
| Tylko przekształcić elementy "płaskiej" kolekcji | Select | |
7. Przydatne niuanse
Składnia Query Syntax (dla fanów "SQL-owego stylu")
Dla tych, którzy cenią sobie wyrazistość — LINQ pozwala pisać zapytania także w składni podobnej do SQL:
var allProducts = from user in users
from order in user.Orders
from product in order.Products
select product;
Ten kod robi dokładnie to samo, co łańcuch SelectMany, tylko wygląda jak wielopoziomowe "from".
"Spłaszczanie" macierzy
Klasyczny przykład użycia SelectMany — praca z tablicą dwuwymiarową albo listą list.
List<List<int>> matrix = new List<List<int>>
{
new List<int> { 1, 2, 3 },
new List<int> { 4, 5 },
new List<int> { 6 }
};
Jak dostać jedną listę wszystkich liczb?
var flat = matrix.SelectMany(row => row);
foreach (var value in flat)
{
Console.Write(value + " "); // 1 2 3 4 5 6
}
8. Zaawansowana składnia
Czasem, używając przeciążenia SelectMany, można zwracać nie tylko element z podkolekcji, ale też info z zewnętrznej.
Przykład: trzeba dostać pary "Imię użytkownika — Nazwa produktu" dla każdego produktu w każdym zamówieniu.
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} zamówił {pair.ProductName}");
}
Tutaj drugi parametr lambdy to element z zewnętrznej kolekcji. Bardzo przydatne, żeby nie "zgubić" info z wyższych poziomów.
9. Praktyczne scenariusze
Wiele zamówień, wiele produktów
Jesteś programistą sklepu internetowego. Chcesz policzyć, ile unikalnych produktów kupili wszyscy klienci? Proszę:
var uniqueProductNames = users
.SelectMany(user => user.Orders)
.SelectMany(order => order.Products)
.Select(product => product.Name)
.Distinct();
foreach (var name in uniqueProductNames)
{
Console.WriteLine(name);
}
Tworzenie płaskiej kolekcji z hierarchii
Masz listę działów firmy, każdy dział ma pracowników. Chcesz dostać listę wszystkich pracowników w firmie:
var allEmployees = departments.SelectMany(d => d.Employees);
Praca z tekstem: "spłaszczanie" tablicy słów do znaków
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. Typowe błędy i niuanse
Teraz, gdy już poczułeś moc SelectMany, czas ostrzec przed kilkoma często spotykanymi błędami.
Najpopularniejszy — oczekiwać, że Select da "płaską" kolekcję. W rzeczywistości on zawsze zwraca kolekcję kolekcji, jeśli funkcja wewnętrzna zwraca kolekcję. W efekcie trzeba pisać podwójne pętle albo gubić się w typach.
Drugi błąd — nie zauważyć, że "spłaszczanie" powoduje utratę info o zewnętrznym elemencie: np. jeśli pracujesz tylko z produktami, nie dowiesz się, w jakim zamówieniu albo u jakiego użytkownika był, jeśli nie użyjesz przeciążenia z selektorem wyniku.
Trzeci niuans — jeśli twoje wewnętrzne listy mogą być null, przed użyciem SelectMany dodaj zabezpieczenie: albo filtrację, albo zamianę na pustą listę, bo inaczej dostaniesz NullReferenceException szybciej, niż zdążysz wcisnąć F5.
GO TO FULL VERSION