Composite Pattern w C# — anatomia GoF i struktury drzewiaste

Kolejny wzorzec z serii opartej na Anatomii wzorca — 8 elementach opisu GoF. Tym razem Composite — wzorzec, który pozwala traktować pojedynczy obiekt i całe drzewo obiektów dokładnie tak samo.
1. Intent (Zamierzenie)
Składa obiekty w struktury drzewiaste reprezentujące hierarchie część-całość. Pozwala klientom traktować pojedyncze obiekty i ich kompozycje jednakowo.
Krótko: liść (plik) i węzeł z dziećmi (katalog) mają wspólny interfejs, więc kod klienta nie musi pytać „czy to pojedynczy element, czy grupa”.
2. Also Known As (Inne nazwy)
Wzorzec nie ma powszechnej drugiej nazwy — po polsku bywa nazywany Kompozyt.
3. Motivation (Motywacja)
GoF opisuje edytor grafiki: proste kształty (linia, tekst) i grupy kształtów. Użytkownik chce przesunąć albo przeskalować zarówno pojedynczy kształt, jak i całą grupę — tym samym poleceniem. Gdyby grupa i kształt miały różne interfejsy, kod obsługi musiałby wszędzie rozróżniać oba przypadki.
W .NET ten sam problem to system plików (plik vs katalog), menu z podmenu, struktura organizacyjna (pracownik vs zespół) czy drzewo komentarzy. Composite pozwala policzyć rozmiar katalogu tak samo, jak rozmiar pliku — bo katalog po prostu sumuje swoje dzieci.
4. Applicability (Kiedy stosować)
Stosuj Composite gdy:
- chcesz reprezentować hierarchie część-całość (drzewa),
- klient ma traktować pojedyncze obiekty i ich kompozycje jednakowo, bez rozróżniania w kodzie,
- struktura jest z natury rekurencyjna (element może zawierać elementy tego samego typu).
5. Structure (Struktura)
┌──────────────────┐
│ <<interface>> │
┌──────────> │ Component │ <───────────┐
│ │──────────────────│ │
│ │ +Operation() │ │ dzieci
│ └──────────────────┘ │
│ △ △ │
│ ┌──────────┘ └──────────┐ │
┌──────────────┐ ┌──────────────────┐ │
│ Leaf │ │ Composite │──┘
│──────────────│ │───────────────────│
│ +Operation() │ │ -children │
└──────────────┘ │ +Operation() │
│ +Add() +Remove() │
└──────────────────┘Leaf nie ma dzieci — wykonuje operację bezpośrednio. Composite trzyma listę Component (którymi mogą być liście lub inne composite) i realizuje operację, delegując ją do dzieci.
6. Participants (Uczestnicy)
- Component — wspólny interfejs dla liści i kompozytów; deklaruje operację wykonywaną na całości.
- Leaf — element bez dzieci; implementuje operację wprost.
- Composite — element z dziećmi; implementuje operację, składając wyniki dzieci; zarządza kolekcją potomków.
- Client — operuje na strukturze wyłącznie przez interfejs
Component.
7. Collaborations (Współpraca)
Klient używa interfejsu Component do interakcji z obiektami. Gdy odbiorcą jest Leaf, żądanie obsługiwane jest wprost. Gdy odbiorcą jest Composite, przekazuje on żądanie swoim dzieciom, zwykle wykonując przed lub po tym dodatkowe operacje (np. sumowanie).
Rekurencja jest naturalna: Composite deleguje do dzieci, z których każde może być kolejnym Composite.
8. Consequences (Konsekwencje)
Plus: klient traktuje liście i kompozyty jednakowo — mniej warunków w kodzie. Plus: łatwo dodać nowy typ liścia lub kompozytu bez zmiany klienta. Plus: struktura jest otwarta na dowolną głębokość. Minus: trudniej ograniczyć, jakie typy dzieci może mieć dany kompozyt — jednolity interfejs bywa „zbyt ogólny”, by wymusić reguły przez typy.
—
Composite w C# — od diagramu do kodu
### Scenariusz
Chcemy policzyć całkowity rozmiar katalogu — sumę rozmiarów wszystkich plików w nim i w podkatalogach, na dowolną głębokość.
### Component, Leaf, Composite
public interface IElementSystemu
{
string Nazwa { get; }
long Rozmiar(); // ta sama operacja dla pliku i katalogu
}
// Leaf — plik
public class Plik : IElementSystemu
{
public string Nazwa { get; }
private readonly long _rozmiar;
public Plik(string nazwa, long rozmiar) => (Nazwa, _rozmiar) = (nazwa, rozmiar);
public long Rozmiar() => _rozmiar; // wprost
}
// Composite — katalog
public class Katalog : IElementSystemu
{
public string Nazwa { get; }
private readonly List<IElementSystemu> _dzieci = new();
public Katalog(string nazwa) => Nazwa = nazwa;
public void Dodaj(IElementSystemu element) => _dzieci.Add(element);
public long Rozmiar() => _dzieci.Sum(d => d.Rozmiar()); // rekurencyjna suma dzieci
}var projekt = new Katalog("projekt");
projekt.Dodaj(new Plik("readme.md", 2_000));
var src = new Katalog("src");
src.Dodaj(new Plik("Program.cs", 5_000));
src.Dodaj(new Plik("Model.cs", 3_000));
projekt.Dodaj(src); // katalog wewnątrz katalogu
Console.WriteLine(projekt.Rozmiar()); // 10 000 — plik i katalog liczone tak samoRozmiar() na katalogu i na pliku wygląda dla klienta identycznie. Katalog.Rozmiar() nie wie, czy jego dziecko to plik, czy kolejny katalog — pyta o Rozmiar() przez wspólny interfejs, a rekurencja robi resztę.
Idiom C# — LINQ i rekurencja
Sercem Composite w C# jest rekurencyjne przejście po dzieciach — a LINQ (Sum, SelectMany) zwięźle je wyraża. _dzieci.Sum(d => d.Rozmiar()) zastępuje ręczną pętlę, a jeśli potrzebujesz spłaszczyć całe drzewo do listy, SelectMany z rekurencyjnym wywołaniem przechodzi je w głąb. To ten sam mechanizm, który stoi za wieloma operacjami na kolekcjach.
Pułapka: przezroczystość vs bezpieczeństwo
Gdzie umieścić Add() i Remove()? GoF nazywa to dylematem przezroczystości i bezpieczeństwa:
- Przezroczysto —
Add/Removew interfejsieComponent. Klient traktuje liść i kompozyt identycznie, alePlik.Add(...)nie ma sensu i musi rzucać wyjątek (bezpieczeństwo typów cierpi). - Bezpiecznie —
Add/Removetylko wComposite. Nie da się dodać dziecka do liścia, ale klient musi rzutowaćComponentnaKatalog, żeby dodawać (przezroczystość cierpi).
Powyższy kod wybrał wariant bezpieczny (Dodaj jest tylko w Katalog). Nie ma tu darmowego lunchu — wybierasz świadomie, co jest ważniejsze w Twoim przypadku.
Wariant: liść jako „pusty kompozyt”
Czasem zamiast osobnej klasy Leaf modeluje się liść jako kompozyt bez dzieci — upraszcza to hierarchię, ale zaciera semantykę „to jest element niepodzielny”. Wybór zależy od tego, czy rozróżnienie liść/węzeł niesie znaczenie w Twojej domenie (plik ≠ pusty katalog), czy jest tylko szczegółem implementacji.
Podsumowanie
Composite modeluje strukturę drzewiastą część-całość, dając liściom i węzłom wspólny interfejs — dzięki czemu klient traktuje pojedynczy obiekt i całe poddrzewo jednakowo, a operacje (jak sumowanie rozmiaru) realizuje rekurencja. W C# rekurencyjne delegowanie do dzieci najzwięźlej wyraża LINQ. Główny dylemat projektowy to umiejscowienie Add/Remove: w interfejsie (przezroczyście, kosztem bezpieczeństwa typów) albo tylko w kompozycie (bezpiecznie, kosztem przezroczystości) — to świadomy wybór, nie przeoczenie.
🚀 Co dalej?
Zobacz to w praktyce na wideo i pobierz darmową roadmapę, żeby ułożyć naukę w spójną ścieżkę do pierwszej pracy.
- 🗺️ Pobierz darmową roadmapę Junior .NET Developer — 12 kroków od podstaw C# do pierwszej pracy: dev-hobby.pl
- 🎬 Subskrybuj kanał YouTube — nowe filmy co tydzień.
Zamień wiedzę w umiejętności
Pobierz darmową Roadmapę .NET i ułóż takie tematy jak ten w spójną ścieżkę do pierwszej pracy.
Pobieram roadmapę →