Wzorce projektowe: dekorator
Czasami przychodzi moment kiedy potrzebujemy istniejące w kodzie klasy uzupełnić o dodatkowe funkcjonalności jednocześnie nie psując już napisanego kodu. Przykładowo dodając logowanie każdej wywołanej metody w klasie, albo uzupełniając zapis do pliku o kompresję danych. Tego typu operacje możemy w prosty sposób dodać do już istniejącego kodu za pomocą wzorca dekorator, którym dzisiaj się zajmiemy.
Co to ten dekorator?
Na początek warto w ogóle wyjaśnić czym jest wzorzec dekoratora.
Otóż jest to wzorzec strukturalny, a więc taki, który opisuje w jaki sposób obiekt jest zbudowany.
Dekorator polega na opakowaniu bazowej klasy w klasę dekorującą. Wykorzystuje się do tego kompozycję. Wzorzec ten jest alternatywą dla dziedziczenia, które posiada szereg ograniczeń w tym zakresie. Przede wszystkim dekorator pozwala na dekorowanie w trakcie działania programu, a nie podczas kompilacji. Dodatkowo umożliwia „składanie” dekoratorów, a więc daje elastyczność w kwestii doboru zestawu nowych funkcjonalności.
Ważne w tym wzorcu jest to, że pierwotne zachowanie klasy, którą dekorujemy pozostaje bez zmian. Pozwala to zachować pełną kompatybilność z już istniejącym kodem i zapewnia transparentność naszego dekoratora.
Formalny diagram opisujący sposób budowy dekoratora prezentuje się następująco:
Jednak jak to z diagramami bywa – nie są dla niektórych najczytelniejszą formą przedstawiania budowy. Dlatego postaram się opisać wzorzec również słownie.
Po kryjomu
Cała „magia” działania dekoratora opiera się o wykorzystanie tego samego interfejsu, z którego korzysta klasa, którą chcemy dekorować. I tutaj jest już pierwszy warunek wymagany przy tym wzorcu – istnienie interfejsu dla klasy bazowej.
Sam dekorator w najprostszej postaci możemy wykonać na jednej klasie, która po prostu zaimplementuje ten sam interfejs co klasa bazowa. Dodatkowo musi ona przyjmować w jakiś sposób klasę, którą dekorujemy – zazwyczaj jest to wykonywane za pomocą konstruktora.
Tak więc zakładamy, że mamy interfejs i implementującą go klasę, którą używamy w naszym kodzie i chcemy ją wzbogacić o nowe funkcje nie zmieniając jej implementacji. Dodajemy nową klasę, która również implementuje nasz interfejs. Klasa ta przyjmuje w konstruktorze obiekt, którego typem jest implementowany interfejs (dzięki temu nie ograniczamy się do dekorowania jednej klasy, ale całego zbioru. Jest to ogromna zaleta pozwalająca nakładać dekoratory jeden na drugi). Klasa dekorująca musi w metodach z interfejsu wywołać takie same metody z obiektu przekazanego w konstruktorze – pozwala to zachować pełną funkcjonalność klasy dekorowanej.
Mając tak przygotowaną konstrukcję możemy dodawać nowe funkcjonalności. Czy to dodając osobne metody, które użyjemy w naszym kodzie modyfikując za ich pomocą obiekt, który dostaniemy, albo dokładając efekty uboczne do już istniejących metod. Dobrym przykładem jest tutaj dodanie logowania wywołania metody z obiektu albo np. modyfikowanie danych przekazywanych do metod obiektu.
Przykładowa implementacja
Przykładowa implementacja napisana będzie w języku C#.
Na początek mamy nasz interfejs i klasę, którą będziemy dekorować:
public interface IBaseType { void FancyMethod(); int Calculate(int a); } public class BaseClass : IBaseType { public void FancyMethod() { // DO SOMETHING } public int Calculate(int a) { return SomeSpecialValue(a); } }
Interfejs zawiera dwie metody, które są następnie implementowanie w klasie BaseClass. Teraz pora na dekorator:
public class Decorator : IBaseType { private IBaseType baseObject; public Decorator(IBaseType base) { baseObject = base; } void FancyMethod() { var logger = Logger.Instance(); logger.log("FancyMethod called"); base.FancyMethod(); } int Calculate(int a) { var modified = a + 2; return base.Calculate(modified); } }
Jak widać on również implementuje ten sam interfejs. Dodatkowo przyjmuje obiekt klas implementujących ten interfejs.
Następnie w implementacji metod wymaganych przez IBaseType oczywiście wywołujemy odpowiednie metody z przekazanego wcześniej obiektu. Dodatkowo jednak uzupełniamy ich działanie – w pierwszym przypadku dodajemy logowanie wywołania metody, zaś w drugim przypadku modyfikujemy przekazywaną wartość.
Teraz chcąc użyć naszego dekoratora możemy zrobić to w taki sposób:
var baseObject = new BaseClass(); IBaseType someObject = baseObject; someObject.FancyMethod(); someObject = new Decorator(baseObject); someObject.FancyMethod();
Ja chcę więcej!
Wspominałem już, że jedną z właściwości wzorca dekorator jest możliwość nakładania różnych dekoratorów tego samego interfejsu na siebie. Jest to bardzo proste jednak warto to również pokazać. W tym celu dodajmy jeszcze jeden dekorator:
public class AnotherDecorator : IBaseType { private IBaseType baseObject; public Decorator(IBaseType base) { baseObject = base; } void FancyMethod() { base.FancyMethod(); } int Calculate(int a) { return 2 + base.Calculate(modified); } void NewImportantMethod() { // DO IMPORTANT THINGS } }
Zostawia on metodę FancyMethod() w spokoju jednak dodaje całkowicie nową.
Teraz możemy nałożyć oba dekoratory na nasz obiekt. W takim wypadku lekko zmodyfikowany przykład przedstawiony powyżej będzie prezentował się następująco:
var baseObject = new BaseClass(); IBaseType someObject = baseObject; someObject.FancyMethod(); someObject = new AnotherDecorator(new Decorator(baseObject)); someObject.FancyMethod();
Dodane zostało jedynie tworzenie nowego dekoratora, który w konstruktorze przyjął poprzedni dekorator zawierający bazowy obiekt. W razie konieczności moglibyśmy te dekoratory zamienić ze sobą miejscami albo dodawać kolejne.
Niezły artykuł, Marek! Jest nowy polski katalog wzorców projektowych: https://refactoring.guru/pl/design-patterns
Super, dzięki za info :D Korzystałem z tej strony w wersji EN więc fajnie, że teraz jest też PL. Będę polecał początkującym :)
To nie zadziała jeżeli zamienisz miejscami dekoratory
someObject = new Decorator(new AnotherDecorator(baseObject));
Dodatkowa metoda wtedy zniknie i sameObject już nie będzie miał do niej dostępu,