Decorator Pattern w C# – przykład z System.IO.Stream
Kolejny wzorzec z serii opartej na Anatomii wzorca — 8 elementach opisu GoF. Tym razem Decorator — wzorzec, którego prawdopodobnie już używałeś, nawet o tym nie wiedząc, jeśli kiedykolwiek otwierałeś plik przez FileStream.
1. Intent (Zamierzenie)
Dołącza dodatkowe obowiązki do obiektu dynamicznie. Decorator to elastyczna alternatywa dla dziedziczenia jako sposobu rozszerzania funkcjonalności.
Krótko: zamiast tworzyć podklasę na każdą kombinację dodatkowych cech, owijasz obiekt innym obiektem o tym samym interfejsie, który dokłada zachowanie przed lub po przekazaniu wywołania dalej.
2. Also Known As (Inne nazwy)
Wrapper — Decorator dosłownie “owija” (wraps) opakowywany obiekt, zachowując ten sam interfejs na zewnątrz.
3. Motivation (Motywacja)
GoF opisuje TextView w bibliotece GUI, który czasem potrzebuje obramowania, czasem paska przewijania, czasem obu naraz. Rozwiązanie przez dziedziczenie wymagałoby klasy na każdą kombinację: BorderedTextView, ScrollableTextView, BorderedScrollableTextView — i eksplozji podklas przy każdej nowej cesze.
Decorator rozwiązuje to inaczej: BorderDecorator i ScrollDecorator owijają dowolny TextView, dokładając swoją cechę. Możesz je składać w dowolnej kombinacji w runtime, bez tworzenia ani jednej nowej klasy na każdą kombinację.
4. Applicability (Kiedy stosować)
Stosuj Decorator gdy:
- chcesz dodać obowiązki do pojedynczych obiektów dynamicznie i przezroczyście, bez wpływu na inne obiekty tej samej klasy,
- obowiązek powinien dać się później zdjąć,
- rozszerzanie przez dziedziczenie jest niepraktyczne — zbyt wiele niezależnych cech dałoby eksplozję podklas, albo klasa bazowa w ogóle nie jest dostępna do podklasowania.
Sygnał ostrzegawczy w kodzie: rosnąca liczba podklas różniących się tylko kombinacją włączonych/wyłączonych cech (KawaZMlekiem, KawaZCukrem, KawaZMlekiemICukrem…).
5. Structure (Struktura)
┌──────────────────┐
│ <<interface>> │
│ Component │
│──────────────────│
│ +Operation() │
└──────────────────┘
△
┌─────┴──────────────────┐
┌──────────────────┐ ┌──────────────────┐
│ ConcreteComponent│ │ Decorator │
│──────────────────│ │──────────────────│
│ +Operation() │ │ -component │
└──────────────────┘ │ +Operation() │
└──────────────────┘
△
┌─────────┴─────────┐
┌──────────────────┐ ┌──────────────────┐
│ConcreteDecoratorA│ │ConcreteDecoratorB│
│──────────────────│ │──────────────────│
│+Operation() │ │ +Operation() │
└──────────────────┘ └──────────────────┘
Decorator implementuje ten sam interfejs co Component i jednocześnie trzyma referencję do owijanego Component. Wywołanie Operation() na dekoratorze robi coś dodatkowego, a potem (albo najpierw) deleguje do owiniętego obiektu.
6. Participants (Uczestnicy)
- Component — wspólny interfejs dla obiektów, które można dekorować.
- ConcreteComponent — podstawowy obiekt, do którego dokładane są dodatkowe obowiązki.
- Decorator — trzyma referencję do
Componenti implementuje ten sam interfejs. - ConcreteDecorator — dokłada konkretny obowiązek przed/po wywołaniu owiniętego obiektu.
7. Collaborations (Współpraca)
Decorator przekazuje żądania do swojego obiektu Component. Może wykonać dodatkowe operacje przed lub po przekazaniu żądania.
Kluczowe: Decorator nie zastępuje Component — deleguje do niego, dokładając własne zachowanie na wejściu lub wyjściu.
8. Consequences (Konsekwencje)
Plus: więcej elastyczności niż statyczne dziedziczenie — obowiązki można dodawać i zdejmować w runtime. Plus: unika przeładowanych klas wysoko w hierarchii — zamiast tego budujesz prosty rdzeń i doklejasz małe dekoratory. Minus: system złożony z wielu małych, podobnych do siebie obiektów jest trudniejszy do nauki i debugowania. Minus: dekorator i opakowywany obiekt nie są tożsame (
==nie zadziała jak oczekujesz), a kolejność nakładania dekoratorów bywa istotna i łatwo się pomylić.
Decorator w C# — od diagramu do kodu
Scenariusz
Kawiarnia sprzedaje napar z opcjonalnymi dodatkami: mleko, syrop, bita śmietana — każdy zwiększa cenę i dopisuje się do opisu.
Bez wzorca — eksplozja podklas
public class Kawa { public virtual decimal Cena() => 8m; public virtual string Opis() => "Kawa"; }
public class KawaZMlekiem : Kawa { public override decimal Cena() => base.Cena() + 2m; public override string Opis() => base.Opis() + " + mleko"; }
public class KawaZMlekiemISyropem : KawaZMlekiem { public override decimal Cena() => base.Cena() + 3m; public override string Opis() => base.Opis() + " + syrop"; }
// ❌ KawaZSyropem, KawaZBita, KawaZMlekiemIBita... N dodatków = 2^N możliwych podklas
Każda nowa kombinacja dodatków to nowa klasa. Trzy dodatki dają już osiem możliwych kombinacji — osiem potencjalnych klas do napisania i utrzymania.
Z wzorcem — dekoratory składane w dowolnej kombinacji
public interface INapoj
{
decimal Cena();
string Opis();
}
public class Kawa : INapoj
{
public decimal Cena() => 8m;
public string Opis() => "Kawa";
}
public abstract class DodatekDecorator : INapoj
{
protected readonly INapoj _napoj;
protected DodatekDecorator(INapoj napoj) => _napoj = napoj;
public abstract decimal Cena();
public abstract string Opis();
}
public class MlekoDecorator : DodatekDecorator
{
public MlekoDecorator(INapoj napoj) : base(napoj) { }
public override decimal Cena() => _napoj.Cena() + 2m;
public override string Opis() => _napoj.Opis() + " + mleko";
}
public class SyropDecorator : DodatekDecorator
{
public SyropDecorator(INapoj napoj) : base(napoj) { }
public override decimal Cena() => _napoj.Cena() + 3m;
public override string Opis() => _napoj.Opis() + " + syrop";
}
INapoj zamowienie = new Kawa();
zamowienie = new MlekoDecorator(zamowienie);
zamowienie = new SyropDecorator(zamowienie);
Console.WriteLine($"{zamowienie.Opis()}: {zamowienie.Cena()} zł");
// "Kawa + mleko + syrop: 13 zł"
Zero nowych klas na kombinację — MlekoDecorator i SyropDecorator można łączyć w dowolnej kolejności i liczbie, w runtime, na podstawie tego, co klient faktycznie zamówił.
Decorator, którego już używasz — System.IO.Stream
Jeśli kiedykolwiek pisałeś coś w stylu:
using Stream plik = File.OpenRead("dane.csv.gz");
using Stream bufor = new BufferedStream(plik);
using Stream rozpakowany = new GZipStream(bufor, CompressionMode.Decompress);
using var reader = new StreamReader(rozpakowany);
— używałeś Decorator Pattern z biblioteki standardowej .NET. FileStream, BufferedStream i GZipStream implementują ten sam interfejs bazowy Stream, a każdy kolejny owija poprzedni, dokładając buforowanie albo dekompresję. Możesz je składać w dowolnej kombinacji — dokładnie tak samo jak MlekoDecorator i SyropDecorator powyżej.
Pułapka: kolejność dekoratorów ma znaczenie
// Kolejność 1: najpierw bufor, potem dekompresja — poprawnie
new GZipStream(new BufferedStream(plik), CompressionMode.Decompress);
// Kolejność 2: najpierw dekompresja, potem próba buforowania nieistniejących
// jeszcze danych — semantycznie co innego, może nie działać jak oczekujesz
new BufferedStream(new GZipStream(plik, CompressionMode.Decompress));
Dekoratory nie są przemienne jak dodawanie liczb — A(B(x)) to nie to samo co B(A(x)), jeśli kolejność operacji ma znaczenie (dekompresja przed czy po buforowaniu, logowanie przed czy po walidacji). Projektując stos dekoratorów, świadomie zdecyduj, w jakiej kolejności mają się wykonywać.
Pułapka: dekorator zmienia tożsamość obiektu
INapoj kawa = new Kawa();
INapoj ozdobiona = new MlekoDecorator(kawa);
Console.WriteLine(ReferenceEquals(kawa, ozdobiona)); // False — to dwa różne obiekty
Po udekorowaniu masz do czynienia z nowym obiektem opakowującym oryginalny, nie z tym samym obiektem z dodaną cechą. Jeśli gdzieś w kodzie porównujesz referencje albo polegasz na konkretnym typie (is Kawa), dekorator to złamie — sprawdzaj przez interfejs INapoj, nie przez konkretny typ.
Podsumowanie
Decorator dokłada zachowanie do obiektu przez owijanie go innym obiektem o tym samym interfejsie, zamiast przez dziedziczenie — dzięki temu można łączyć dowolną liczbę niezależnych cech bez eksplozji podklas. To nie teoria z podręcznika: Stream w .NET jest zbudowany dokładnie w ten sposób. Dwie realne pułapki: kolejność nakładania dekoratorów bywa istotna, a dekorator to inny obiekt niż oryginał — porównuj przez interfejs, nie przez tożsamość referencji.
🚀 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ę →