Wiosenna WYPRZEDAŻ

Dekorator

Znany też jako: Nakładka, Wrapper, Decorator

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.

Wzorzec projektowy Dekorator

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

Struktura biblioteki przed zastosowaniem wzorca Dekorator

Program może korzystać z klasy powiadamiającej by wysyłać powiadomienia o ważnych wydarzeniach na określony zestaw adresów email.

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.

Struktura biblioteki po zaimplementowaniu dodatkowych rodzajów powiadamiania.

Każdy rodzaj powiadomienia zaimplementowano w osobnej podklasie powiadamiacza.

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.

Struktura biblioteki po stworzeniu klas kombinacji powiadomień

Kombinatoryczna eksplozja podklas.

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.

Dziedziczenie a agregacja

Dziedziczenie a agregacja

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.

Rozwiązanie za pomocą wzorca Dekorator

Różne metody powiadamiania stały się dekoratorami.

Kod kliencki musiałby opakować podstawowy obiekt powiadamiacza w zestaw dekoratorów stosowny do preferencji klienta. Wynikowy obiekt będzie miał strukturę stosu.

Aplikacje mogą tworzyć złożone konfiguracje stosów dekoratorów powiadamiaczy

Aplikacje mogą tworzyć złożone konfiguracje stosów dekoratorów powiadamiaczy.

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

Przykład wzorca Dekorator

Zyskujesz połączony efekt poszczególnych elementów ubioru.

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

Struktura wzorca projektowego DekoratorStruktura wzorca projektowego Dekorator
  1. Komponent deklaruje interfejs wspólny zarówno dla nakładek, jak i opakowywanych obiektów.

  2. Konkretny Komponent to klasa opakowywanych obiektów. Definiuje ona podstawowe zachowanie, które następnie można zmieniać za pomocą dekoratorów.

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

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

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

Struktura przykładu wzorca Dekorator

Przykład dekoratorów kompresujących i szyfrujących.

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.

// Interfejs komponentu definiuje działania które można
// modyfikować za pomocą dekoratorów.
interface DataSource is
    method writeData(data)
    method readData():data

// Konkretne komponenty dostarczają domyślnych implementacji
// działań. Może istnieć wiele odmian tych klas w całym
// programie.
class FileDataSource implements DataSource is
    constructor FileDataSource(filename) { ... }

    method writeData(data) is
        // Zapisz dane do pliku.

    method readData():data is
        // Wczytaj dane z pliku.

// Bazowa klasa dekorator ma taki sam interfejs jak inne
// komponenty. Głównym celem tej klasy jest zdefiniowanie
// interfejsu, który będzie opakowywał wszystkie konkretne
// dekoratory. Domyślna implementacja kodu opakowującego może
// zawierać pole służące przechowywaniu opakowywanego komponentu
// oraz narzędzia służące jego inicjalizacji.
class DataSourceDecorator implements DataSource is
    protected field wrappee: DataSource

    constructor DataSourceDecorator(source: DataSource) is
        wrappee = source

    // Dekorator bazowy po prostu deleguje całą pracę
    // opakowywanemu komponentowi. Dodatkową funkcjonalność
    // można dodać w formie kolejnych konkretnych dekoratorów.
    method writeData(data) is
        wrappee.writeData(data)

    // Konkretni dekoratorzy mogą wywoływać implementację
    // działania z nadklasy zamiast bezpośrednio z opakowywanego
    // obiektu. To podejście upraszcza rozszerzanie klas
    // dekoratorów.
    method readData():data is
        return wrappee.readData()

// Konkretne dekoratory wywołują metody opakowywanego obiektu,
// ale mogą wzbogacać ich funkcjonalność. Dekoratory mogą
// wykonywać swoje działania albo przed, albo po wywołaniu
// metody opakowywanego obiektu.
class EncryptionDecorator extends DataSourceDecorator is
    method writeData(data) is
        // 1. Zaszyfruj przekazane dane.
        // 2. Przekaż zaszyfrowane dane metodzie writeData
        // obiektu opakowywanego.

    method readData():data is
        // 1. Pobierz dane od metody readData obiektu
        // opakowywanego.
        // 2. Spróbuj odszyfrować dane jeśli są zaszyfrowane.
        // 3. Zwróć wynik.

// Można opakowywać obiekty wieloma warstwami dekoratorów.
class CompressionDecorator extends DataSourceDecorator is
    method writeData(data) is
        // 1. Skompresuj przekazane dane.
        // 2. Przekaż skompresowane dane metodzie writeData
        // opakowywanego obiektu.

    method readData():data is
        // 1. Pobierz dane od metody readData opakowywanego
        // obiektu.
        // 2. Spróbuj je zdekompresować jeśli są skompresowane.
        // 3. Zwróć wynik.


// Opcja 1. Prosty przykład zestawu dekoratorów.
class Application is
    method dumbUsageExample() is
        source = new FileDataSource("somefile.dat")
        source.writeData(salaryRecords)
        // Docelowy plik wypełniono danymi w formie otwartego
        // tekstu.

        source = new CompressionDecorator(source)
        source.writeData(salaryRecords)
        // Plik docelowy wypełniono skompresowanymi danymi.

        source = new EncryptionDecorator(source)
        // Zmienna source zawiera teraz:
        // Szyfrowanie > Kompresja > FileDataSource
        source.writeData(salaryRecords)
        // Plik zapisano zaszyfrowanymi i skompresowanymi
        // danymi.


// Opcja 2. Kod kliencki korzystający z zewnętrznego źródła
// danych. Obiekty SalaryManager nie znają szczegółów
// magazynowania danych. Pracują z już skonfigurowanym źródłem
// danych które otrzymały od konfiguratora aplikacji.
class SalaryManager is
    field source: DataSource

    constructor SalaryManager(source: DataSource) { ... }

    method load() is
        return source.readData()

    method save() is
        source.writeData(salaryRecords)
    // ...Inne przydatne metody...


// W aplikacji można złożyć różne stosy dekoratorów w trakcie
// działania programu — zależnie od konfiguracji lub środowiska
// uruchomieniowego.
class ApplicationConfigurator is
    method configurationExample() is
        source = new FileDataSource("salary.dat")
        if (enabledEncryption)
            source = new EncryptionDecorator(source)
        if (enabledCompression)
            source = new CompressionDecorator(source)

        logger = new SalaryManager(source)
        salary = logger.load()
    // ...

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ć

  1. Upewnij się, że twoja domena biznesowa może zostać przedstawiona w formie podstawowego komponentu z nałożonymi nań wieloma opcjonalnymi warstwami.

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

  3. Stwórz klasę konkretnego komponentu i zdefiniuj w niej podstawowe zachowanie.

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

  5. Upewnij się, że wszystkie klasy implementują interfejs komponentu.

  6. 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).

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

Przykłady kodu

Dekorator w języku C# Dekorator w języku C++ Dekorator w języku Go Dekorator w języku Java Dekorator w języku PHP Dekorator w języku Python Dekorator w języku Ruby Dekorator w języku Rust Dekorator w języku Swift Dekorator w języku TypeScript