sobota, 8 marca 2014

Dekorator - zmiany bez dziedziczenia

więc chodź, pomaluj mój świat

Jak obiecałem ostatnio pora zabrać się w końcu za wzorce. Pierwszym, nad którym się odrobinę poznęcamy będzie Dekorator.

Dekorator to wzorzec należący do rodziny wzorców strukturalnych i jego głównym zadaniem jest umożliwienie rozszerzenia funkcjonalności konkretnego obiektu poprzez "dodanie" do niej czegoś od siebie, rozwinięcie jego możliwość o to, co oferuje konkretny dekorator.
Zdaję sobie sprawę, że trochę enigmatycznie może to brzmieć, ale mam nadzieję, że po przeczytaniu całego wpisu, wszystko stanie się jasne i proste :)

jak on wygląda?

Jak już ostatnio pisałem, podstawowymi elementami wzorca są: jego nazwa, problem, który pomaga rozwiązać, sposób zastosowania tego rozwiązania oraz opis konsekwencji, zarówno pozytywnych jak i negatywnych, i postaram się w każdym artykule każdy z tych elementów Wam przybliżyć.

Nazwa wzorca i krótki jego opis pojawił się w pierwszym akapicie, teraz pozwolę sobie na krótki opis tego, jak sam wzorzec wygląda.
Dekorator składa się z interfejsu, czyli kontraktu, który musi zarówno spełnić konkretny, podstawy obiekt oraz wszystkie obiekty dekorujące.
Zastosowanie interfejsu daje nam sposobność do ukrycia przed użytkownikami komponentu (obiekt + dekoratory) wszystkich części składowych, ich interesuje jedynie to, co zostało zadeklarowane w interfejsie.

Kolejną składową implementacji wzorca jest konkretny komponent. To jest nasz punkt wyjścia i jesteśmy w stanie z niego korzystać bez żadnego dekorowania, jeżeli nie jest ono aktualnie nam do szczęścia potrzebne.
Bez instancji tej klasy cały wzorzec nie miałby sensu, ponieważ cały proces musi się od czegoś zaczynać :)

Następna jest abstrakcyjna klasa będąca podstawą dekoratora. Jedyną jej funkcją jest zapewnienie, że jednym z atrybutów dekoratora będzie obiekt, który jest instancją klasy implementującej nasz interfejs.
Na rysunku obok nie umieściłem żadnej metody, która służyłaby do wstrzyknięcia takiego obiektu, a to dlatego, że jest to już całkowicie zależne od Was, najważniejsze, że obiekt dekorowany ma zostać wykorzystany :)

I na samym końcu mamy wszelkiego rodzaju dekoratory, których ilość jest ograniczona jedynie Waszą wyobraźnią :) No i potrzebami :P

gdzie, kiedy i po co?

Ok, to już wiemy jak to wygląda i z czego się składa, ale prawda jest taka, że bez umiejętności zidentyfikowania problemów, które mogą zostać rozwiązane za pomocą tego wzorca, na nic on nam się nie przyda. Przejdźmy więc do rzeczy.

Jak pisałem wyżej, dekorator ma na celu rozszerzenie pewnej funkcjonalności. I tutaj pytanie pomocnicze - jaka podstawowa cecha języków obiektowych pozwala nam na osiągnięcie tego samego celu? Chwila niepewności, ciszy... jest to dziedziczenie!
Dekorator jest często wykorzystywany w celu zastąpienia dziedziczenia, po to by zapewnić większą dynamikę i możliwość zmian w trakcie działania aplikacji (dziedziczenie jest realizowane na etapie kompilacji). Dodatkowo dekorator zapewnia nam większą swobodę, ponieważ pozwala nam na dowolne łączenie dekoratorów (możliwe jest nawet wykorzystanie instacji tej samej klasy dekoracyjnej wielokrotnie), na wykorzystywnie ich w różnych powiązaniach.
Z powyższego wynika, że jeżeli mamy potrzebę, aby rozszerzenie nie było sztywno zaszyte w kodzie, aby nie było z góry narzucone i zdefiniowane, to wtedy warto rozważyć wykorzystanie omawianego wzorca.

Kolejną rzeczą, która może nas motywować jest fakt, że zmiejszamy zależności pomiędzy klsasami. Dziedziczenie jest najsilniejszym powiązaniem jakie istnieje i dobrą praktyką jest zmniejszać siłę tych powiązań, tam gdzie jest to możliwe. Wpływa to również bezpośrednio na łatwość rozszerzania funkcjonalności w przyszłości.

wady i zalety

Zalety rozwiązania są poniekąd nierozłączną częścią motywacji, która nami kieruje, gdy decydujemy się na konkretne rozwiązanie, tak więc szerzej są opisane w paragrafie wyżej. Tutaj jedynie krótkie ich podsumowanie:
  • Większa elastyczność, czyli możliwość dynamicznego określania sposobu dekoracji podczas wykonywania aplikacji.
  • Zmniejszenie zależności powiązania pomiędzy obiektami.

Niestety każde rozwiązanie niesie za sobą również pewne negatywne następstwa, które nie zawsze mogą być akceptowalne:
  • W pewnych przypadkach ilość obiektów dekorujących może okazać się bardzo (zbyt) duża.
  • Nie można polegać na jednakowości obiektów z racji możliwości wykorzystywania dekoratorów.

popatrzmy na kod

I w końcu długo wyczekiwany przykład :)

