Dekorator
Cel
Dekorator to strukturalny wzorzec projektowy pozwalający dodawać nowe obowiązki obiektom poprzez umieszczanie tych obiektów w specjalnych obiektach opakowujących, które zawierają odpowiednie zachowania.
Problem
Wyobraź sobie, że pracujesz nad biblioteką powiadamiającą, która pozwala innym programom informować użytkowników o istotnych zdarzeniach.
Wstępna wersja biblioteki bazowała na klasie Powiadamiacz
, która miała tylko parę pól, konstruktor oraz jedną metodę — Wyślij
. Metoda przyjmowała wiadomość jako argument od klienta i przesyłała tę wiadomość do listy emailów, które z kolei przekazywano powiadamiaczowi przez jego konstruktor. Aplikacja innego producenta, pełniąca rolę klienta, miała za zadanie stworzyć i skonfigurować obiekt powiadamiacza jednorazowo, potem zaś korzystać z niego w razie istotnych zdarzeń.
W jakimś momencie zauważasz, że użytkownicy biblioteki oczekują więcej, niż tylko przysłania maila. Wielu chce otrzymywać SMS gdy zdarzy się coś bardzo ważnego. Inni z kolei chcą być powiadamiani poprzez Facebooka, a użytkownicy korporacyjni byliby zachwyceni powiadomieniami Slack.
Czy to takie trudne? Wystarczy rozszerzyć klasę Powiadamiacz
i umieścić dodatkowe metody powiadamiania w nowych podklasach. Teraz klient powinien stworzyć instancję potrzebnej mu klasy powiadomień i może korzystać do woli.
Wszystko w porządku do momentu, aż ktoś zada całkiem sensowne pytanie: “Czemu nie da się przesłać powiadomienia wieloma drogami naraz? Przecież jeśli w twoim domu wybuchnie pożar, warto zastosować wszelkie możliwe opcje powiadamiania”.
Próbujesz więc spełnić to wymaganie tworząc specjalne podklasy łączące różne metody powiadamiania w jednej klasie. Jednakże, szybko okazuje się oczywiste, że wskutek tego podejścia kod strasznie spuchł, i to nie tylko po stronie biblioteki, ale i klienta.
Trzeba znaleźć jakiś inny pomysł na strukturę klas powiadomień, aby uniknąć wpisania do księgi rekordów Guinessa przy dodawaniu kolejnych możliwości powiadamiania.
Rozwiązanie
Rozszerzenie klasy jest pierwszym sposobem jaki przychodzi do głowy, gdy stajemy wobec konieczności zmiany zachowania się obiektu. Jednakże dziedziczenie wiąże się z wieloma obciążeniami, o których trzeba pamiętać.
- Dziedziczenie jest statyczne. Nie da się zmienić zachowania istniejącego obiektu po uruchomieniu programu. Można tylko zastąpić cały obiekt innym, stworzonym z innej podklasy.
- Podklasy mogą mieć tylko jedną klasę-rodzica. W większości języków nie można odziedziczyć zachowania wielu klas jednocześnie.
Jednym ze sposobów uniknięcia tych ograniczeń jest zastosowanie Agregacji lub Kompozycji zamiast Dziedziczenia. Obie alternatywy działają prawie tak samo: jeden z obiektów posiada odniesienie do innego i deleguje mu jakąś pracę, zaś w przypadku dziedziczenia, obiekt sam jest w stanie wykonać tę pracę, dziedzicząc zachowanie od swej nadklasy.
Dzięki temu nowemu sposobowi można łatwo zamienić obiekt, z którym istnieje połączenie, na inny, tym samym zmieniając zachowanie kontenera w czasie działania programu. Obiekt może korzystać z zachowań różnych klas, mając odniesienia do wielu obiektów i delegując im różne rodzaje zadań. Agregacja/kompozycja jest kluczową koncepcją wielu wzorców projektowych. Nie inaczej jest z Dekoratorem. Wróćmy więc do omówienia wzorca.
Dekorator znany jest też pod nazwą “Nakładka”. To słowo dobrze wyraża główną ideę tego wzorca. Nakładka jest obiektem który może być połączony z jakimś docelowym obiektem. Nakładka zawiera ten sam zestaw metod jak obiekt docelowy i deleguje mu wszelkie otrzymywane żądania. Jednak nakładka może wpłynąć na rezultat, wykonując coś albo przed, albo po przekazaniu żądania.
Kiedy więc prosta nakładka staje się prawdziwym dekoratorem? Jak wspomniałem, nakładka implementuje ten sam interfejs co “opakowywany” obiekt. Dlatego też z punktu widzenia klienta te obiekty są identyczne. Niech pole referencyjne nakładki przyjmuje każdy obiekt zgodny z tym interfejsem, pozwoli to wówczas “przykryć” obiekt wieloma warstwami, sumując tym samym zachowania każdej z nich.
W naszym przykładzie z powiadomieniami, zostawmy proste powiadomienie mailowe w klasie bazowej Powiadamiacz
, ale zmieńmy inne metody powiadamiania w dekoratory.
Kod kliencki musiałby opakować podstawowy obiekt powiadamiacza w zestaw dekoratorów stosowny do preferencji klienta. Wynikowy obiekt będzie miał strukturę stosu.
Ostatnim dekoratorem w stosie będzie obiekt, z którym klient faktycznie pracuje. Skoro wszystkie dekoratory implementują ten sam interfejs co powiadamiacz bazowy, reszta kodu klienta nie będzie musiała wiedzieć, czy pracuje na “czystym” obiekcie powiadamiacza, czy “udekorowanym”.
Moglibyśmy zastosować to samo podejście wobec innych obowiązków, jak formatowanie wiadomości lub komponowanie listy odbiorców. Klient może udekorować obiekt dowolnymi dekoratorami, o ile będą one zgodne ze sobą co do interfejsu.
Analogia do prawdziwego życia
Noszenie ubrań jest przykładem stosowania dekoratorów. Gdy ci zimno, zakładasz sweter. Jeśli dalej ci zimno, zakładasz jeszcze kurtkę. A jeśli do tego pada deszcz, możesz założyć płaszcz przeciwdeszczowy. Wszystkie te elementy ubioru “rozszerzają” twoje domyślne zachowanie, ale nie są częścią ciebie i możesz pozbyć się każdego z nich gdy nie jest ci akurat potrzebny.
Struktura
-
Komponent deklaruje interfejs wspólny zarówno dla nakładek, jak i opakowywanych obiektów.
-
Konkretny Komponent to klasa opakowywanych obiektów. Definiuje ona podstawowe zachowanie, które następnie można zmieniać za pomocą dekoratorów.
-
Klasa Bazowy Dekorator posiada pole przeznaczone na referencję do opakowywanego obiektu. Typ pola powinien być zadeklarowany jako interfejs komponentu, aby mogło przechować zarówno konkretne komponenty, jak i inne dekoratory. Dekorator bazowy deleguje wszystkie działania opakowywanemu obiektowi.
-
Konkretni Dekoratorzy definiują dodatkowe zachowania które można przypisać do komponentów dynamicznie. Konkretni dekoratorzy nadpisują metody dekoratora bazowego i wykonują swoje działania albo przed, albo po wywołaniu metody klasy-rodzica.
-
Klient może opakowywać komponenty w wiele warstw dekoratorów, o ile działa na wszystkich obiektach poprzez interfejs komponentu.
Pseudokod
W tym przykładzie, wzorzec Dekorator pozwala skompresować i zaszyfrować wrażliwe dane niezależnie od kodu który faktycznie korzysta z tych danych.
Aplikacja opakowuje źródło danych w parę dekoratorów. Obie nakładki zmieniają sposób, w jaki dane są zapisywane na i odczytywane z dysku:
-
Tuż przed zapisaniem na dysk danych, dekoratory szyfrują i kompresują je. Pierwotna klasa zapisuje do pliku dane już zaszyfrowane i skompresowane — bez wiedzy o dokonanej obróbce.
-
Tuż po odczytaniu z dysku danych, przechodzą one przez te same dekoratory, które je dekompresują i deszyfrują.
Dekoratory i klasa źródła danych implementują ten sam interfejs, co czyni je wymienialnymi w kodzie klienta.
Zastosowanie
Stosuj wzorzec Dekorator gdy chcesz przypisywać dodatkowe obowiązki obiektom w trakcie działania programu, bez psucia kodu, który z tych obiektów korzysta.
Dekorator pozwala ustrukturyzować logikę biznesową w formie warstw, tworząc dekorator dla każdej warstwy i składać obiekty z różnymi kombinacjami tej logiki w czasie działania programu. Kod klienta może traktować wszystkie obiekty w taki sam sposób, ponieważ wszystkie są zgodne pod względem wspólnego interfejsu.
Stosuj ten wzorzec gdy rozszerzenie zakresu obowiązków obiektu za pomocą dziedziczenia byłoby niepraktyczne, lub niemożliwe.
Wiele języków programowania posiada słowo kluczowe final
, za pomocą którego uniemożliwia się dalsze rozszerzanie klasy. W przypadku klasy finalnej, jedynym sposobem na ponowne wykorzystanie istniejącego zachowania jest opakowanie jej nakładkami swojego autorstwa — zgodnie ze wzorcem Dekorator.
Jak zaimplementować
-
Upewnij się, że twoja domena biznesowa może zostać przedstawiona w formie podstawowego komponentu z nałożonymi nań wieloma opcjonalnymi warstwami.
-
Ustal jakie metody są wspólne zarówno dla podstawowego komponentu, jak i warstw opcjonalnych. Stwórz interfejs komponentu i zadeklaruj tam owe wspólne metody.
-
Stwórz klasę konkretnego komponentu i zdefiniuj w niej podstawowe zachowanie.
-
Stwórz bazową klasę dekoratora. Powinna ona zawierać pole do przechowywania odniesienia do opakowywanego obiektu. Pole takie powinno być zadeklarowane jako typ interfejsu komponenta, aby umożliwić wiązanie z konkretnymi komponentami oraz dekoratorami. Dekorator bazowy musi delegować pracę obiektowi opakowywanemu.
-
Upewnij się, że wszystkie klasy implementują interfejs komponentu.
-
Stwórz konkretne dekoratory poprzez rozszerzanie dekoratora bazowego. Konkretny dekorator musi wykonywać swoje zadania przed lub po wywołaniu metody rodzica (który zawsze deleguje opakowywanemu obiektowi).
-
Kod kliencki musi być odpowiedzialny za tworzenie dekoratorów oraz składanie ich wedle swoich potrzeb.
Zalety i wady
- Można rozszerzać zachowanie obiektu bez tworzenia podklasy.
- Można dodawać lub usuwać obowiązki obiektu w trakcie działania programu.
- Możliwe jest łączenie wielu zachowań poprzez nałożenie wielu dekoratorów na obiekt.
- Zasada pojedynczej odpowiedzialności. Można podzielić klasę monolityczną, która implementuje wiele wariantów zachowań, na mniejsze klasy.
- Zabranie jednej konkretnej nakładki ze środka stosu nakładek jest trudne.
- Trudno jest zaimplementować dekorator w taki sposób, aby jego zachowanie nie zależało od kolejności ułożenia nakładek na stosie.
- Kod wstępnie konfigurujący warstwy może wyglądać brzydko.
Powiązania z innymi wzorcami
-
Adapter zapewnia zupełnie inny interfejs dostępu do istniejącego obiektu. Z drugiej strony, w przypadku wzorca Dekorator interfejs albo pozostaje taki sam, albo zostaje rozszerzony. Ponadto, Decorator obsługuje rekurencyjną kompozycję, co nie jest możliwe w przypadku użycia Adapter.
-
Za pomocą Adapter można uzyskać dostęp do istniejącego obiektu za pośrednictwem innego interfejsu. W przypadku Pełnomocnik interfejs pozostaje taki sam. Za pomocą Dekorator uzyskuje się dostęp do obiektu za pośrednictwem ulepszonego interfejsu.
-
Łańcuch zobowiązań i Dekorator mają bardzo podobne struktury klas. Oba wzorce bazują na rekursywnej kompozycji w celu przekazania obowiązku wykonania przez ciąg obiektów. Istnieją jednak kluczowe różnice.
Obsługujący Łańcucha zobowiązań mogą wykonywać działania niezależnie od siebie. Mogą również zatrzymać dalsze przekazywanie żądania na dowolnym etapie. Z drugiej strony, różne Dekoratory mogą rozszerzać obowiązki obiektu zachowując zgodność z interfejsem bazowym. Dodatkowo, dekoratory nie mają możliwości przerwania przepływu żądania.
-
Kompozyt i Dekorator mają podobne diagramy struktur ponieważ oba bazują na rekursywnej kompozycji w celu zorganizowania nieokreślonej liczby obiektów.
Dekorator przypomina Kompozyt, ale posiada tylko jeden element podrzędny. Ponadto, kolejną różnicą jest to, że Dekorator przypisuje dodatkowe obowiązki opakowywanemu obiektowi, zaś Kompozyt jedynie “sumuje” wyniki otrzymane od elementów podrzędnych.
Wzorce mogą też współpracować: Dekorator może służyć rozszerzeniu zachowania określonego obiektu w drzewie Kompozytowym
-
Projekty intensywnie korzystające ze wzorców Kompozyt i Dekorator mogą skorzystać również na zastosowaniu Prototypu. Zastosowanie tego wzorca pozwala klonować złożone struktury zamiast konstruować je ponownie od zera.
-
Dekorator pozwala zmienić otoczkę obiektu, zaś Strategia jej wnętrze.
-
Dekorator i Pełnomocnik mają podobne struktury, ale inne cele. Oba wzorce bazują na zasadzie kompozycji — jeden obiekt deleguje część zadań innemu. Pełnomocnik dodatkowo zarządza cyklem życia obiektu udostępniającego jakąś usługę, zaś komponowanie Dekoratorów leży w gestii klienta.