Wzorce projektowe w programowaniu – wzorzec Dekorator

Problem do rozwiązania.

Wzorce projektowe w programowaniu – wzorzec Dekorator. W tym wpisie pochylimy się nad kolejnym wzorcem projektowym. Rozważmy następujący, przykładowy problem. Mamy wypożyczalnię pojazdów – załóżmy na ten moment, że są to tylko samochody. W wypożyczalni można wypożyczyć różne marki samochodów: Mercedes, Fiat, Renault, VW, Audi. Wypożyczenie samochodu różnych marek ma oczywiście różną cenę. Spójrzmy na poniższy pseudodiagram UML.

Wzorzec Dekorator

Pojazd to klasa abstrakcyjna, której podklasami są poszczególne rodzaje pojazdów oferowanych przez wypożyczalnię. Zmienna obiektowa „opis” w tejże klasie abstrakcyjnej jest ustawiana w każdej podklasie (przechowuje opis danego pojazdu np. „Ekskluzywny, dużo pali”). Metoda pobierzOpis() zwraca wartość zmiennej „opis”. Metoda „koszt()” jest abstrakcyjna i poszczególne podklasy powinny posiadać jej własne implementacje.

Każda marka ma także różne warianty wyposażenia. Mogą one posiadać skórzaną tapicerkę, klimatyzację automatyczną, listwy LED, kamera cofania itp. (załóżmy dla uproszczenia, że dany wariant wyposażenia ma stałą cenę, niezależnie w jakiej marce się znajduje).

Wzorzec Dekorator
Poszczególne klasy posiadą własną metodę „koszt()”, która oblicza cenę danej marki samochodu przy uwzględnieniu wyposażenia.

Nastąpiła wręcz eksplozja klas, ponieważ każda z podklas dziedziczy z klasy nadrzędnej. teraz wyobraźmy sobie sytuację, że należy zmienić cenę jednego wariantu wyposażenia np. klimatyzacji. Spowoduje to, iż konieczne będzie zaimplementowanie tychże zmian w każdej klasie.

Aby uniknąć takiego zamieszania, jak przedstawiono powyżej, spróbujmy innego rozwiązania. Spójrzmy na poniższy diagram.

Wzorzec Dekorator

Metoda koszt() w klasie nadrzędnej wylicza całkowitą cenę dodatkowego wyposażenia w pojeździe. Natomiast przesłaniające ją metody koszt() w podklasach rozszerzają jej funkcjonalność, pozwalając dołączyć cenę danej marki pojazdu (a więc wywołują metodę koszt() z klasy nadrzędnej i do wyniku dodają cenę dodatkowego wyposażenia).

I takie rozwiązanie wydaje się o wiele lepsze – znacznie ograniczyliśmy liczbę podklas, nie ma już takiego bałaganu. Jednak i w tym przypadku pojawiają się wady, które wyjdą na jaw podczas chęci zmiany funkcjonalności. Jeżeli będziemy chcieli dodać do oferty nowe dodatkowe elementy wyposażenia, to należy zmodyfikować metodę koszt() w klasie nadrzędnej. A tego nie chcemy, ponieważ ingerujemy w już istniejący kod, który działa i którego lepiej nie ruszać podczas modyfikacji czy aktualizacji. Ponadto, dla niektórych nowo wprowadzonych pojazdów np. motor, część wyposażenia nie będzie odpowiednia i niepotrzebna (w przypadku motoru np. klimatyzacja).

Zastosowanie wzorca Dekorator

I tu dochodzimy do tytułu wpisu. Aby rozwiązać powyższe problemy można posłużyć się wzorcem Dekorator. Wykorzystuje on tzw. dekoratory, które niejako owijają obiekt główny.

Wzorzec Dekorator
Właśnie taka jest idea tego wzorca.

