Przeznaczenie
Dekorator pozwala na dynamiczne dodanie lub zmianę zachowania konkretnego obiektu danej klasy, bez wpływu na inne obiekty tej samej klasy. W pewnych przypadkach pozwala to znacząco ograniczyć liczbę klas, przez wyciągnięcie współdzielonego zachowania do klasy dekorującej, zamiast rozbudowy struktury dziedziczenia.
Zadaniem dekoratora jest “owinąć” (stąd inna nazwa: Wrapper
) oryginalny obiekt i zmodyfikować lub nadpisać jego zachowanie. Klasa dekoratora ma ten sam interfejs co obiekt dekorowany, dlatego dla klienta nie ma znaczenia czy obiekt został udekorowany, czy nie. Co do zasady, Dekorator raczej nie dodaje nowych publicznych metod, bo klient korzystający z interfejsu oryginalnego obiektu i tak nie miałby do nich dostępu. Posiadanie tego samego interfejsu co oryginalny obiekt ma tę zaletę, że dekoratory mogą się swobodnie zagnieżdżać i owijać jeden w drugi.
Dekorowanie odbywa się dynamicznie, w trakcie działania programu, a nie w czasie kompilacji. Obiekt dekorowany jest zwykle przekazywany w konstruktorze Dekoratora, ponieważ instancja Dekoratora
nie ma sensu sam z siebie, bez obiektu, który owija.
Implementacja
We wzorcu Dekorator
wyróżniamy kilka elementów:
Elementy:
- Client - klasa korzystająca z obiektu
Component
, zna tylko ten interfejs, a nie konkretne klasy, tym bardziej nie musi wiedzieć o istnieniu dekoratora - Component - interfejs obiektu, który
Decorator
dekoruje - ConcreteComponent - konkretna implementacja
Component
, która potem trafi w konstruktorze do dekoratora - Decorator - abstrakcyjna klasa implementująca interfejs
Component
i przyjmująca w konstruktorze obiekt z tym interfejsem. Użycie klasy abstrakcyjnej, zamiast tylko interfejsu pozwala wymusić konstruktor na dziedziczących klasach, a jednocześnie nie pozwala stworzyć instancji. JeśliDecorator
przyjmuje obiekty z ogólnym interfejsemComponent
a nie konkretne klasy, może dekorować całe rodziny obiektów. - ConcreteDecorator - konkretna implementacja dekoratora
Abstrakcyjna
W kodzie prezentuje się to mniej więcej tak:
interface Component {
// przykładowe metody dekorowanego interfejsu
fun methodA()
fun methodB()
}
// konkretna implementacja, może ich być wiele
class ConcreteComponent : Component {
override fun methodA() {}
override fun methodB() {}
}
// Dekorator `jest obiektem` Component i `posiada obiekt` Component
// pole `component` jest `protected` i dzięki temu dostępne dla konkretnych Dekoratorów
abstract class Decorator(protected val component: Component) : Component
// Konkretna implementacja dekoratora, wymuszony konstruktor przyjmujący obiekt Component
class ConcreteDecorator1(component: Component) : Decorator(component) {
// metody muszą być nadpisane
// w tym przypadku Dekorator wywołuje metody owijanego obiektu bez żadnych zmian
// czyli jest w zasadzie proxy
override fun methodA() = component.methodA()
override fun methodB() = component.methodB()
}
// kolejna implementacja Dekoratora
class ConcreteDecorator2(component: Component) : Decorator(component) {
override fun methodA(){
// w tej wersji obiektu nie można wykonać methodA()
// może to być połączone ze sprawdzaniem parametrów komponentu
throw Exception("you can't do this")
}
override fun methodB(){
println("running methodB")
component.methodB()
}
}
fun main(){
// "goły" obiekt `Component``
val component: Component = ConcreteComponent()
// pierwszy Dekorator owijający obiekt
val dec1: Component = ConcreteDecorator1(component)
// drugi Dekorator, owijający obiekt owinięty już w pierwszy
val dec2: Component = ConcreteDecorator2(dec1)
}
Widać tutaj jak łatwo można zagnieżdżać Dekoratory. W tej implementacji ConcreteDecorator1
to w zasadzie Proxy
bo wywołuje bez zmian metody z przekazanego obiektu Component
. Zwróć uwagę na ConcreteDecorator2
- methodA()
rzuca wyjątek. Dekorator może też zwracać stałą i w ogóle nie wykorzystywać przekazanego obiektu Component
. Dekorator
sam decyduje, jak się zachować. Na podstawie własności owijanego obiektu może zdecydować o rzuceniu wyjątku zamiast zwróceniu jakiejś wartości.
Delegaty
Kotlin ma wbudowane wsparcie dla wzorca delegacji, czyli kolejnego wzorca stawiającego kompozycję ponad dziedziczeniem. Ogólnie chodzi o to, żeby przekazywać (delegować) wykonanie zadania wynikającego z interfejsu klasy do jakiegoś obiektu będącego jej składnikiem, przekazanym w konstruktorze, lub wstrzykniętym przez DI. W ten sposób można sobie skomponować klasę z reużywalnych delegatów, zamiast powielać implementację lub tworzyć dziwne struktury dziedziczenia. Trochę taka metoda szablonowa tylko, że dla klasy — klasa szablonowa :)
Delegaty można wykorzystać do implementacji Dekoratora
. Delegaty w Kotlinie są tworzone przy użyciu słowa kluczowego by
:
// podstawowy interfejs do użycia z Dekoratorem
interface Component {
fun sayHello(): String
}
class ConcreteComponent1 : Component {
override fun sayHello() = "hello from ${javaClass.simpleName}"
}
// delegujemy zachowanie z interfejsu `Component` do obiektu `component` z konstruktora
abstract class Decorator(protected val component: Component): Component by component
class Decorator1(component: Component) : Decorator(component) {
// nadpisujemy metodę z komponentu, dodając informację z dekoratora
override fun sayHello() = "${component.sayHello()} and from ${javaClass.simpleName}"
}
// nie ma potrzeby za każdym razem nadpisywać metody `sayHello()`
// została "oddelegowana" do obiektu `component` z konstruktora
class Decorator2(component: Component) : Decorator(component)
fun main() {
val first: Component = ConcreteComponent1()
val decOne: Component = Decorator1(first)
val decTwo: Component = Decorator2(decOne)
println(first.sayHello()) // hello from ConcreteComponent1
println(decOne.sayHello()) // hello from ConcreteComponent1 and from Decorator1
println(decTwo.sayHello()) // hello from ConcreteComponent1 and from Decorator1 and from Decorator2
}
Alternatywnie, component
może nie być nawet polem w abstrakcyjnym Dekoratorze. Wtedy dziedziczące konkretne dekoratory mogą użyć super
, które oddeleguje wywołanie metody do obiektu component
z konstruktora.
abstract class AltDecorator(component: Component): Component by component
class Decorator1(component: Component) : Decorator(component) {
override fun sayHello() = "${super.sayHello()} and from ${javaClass.simpleName}"
}
Delegować można dowolną liczbę interfejsów, ale tylko interfejsów, a nie klas:
interface Component {
fun sayHello(): String
}
interface Component2{
fun sayBye(): String
}
abstract class Decorator(
protected val component: Component,
protected val component2: Component2
) :
Component by component,
Component2 by component2
Dzięki użyciu delegatów, konkretny Dekorator
może zawierać wyłącznie metody, które faktycznie chce zmienić. Abstrakcyjna klasa bazowa Dekorator
mogłaby też zawierać wywołania wszystkich metod na obiekcie dekorowanym, wtedy dziedziczące po niej konkretne klasy nadpisywałyby tylko wymagane dla siebie metody. Ale jeśli można to samo osiągnąć pojedynczym słówkiem by
to po co przepłacać? :)
Interfejsy komunikacyjne
Weźmy system wysyłający komunikaty tekstowe. Komunikaty mogą być wysłane przez Bluetooth lub TCP. Chcemy mieć możliwość wysłania samego tekstu, JSON-a oraz JSON-a z wiadomością zakodowaną Base64. Każdy rodzaj wiadomości powinien dać się przesyłać każdym medium. A z poziomu klienta nie powinno mieć znaczenia, jaki typ wiadomości i medium zostało wybrane.
Ok zacznijmy od sposobów komunikacji:
// ogólny interfejs komunikacyjny
interface Comm {
fun sendMessage(text: String): Result
}
// klasa obsługująca Bluetooth przy pomocy przekazanego BtBodule
class BtComm(val bt: BtModule) : Comm {
override fun sendMessage(text: String): Result {
return bt.send(text)
}
}
// klasa obsługująca TCP przy pomocy przekazanego TcpModule
class TcpComm(val tcpModule: TcpModule) : Comm {
override fun sendMessage(text: String): Result {
return tcpModule.send(text)
}
}
class TcpModule {
fun send(text: String): Result {
println("sending message via TCP: $text")
return Result.Success() // klasa sealed
}
}
class BtModule {
fun send(text: String): Result {
println("sending message via BT: $text")
return Result.Success()
}
}
Klienty będą znały wyłącznie ogólny interfejs Comm
, bez wiedzy, jaki to konkretnie sposób przesyłania wiadomości. Szczegółami przesyłania przez Bluetooth i TCP zajmą się osobne moduły. Z punktu widzenia interfejsu komunikacyjnego mamy tutaj fasadę na potencjalnie skomplikowane przesyłanie wiadomości, dzielenie na ramki, parowanie urządzeń, ustalenie adresu itd.
BtComm
i TcpComm
to konkretne klasy implementujące dekorowany interfejs, a nie dekoratory.
Użycie np. Bluetooth może wyglądać tak:
val btModule = BtModule()
val message = "hello"
val btComm: Comm = BtComm(btModule)
btComm.sendMessage(message) // sending message via BT: hello
Co spowoduje wysłanie gołego tekstu przez Bluetooth. Analogicznie wyglądałoby to dla TCP.
JsonDecorator
Chcemy móc wysłać wiadomość w formacie JSON zarówno przez BT, jak i TCP. Można do tego wykorzystać dekorator:
// w tym dekoratorze delegowany `comm` nie jest polem a dziedziczące klasy nie mają do niego dostępu
abstract class CommDecorator(comm: Comm) : Comm by comm
class JsonDecorator(comm: Comm) : CommDecorator(comm) {
override fun sendMessage(text: String): Result {
// przez `super` wywoływana jest metoda z abstrakcyjnego dekoratora,
// która deleguje wywołanie do przekazanego `comm`
return super.sendMessage("{\"message\":\"$text\"}")
}
}
I użycie:
val tcpModule = TcpModule()
val btModule = BtModule()
val message = "hello"
// owijamy komunikację TCP w dekorator JSON
val tcpJsonComm: Comm = JsonDecorator(TcpComm(tcpModule))
tcpJsonComm.sendMessage(message) // sending message via TCP: {"message":"hello"}
// dla BT analogicznie:
val btJsonComm: Comm = JsonDecorator(BtComm(btModule))
btJsonComm.sendMessage(message) // sending message via BT: {"message":"hello"}
Jeśli teraz doszedłby kolejny sposób komunikacji (Websockety, Firebase, gołąb pocztowy) i wiadomości powinny mieć taki sam format, to nie ma problemu. Po prostu udekoruje się nową implementację Comm
już gotowym dekoratorem. Podobnie, jeśli powstanie nowy format wiadomości, przy pomocy dekoratora można użyć znanych już sposobów przesyłania.
Base64Decorator
Ostatni dekorator posłuży do kodowania wiadomości przy użyciu Base64:
class Base46Decorator(comm: Comm) : CommDecorator(comm) {
override fun sendMessage(text: String): Result {
return super.sendMessage(prepareMessage(text))
}
private fun prepareMessage(text: String): String {
return Base64.getEncoder().encodeToString(text.toByteArray())
}
}
Ma nową prywatną metodę do przygotowania wiadomości. Nie rozszerza w ten sposób oryginalnego interfejsu, więc nie ma różnicy czy klient zna Comm
czy Base46Decorator
- ma dostęp do tych samych metod, więc raczej zdecyduje się na Comm
ze względu na łatwą podmienialność implementacji. Takie podprogowe zachęcanie do dobrych zachowań.
Kolejność dekoratorów
Użycie wszystkiego razem:
val tcpBase64JsonComm: Comm = JsonDecorator( // zapakuj wiadomość w strukturę JSON
Base46Decorator( // zakoduj całość Base64
TcpComm( // wyślij całość przez TCP
tcpModule
)
)
)
tcpBase64JsonComm.sendMessage(message) // sending message via TCP: eyJtZXNzYWdlIjoiaGVsbG8ifQ==
// inna kolejność dekoratorów
val tcpJsonBase64Comm: Comm = Base46Decorator( // zakoduj samą wiadomość Base64
JsonDecorator( // zapakuj całość w strukturę JSON
TcpComm( // wyślij całość przez TCP
tcpModule
)
)
)
tcpJsonBase64Comm.sendMessage(message) // sending message via TCP: {"message":"aGVsbG8="}
Jak widać, kolejność dekoratorów ma kolosalne znaczenie. Co się właściwie stało?
- tcpBase64JsonComm
- zapakuj wiadomość w strukturę JSON
- zakoduj całość Base64
- wyślij całość przez TCP
- tcpJsonBase64Comm
- zakoduj samą wiadomość Base64
- zapakuj całość w strukturę JSON
- wyślij całość przez TCP
Im głębiej znajduje się dekorator, tym później zostanie wywołany z wynikiem przetwarzania danych z wcześniejszych dekoratorów.
Jak już pisałem wcześniej, któryś z dekoratorów mógłby też rzucić wyjątkiem albo w ogóle nie użyć metody z dekorowanego obiektu i zwrócić np. stałą. Bywa to pomocne np. kiedy jakieś dane chcemy zaciemnić w zależności od tego, gdzie je wysyłamy. Przykładowo logi z aplikacji z użyciem klasy
data class LogEntry (val timestamp: Timestamp, val tag: String, val message: String)
- jeśli są przechowywane lokalnie niech mają wszystkie informacje
- jeśli są wysyłane do naszego serwisu przez HTTPS niech tylko wrażliwe dane będą zaciemnione
- jeśli na jakiś zewnętrzny monitoring to wysyłamy minimum informacji, może nawet samego TAG-a i timestamp.
Nie ma sensu rozszerzać implementacji logowania lub wysyłki logów o te funkcjonalności. Można stworzyć dekoratory na LogEntry
, które korzystając z wewnętrznej logiki, oczyszczałyby przed wysłaniem wiadomość. Zmiana polityki logowania lub dodanie nowych agregatorów nie spowoduje konieczności zmian interfejsów klas odpowiedzialnych za zbieranie czy wysyłkę logów, a jedynie podmianę dekoratorów.
Extension Methods
Wydaje się, że zamiast dodawać kolejne klasy i bawić się w delegaty można dopisać extension methods
do interfejsu i w ten sposób przygotować wiadomość do wysłania jako JSON i/lub Base64.
fun String.toJsonMessage(): String = "{\"message\":\"$this\"}"
fun String.toBase64(): String = Base64.getEncoder().encodeToString(this.toByteArray())
btComm.sendMessage(message.toBase64().toJsonMessage()) // sending message via BT: {"message":"aGVsbG8="}
Jednak teraz to klienty muszą wiedzieć, w jakiej formie wysyłać wiadomości, zamiast użyć udekorowanego obiektu z ogólnym interfejsem, który można im wstrzyknąć. Dodatkowo extension method
może zostać niejawnie nadpisane w innym miejscu projektu, więc nigdy nie ma pewności, że wywołanie toJsonMessage()
da taki sam rezultat. Pisałem już o tym w kontekście Wzorca Adapter. W tym przypadku korzystam z klasy String
, ale jeśli toJsonMessage()
zwracałoby obiekt JSON, to metoda toBase64()
musiałaby to uwzględnić — przy korzystaniu z dekoratorów interfejs jest zawsze ten sam i dekoratory nie muszą o sobie nic wiedzieć nawzajem.
Kod przy wykorzystaniu extension methods
jest znacznie prostszy niż z użyciem serii dekoratorów. Jednak jeśli metody rozszerzające zaczną być stosowane w wielu miejscach i z wieloma interfejsami, to ich utrzymanie może być kłopotliwe, podobnie jak testowanie. A jeśli nie będą stosowane nigdzie indziej, to może zwykłe metody w klasie wystarczą zamiast extension methods
. Chociaż przyznam, że czasami preferuję składnię instance.extMethod()
zamiast extMethod(instance)
.
Nazewnictwo
Zwykle nie lubie w nazwach klas sufixów pochodzących ze wzorców projektowych, ale w przypadku Dekoratora
dobrze mieć jasną informację o celu klasy. Mimo że interfejs klasy jest taki sam jak dekorowanego obiektu, to jednak sama instancja Dekoratora
nie ma sensu, w przeciwieństwie do obiektu dekorowanego.
Podsumowanie
The Decorator
pattern is used where creating separate classes which are a combination of all possibilities would result in their explosion. This pattern focuses on creating object layers to transparently and dynamically complement objects with new tasks. The decorator provides an object with the same interface as the decorated object.
While it is possible to add new public methods in the decorator, it may encourage clients to cast up or use the interface of a concrete decorator instead of a general component. The main advantage of the Decorator is its transparency to the customer, which is achieved by using the generic interface of the wrapped component.
In the example with communication interfaces, if not for the decorator, you would probably need to extend clients with new functionalities or introduce all variations of message processing and the way of sending it in separate classes.
Kotlin allows you to elegantly create decorators with delegates. Extension methods also kind of dekorują the original class, but are not a replacement or alternative to this pattern. Instead, they are an alternative to using the simple Java Wrappers
, which add new public methods to objects that we don’t want or can’t extend.
Konsekwencje
- zmiana zachowania obiektu bez dziedziczenia - dziedziczenie jest właściwe tylko w przypadkach, gdzie klasa pochodna jest podtypem klasy bazowej (relacja na zasadzie generalizacja->specjalizacja) - “Effective Java”. Pewnie dałoby się znaleźć takie połączenie pomiędzy
String
a pakietemTCP
, jednak byłoby to trochę naciągane i mało elastyczne. Szczególnie dodając po drodze formatowanie do JSONa i kodowanie Base64. Trudno byłoby też użyć kod ponownie w przypadku wiadomości przesyłanej przez Bluetooth. Dekorator przez kompozycję warstw pozwala elastycznie zmieniać zachowanie obiektu, bez zmiany tego, czym obiekt jest. - dynamiczne zmiany zachowania obiektu - obiekt można udekorować w trakcie działania programu, ponieważ nowe zachowania nie zmieniają interfejsu, a jedynie wpisują się w niego. Ten sam obiekt może w jednym miejscu być dekorowany jedną klasą, a w kolejnym drugą.
- SRP - duża monolityczna klasa z wieloma odpowiedzialnościami, może zostać przekształcona w zbiór dekoratorów używanych tylko tam, gdzie są potrzebne. Może to niestety prowadzić również do rzutowania w górę do interfejsu dekoratora lub odwoływaniu się do wewnętrznego dekorowanego obiektu z poziomu klienta (naruszenie prawa Demeter).
- kolejność stosowania dekoratorów jest ważna - przykład z wysłaniem wiadomości. Kolejność jest ważna, a jednocześnie same dekoratory nie wiedzą nic o sobie nawzajem. Odpowiedzialność tworzenia poprawnego zestawu dekoratorów powinna spoczywać na jakiejś np.
Fabryce
lubBuilderze
— o ile mamy do czynienia z rozbudowaną hierarchią.