Wyobraźcie sobie aplikację do obsługi sklepów, która ma rozbudowany system zniżek, promocji, itp. Najgorsze, że wszystkie one mogą być dowolnie łączone, co tym bardziej komplikuje nasze zadanie.
Na obecną chwilę klient życzy sobie, aby istniały trzy typy zniżek:
  • Dla stałych klientów - każdemu klientowi przyznawana indywidualnie.
  • Sezonowa - 50% zniżki na produkt.
  • Jednorazowa - pracownik decyduje o jej kwocie.

Teraz czas na kod. Nasz interfejs:
public interface Valuable {
    int getPrice();
}

obiekt dekorowany:
class Product implements Valuable {
 
    private int price;
 
    public Product(int price) {
        this.price = price;
    }

    public int getPrice() {
        return price;
    }
}

klasa abstrakcyjna będąca podstawą dekoratorów:
abstract class Discount implements Valuable {

    private Valuable valuable;
 
    Discount(Valuable valuable) {
        this.valuable = valuable;
    }
 
    public int getPrice() {
        return valuable.getPrice() - getDiscount(valuable.getPrice());
    }

    abstract protected int getDiscount(int price);
}

kilka dekoratorów:
class Regular extends Discount {

    private Customer customer;
 
    Regular(Customer customer, Valuable valuable) {
        super(valuable);
        this.customer = customer;
    }
 
    @Override
    protected int getDiscount(int price) {
        return Math.round(price * customer.getDiscountPercentage() / 100);
    }
}

class OneTime extends Discount {

    private int discount;
 
    OneTime(int discount, Valuable valuable) {
        super(valuable);
        this.discount = discount;
    }
 
    @Override
    protected int getDiscount(int price) {
        return discount;
    }
}

class Seasonal extends Discount {

    Seasonal(Valuable valuable) {
        super(valuable);
    }
 
    @Override
    protected int getDiscount(int price) {
        return Math.round(price/2);
    }
}

oraz klasa i interfejs nie będące częścią wzorce, które jednak również wykorzystuję w przykładzie:
public interface Customer {
    int getDiscountPercentage();
}

public class Discounts {
 
    public static Valuable seasonal(Valuable valuable) {
        return new Seasonal(valuable);
    }
 
    public static Valuable oneTime(int discount, Valuable valuable) {
        return new OneTime(discount, valuable);
    }
 
    public static Valuable regular(Customer customer, Valuable valuable) {
        return new Regular(customer, valuable);
    }
}

A tutaj jeszcze kilka testów demonstrujących działanie całego komponentu:
@Test
public void withSeasonalDiscount() {
    Valuable withDiscount = Discounts.seasonal(getProduct());
 
    assertEquals(7, withDiscount.getPrice());
}

@Test
public void withOneTimeDiscount() {
    Valuable withDiscount = Discounts.oneTime(10, getProduct());
 
    assertEquals(3, withDiscount.getPrice());
}

@Test
public void withRegularDiscount() {
    Valuable withDiscount = Discounts.regular(getCustomer(), getProduct());
 
    assertEquals(12, withDiscount.getPrice());
}

@Test
public void withMixedDiscount() {
    Valuable withDiscount = Discounts.regular(getCustomer(), getProduct());
    withDiscount = Discounts.seasonal(withDiscount);
    withDiscount = Discounts.oneTime(2, withDiscount);
 
    assertEquals(4, withDiscount.getPrice());
}

@Test
public void withMixedDiscountWithDifferentOrder() {
    Valuable withDiscount = Discounts.oneTime(2, getProduct());
    withDiscount = Discounts.seasonal(withDiscount);
    withDiscount = Discounts.regular(getCustomer(), withDiscount);
 
    assertEquals(6, withDiscount.getPrice());
}

@Test
public void withMultipliedDiscount() {
    Valuable withDiscount = Discounts.oneTime(2, getProduct());
    withDiscount = Discounts.oneTime(1, withDiscount);
    withDiscount = Discounts.oneTime(3, withDiscount);
 
    assertEquals(7, withDiscount.getPrice());
}

Jak pokazują testy, działanie dekoratorów jest całkowicie przezroczyste, można je dowolnie łączyć, w dowolnej kolejności i absolutnie nas to nie interesuje, ponieważ my opieramy funkcjonalność, która będzie wykorzystywała dany komponent na interfejsie Valuable.

Mam nadzieję, że przykład jest wystarczająco prosty. Gdybyście jednak mieli jakieś pytania lub coś byłoby nie jasne to czekam na komentarze.

Uwagi do przykładu:
W implementacji pominąłem wszelkie sprawdzenia dotyczące kalkulacji na cenie oraz wykorzystywałem do jej przechowania zwykłego int'a. Oczywiście w prawdziwych aplikacjach nie możemy sobie pozwolić na takie zaniedbanie. Ja jednak zdecydowałem się na ten zabieg, ponieważ chciałem maksymalnie uprościć przykład.
Dodatkowo warto zwrócić uwagę na testy withMixedDiscount() oraz withMixedDiscountWithDifferentOrder(), które pomimo wykorzystania tych samych dekoratorów różnią się ceną końcową. Nie zawsze jest to porządane zachowanie i warto mięc to na uwadze.

i to by było na tyle

Dzisiaj to już wszystko, mam nadzieję, że udało mi się sprostać zadaniu i przybliżyć Wam wzorzec w taki sposób, że będziecie w stanie rozpoznać miejsca, w których warto go wykorzystać oraz będziecie w stanie to zrobić.

Jeżeli macie jakieś dalsze pytania bądź macie dla mnie jakieś sugestie na przyszłość będę za nie wdzięczny i czekam na Wasze komentarze.

Brak komentarzy:

Prześlij komentarz