Należy pamiętać o kilku ważnych kwestiach:
– obiekty dekorujące są tego samego typu co obiekty dekorowane wzorzec dekorator,
– jeden obiekt podstawowy może zostać udekorowany jeden lub więcej razy,
– przy założeniu, że dekorator jest tego samego typu co obiekt dekorowany, możemy przekazywać obiekt już „owinięty” (udekorowany), zawierający wszelkie dodatki łącznie z podstawą, zamiast oryginalnego (samej podstawy),
– dekorator dodaje swoje własne zachowania przed delegowaniem do obiektu dekorowanego właściwego zadania i/lub po nim,
– obiekty mogą być dekorowane w dowolnym momencie (także w czasie działania programu).

Po uwzględnieniu omawianego wzorca diagram klas mógłby wyglądać tak.

Wzorzec Dekorator

Wiemy już, jak powinien wyglądać projekt wdrażający wzorzec Dekorator. Teraz zobrazujmy to przy pomocy kodu.

public abstract class Pojazd {
     String opis = "Nieznany pojazd";

     public String pobierzOpis() {
          return opis;
     }

     public abstract int koszt();
}

Klasa Pojazd jest klasą abstrakcyjną, która posiada dwie metody: pobierzOpis() oraz koszt().

Utwórzmy teraz abstrakcyjną klasę WyposazenieDekorator.

public abstract class WypozazenieDekorator extends Pojazd {
     public abstract String pobierzOpis();
}

Musimy zapewnić zgodność typów z klasą Pojazd, dlatego też powyższa klasa jest rozszerzeniem klasy Pojazd. Wymagamy też, aby poszczególne dekoratory posiadały metodę pobierzOpis().

Kod klas bazowych jest gotowy, więc zaimplementujmy poszczególne marki. Pamiętajmy, że każda marka powinna posiadać swój opis oraz mieć zaimplementowaną metodę koszt().

public class Mercedes extends Pojazd {
     public Mercedes () {
          opis = "Ekskluzywny, dużo pali";
     }
     public int koszt() {
          return 500;
     }
}

Pozostałe marki implementujemy analogicznie. Teraz pozostało nam utworzyć poszczególne dekoratory.

//Klimatyzacja jest dekoratorem więc dziedziczy po klasie WyposazenieDekorator
public class Klimatyzacja extends WyposazenieDekorator {
     Pojazd pojazd;
     
     public Klimatyzacja (Pojazd pojazd) {
          this.pojazd = pojazd;
     }

     public String pobierzOpis() {
     //do opisu marki dodajmy także opis danego dodatkowego wyposażenia
          return pojazd.pobierzOpis() + ", Klimatyzacja";
     }

     public int koszt() {
     // liczymy całkowitą cenę pojazdu. Załóżmy, że klimatyzacja to dodatkowe 50 zł.
          return pojazd.koszt() + 50;
     }
}

Mamy już wszystkie składowe, których potrzebujemy. Zbierzmy do wszystko razem i zastosujmy.

public class RentCar {
     public static void main(String []) {
          //wypożyczmy gołego Mercedesa
          Pojazd pojazd = new Mercedes();
          System.out.println(pojazd.pobierzOpis() + " " + pojazd.koszt() + " zł.");

          //wypożyczmy Mercedesa z klimatyzacją i kamerą cofania
          Pojazd pojazd2 = new Mercedes();
          pojazd2 = new Klimatyzacja(pojazd2);
          pojazd2 = new Kamera(pojazd2);
          System.out.println(pojazd2.pobierzOpis() + " " + pojazd2.koszt() + " zł.");

          //wypożyczmy Fiata z pełnym wypasem
          Pojazd pojazd3 = new Fiat();
          pojazd3 = new Klimatyzacja(pojazd3);
          pojazd3 = new LED(pojazd3);
          pojazd3 = new Tapicerka(pojazd3);
          pojazd3 = new Kamera(pojazd3);
          System.out.println(pojazd3.pobierzOpis() + " " + pojazd3.koszt() + " zł.");
     }
}

Efektem jest komunikat.

Przykład dekoratorów w języku Java.

W dostępnym pakiecie java.io napotykamy na ogromną liczbę klas, które w dużej mierze powstały w oparciu o wzorzec Dekorator. Poniżej przedstawiony został typowy zestaw obiektów, które wykorzystują dekoratory do modyfikacji zachowań przy odczycie danych z pliku.

Wzorzec Dekorator

Wyjaśnijmy powyższy schemat.
FileInputStream – jest dekorowanym elementem, odpowiadającym bezpośrednio za odczyt danych z pliku.
BufferedInputStream – jest dekoratorem. Klasa ta dodaje dwa rodzaje nowych zachowań. Po pierwsze, zapewnia buforowanie strumienia danych wejściowych (w skrócie poprawia wydajność). Po drugie, rozbudowuje interfejs, dodając do niego nową metodę readLine(), która pozwala na odczyt danych z pliku tekstowego po jednym wierszu naraz.
LineNumberInputStream – jest dekoratorem. Jej zadaniem jest umożliwienie policzenia odczytanych wierszy danych.
Klasy BufferedInputStream oraz LineNumberInputStream są rozszerzeniem klasy FileInputStream, która odgrywa rolę abstrakcyjnej klasy dekoratora.

Stworzenie własnego dekoratora.

Aby jeszcze bardziej przybliżyć funkcjonowanie omawianego wzorca, stwórzmy samodzielnie dekorator obsługi wejścia – wyjścia. W tym celu należy po prostu utworzyć klasę podrzędną do klasy FileInputStream (rozszerzającą funkcjonalność tej klasy) i przesłonić w niej metodę read().
Nasz dekorator będzie służył do konwersji wszystkich dużych liter na litery małe w strumieniu danych wejściowych.

public class LowerCaseInputStream extends FilterInputStream {

    public LowerCaseInputStream(InputStream in) {
        super((in));
    }

    public int read() throws IOException {
        int c = super.read();
        return (c == -1 ? c : Character.toLowerCase((char) c));
    }

    public int read(byte[] b, int offset, int len) throws IOException {
        int result = super.read(b, offset, len);
        for (int i = offset; i < offset + result; i++) {
            b[i] = (byte) Character.toLowerCase((char) b[i]);
        }
        return result;
    }
}

Nasz dekorator jest gotowy. Teraz pora na jego przetestowanie.

public class Test {

    public static void main(String[] args) {
        int c;
        try {
            InputStream in = 
                    new LowerCaseInputStream(
                            new BufferedInputStream(
                                    new FileInputStream("Test.txt")));
            while((c = in.read()) >= 0) {
                System.out.print((char)c);
            }
            in.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Wynikiem jest poniższy komunikat.

Definicja

Teraz, kiedy wiemy już na czy polega stosowanie wzorca Dekorator, możemy spojrzeć na jego definicję.

Dekorator – umożliwia dynamiczne przydzielenie wybranemu obiektowi nowych zachowań. Dekoratory dają elastyczność podobną do tej, jaką daje dziedziczenie, jednak oferują znacznie lepszą funkcjonalność.

Ciemna strona wzorca Dekorator.

Jak każde rozwiązanie, we wzorcu tym można także doszukać się wad:
– poprzez stosowanie dekoratorów w projekcie może pojawić się duża liczba małych klas, przez co projekt staje się bardziej zagmatwany i trudny do zrozumienia,
– pojawia się wyższy stopień złożoności kodu niezbędnego do prawidłowego tworzenia obiektów podstawowych poszczególnych klas (kiedy koszta się z dekoratorów trzeba nie tylko utworzyć sam obiekt danej klasy, ale także dodatkowo „opakować” go wieloma dekoratorami; jednak pomocne w rozwiązaniu tego problemu są dodatkowe wzorce „Fabryka” oraz „Budowniczy”.