🔥 Zapisy zamknięte, ale możesz pobrać Roadmapę .NET i dołączyć do listy oczekujących — Pobierz i dołącz do Listy VIP →

Composite Pattern w C# — anatomia GoF i struktury drzewiaste

Composite Pattern — drzewo katalogów i plików ze wspólnym interfejsem, rozmiar liczony rekurencyjnie w C#

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 samo

Rozmiar() 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:

  • PrzezroczystoAdd/Remove w interfejsie Component. Klient traktuje liść i kompozyt identycznie, ale Plik.Add(...) nie ma sensu i musi rzucać wyjątek (bezpieczeństwo typów cierpi).
  • BezpiecznieAdd/Remove tylko w Composite. Nie da się dodać dziecka do liścia, ale klient musi rzutować Component na Katalog, ż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.

👨‍💻
Mariusz Jurczenko
Senior .NET Developer · 10+ lat doświadczenia komercyjnego

Programista .NET z doświadczeniem komercyjnym w firmach takich jak NFZ, Kamsoft, Diagnostyka, Hermes Reply Polska czy Etisoft Smart Solutions. Twórca kursów, z których skorzystało już ponad 11 000 osób w Strefie Kursów i ponad 1 000 kursantów na dev-hobby.pl.

Specjalizacja: Clean Code, Clean Architecture i uczenie programowania tak, żeby dało się je naprawdę zrozumieć — nie wykuć.

🚀 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.

Dodaj komentarz

czytanie to początek

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ę →