CodeGym /Kursy /C# SELF /Wyrównywanie kolekcji za pomocą

Wyrównywanie kolekcji za pomocą SelectMany

C# SELF
Poziom 31, Lekcja 4
Dostępny

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?

  1. 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).
  2. .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
users.Select(u => u.Orders)
→
IEnumerable<List<Order>>
users.SelectMany(u => u.Orders)
→
IEnumerable<Order>
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
IEnumerable<ICollection<T>>
Dostać jedną "płaską" sekwencję SelectMany
IEnumerable<T>
Dostać pary "rodzic-dziecko" SelectMany z selektorem wyniku
IEnumerable<CustomType>
Tylko przekształcić elementy "płaskiej" kolekcji Select
IEnumerable<TransformedType>

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.

2
Zadanie
C# SELF, poziom 31, lekcja 4
Niedostępne
Wyodrębnianie wszystkich przedmiotów z zagnieżdżonych list
Wyodrębnianie wszystkich przedmiotów z zagnieżdżonych list
1
Ankieta/quiz
Wprowadzenie do LINQ, poziom 31, lekcja 4
Niedostępny
Wprowadzenie do LINQ
Podstawy LINQ: proste zapytania i filtrowanie danych
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION