Decorator

Wzorce strukturalne

w trakcie

1. Definicja

Dekorator to strukturalny wzorzec projektowy, który pozwala dynamicznie dodawać nowe zachowania do obiektów przez opakowywanie ich w specjalne klasy-opakowania, które implementują ten sam interfejs co opakowywany obiekt.

Attach additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality. — Gang of Four, Design Patterns

Najprościej rzecz ujmując: zamiast dziedziczyć, żeby dodać nowe zachowanie — owiń obiekt w kolejną warstwę.

Kluczowe słowo w definicji to dynamicznie — w przeciwieństwie do dziedziczenia, które ustala zachowanie klasy na etapie kompilacji, Dekorator pozwala decydować o zachowaniu obiektu w trakcie działania programu.


2. Przykład z życia: zobaczmy jak to zrozumieć?

Wyobraźmy sobie kawę w kawiarni.

Zamawiamy espresso. Kelner pyta: „Coś do tego?" Mówimy: mleko. Pyta dalej: „Może syrop waniliowy?" Czemu nie. „Bita śmietana na wierzch?" Tak, proszę.

Dostajemy filiżankę, w której bazowe espresso zostało kolejno owinięte w mleko, potem w syrop waniliowy, potem w bitą śmietanę. Każda warstwa dodaje coś do smaku i do ceny. Możemy dowolnie komponować — espresso z mlekiem, espresso z syropem bez mleka, sama czarna kawa. To nie są cztery różne kawy z czterech różnych klas — to jeden obiekt bazowy z dynamicznie nałożonymi warstwami.

Teraz wyobraźmy sobie alternatywę opartą na dziedziczeniu:

Kawa
├── KawaZMlekiem
├── KawaZSyropem
├── KawaZBitaSmietana
├── KawaZMlekiemISyropem
├── KawaZMlekiemIBitaSmietana
├── KawaZSyropemIBitaSmietana
└── KawaZMlekiemISyropemIBitaSmietana

Przy trzech dodatkach mamy już siedem podklas. Przy pięciu dodatkach — trzydzieści jeden. To jest eksplozja podklas — klasyczny sygnał, że dziedziczenie nie jest właściwym narzędziem do tego problemu.

Ćwiczenie myślowe: wyobraźmy sobie teraz system logowania w aplikacji. Mamy podstawowy logger, który zapisuje wiadomości do konsoli. Chcemy móc opcjonalnie:

  • dopisywać znacznik czasu do każdej wiadomości,
  • zapisywać logi równolegle do pliku,
  • filtrować logi poniżej określonego poziomu.

Każda kombinacja tych opcji to osobna konfiguracja. Jeśli nie chcemy dziedziczenia, potrzebujemy Dekoratora.


3. Przykład — naruszenie (bez Dekoratora)

Tworzymy system logowania. Początkowo logger zapisuje do konsoli. Potem pojawiają się nowe wymagania — logi z datą, logi do pliku, logi z obydwoma. Sięgamy po dziedziczenie.

// Klasa bazowa
class Logger {
    public void log(String wiadomosc) {
        System.out.println(wiadomosc);
    }
}
// Pierwsze wymaganie: logi ze znacznikiem czasu
class LoggerZCzasem extends Logger {
    @Override
    public void log(String wiadomosc) {
        System.out.println("[" + LocalDateTime.now() + "] " + wiadomosc);
    }
}

// Drugie wymaganie: logi zapisywane do pliku
class LoggerDoPliku extends Logger {
    @Override
    public void log(String wiadomosc) {
        System.out.println(wiadomosc); // uproszczone
        // zapis do pliku...
    }
}
// Trzecie wymaganie: czas ORAZ plik — musimy tworzyć kolejną podklasę
// Każda nowa kombinacja = nowa klasa
class LoggerZCzasemIDoPliku extends Logger {
    @Override
    public void log(String wiadomosc) {
        String zCzasem = "[" + LocalDateTime.now() + "] " + wiadomosc;
        System.out.println(zCzasem);
        // zapis do pliku...
        // Logika dodawania czasu zduplikowana z LoggerZCzasem!
    }
}

// Czwarte wymaganie: filtrowanie poziomu logów + czas + plik?
// Kolejna podklasa... i kolejna... i kolejna...
// Przy 4 opcjach mamy potencjalnie 15 kombinacji = 15 klas
public class Main {
    public static void main(String[] args) {

        // Wybór zachowania jest sztywny — ustalony na etapie kompilacji
        Logger logger = new LoggerZCzasemIDoPliku();
        logger.log("Aplikacja uruchomiona");

        // Chcemy zmienić konfigurację w trakcie działania? Niemożliwe.
        // Chcemy dodać nową opcję? Mnożymy podklasy.
    }
}

Co jest tutaj złe?

Każda nowa opcja logowania wymaga nowych podklas dla wszystkich kombinacji z już istniejącymi opcjami. Logika jest duplikowana między klasami. Zachowanie jest ustalane w kodzie — nie można go zmienić w czasie działania programu.

Konkretne konsekwencje:

  • eksplozja podklas — liczba klas rośnie wykładniczo wraz z liczbą opcji,
  • duplikacja kodu — logika dodawania znacznika czasu pojawia się w wielu klasach,
  • brak elastyczności — nie można zmienić zachowania loggera bez rekompilacji,
  • naruszenie OCP — dodanie nowej opcji wymaga modyfikacji lub tworzenia wielu klas.

4. Przykład — zgodny z wzorcem Dekorator

Podejdźmy do tego lepiej. Zamiast tworzyć podklasy dla każdej kombinacji, będziemy owijać logger w kolejne warstwy — jak cebula albo jak warstwy kawy.

// Wspólny interfejs — zarówno bazowy logger, jak i wszystkie dekoratory
// muszą go implementować, by można je było ze sobą łączyć
interface Logger {
    void log(String wiadomosc);
}
// Konkretna implementacja bazowa — robi jedną rzecz, bez żadnych ozdobników
class KonsolaLogger implements Logger {
    @Override
    public void log(String wiadomosc) {
        System.out.println(wiadomosc);
    }
}
// Abstrakcyjny dekorator — bazowa klasa dla wszystkich dekoratorów
// Implementuje Logger i trzyma referencję do opakowanego Loggera
// Dzięki temu każdy dekorator może owijać zarówno bazowy logger,
// jak i inny dekorator — to umożliwia dowolne łączenie warstw
abstract class LoggerDekorator implements Logger {

    // Opakowany obiekt — może być KonsolaLogger lub inny dekorator
    protected final Logger opakowany;

    public LoggerDekorator(Logger opakowany) {
        this.opakowany = opakowany;
    }

    // Domyślnie delegujemy wywołanie do opakowanego obiektu
    @Override
    public void log(String wiadomosc) {
        opakowany.log(wiadomosc);
    }
}
// Dekorator dodający znacznik czasu — jedna odpowiedzialność, jedna klasa
class CzasDekorator extends LoggerDekorator {

    public CzasDekorator(Logger opakowany) {
        super(opakowany);
    }

    @Override
    public void log(String wiadomosc) {
        // Dodajemy znacznik czasu DO wiadomości, a potem delegujemy dalej
        String zCzasem = "[" + LocalDateTime.now() + "] " + wiadomosc;
        opakowany.log(zCzasem);
    }
}
// Dekorator zapisujący do pliku — również jedna odpowiedzialność
class PlikDekorator extends LoggerDekorator {

    private final String sciezkaPliku;

    public PlikDekorator(Logger opakowany, String sciezkaPliku) {
        super(opakowany);
        this.sciezkaPliku = sciezkaPliku;
    }

    @Override
    public void log(String wiadomosc) {
        // Najpierw delegujemy do opakowanego (np. konsola)
        opakowany.log(wiadomosc);
        // Potem dodajemy własne zachowanie — zapis do pliku
        System.out.println("[PLIK: " + sciezkaPliku + "] " + wiadomosc);
    }
}
// Dekorator filtrujący poziom logów
class PoziomDekorator extends LoggerDekorator {

    public enum Poziom { DEBUG, INFO, WARN, ERROR }

    private final Poziom minimalnyPoziom;
    private final Poziom aktualnyPoziom;

    public PoziomDekorator(Logger opakowany, Poziom minimalny, Poziom aktualny) {
        super(opakowany);
        this.minimalnyPoziom = minimalny;
        this.aktualnyPoziom = aktualny;
    }

    @Override
    public void log(String wiadomosc) {
        // Przekazujemy dalej tylko gdy poziom jest wystarczająco wysoki
        if (aktualnyPoziom.ordinal() >= minimalnyPoziom.ordinal()) {
            opakowany.log("[" + aktualnyPoziom + "] " + wiadomosc);
        }
        // Wiadomości poniżej progu są po prostu ignorowane
    }
}
public class Main {
    public static void main(String[] args) {

        // Wariant 1: prosty logger — tylko konsola
        Logger prosty = new KonsolaLogger();
        prosty.log("Start aplikacji");
        // → Start aplikacji

        // Wariant 2: konsola + znacznik czasu
        Logger zCzasem = new CzasDekorator(new KonsolaLogger());
        zCzasem.log("Zamówienie złożone");
        // → [2024-01-15T10:30:00] Zamówienie złożone

        // Wariant 3: konsola + czas + plik — owijamy kolejną warstwą
        Logger pelny = new PlikDekorator(
                           new CzasDekorator(
                               new KonsolaLogger()),
                           "app.log");
        pelny.log("Płatność przetworzona");
        // → [2024-01-15T10:30:01] Płatność przetworzona  (konsola)
        // → [PLIK: app.log] [2024-01-15T10:30:01] Płatność przetworzona  (plik)

        // Wariant 4: wszystkie warstwy razem
        Logger maxymalny = new PoziomDekorator(
                               new PlikDekorator(
                                   new CzasDekorator(
                                       new KonsolaLogger()),
                                   "app.log"),
                               PoziomDekorator.Poziom.WARN,
                               PoziomDekorator.Poziom.ERROR);
        maxymalny.log("Błąd krytyczny!");
        // → [ERROR] [2024-01-15T10:30:02] Błąd krytyczny!  (konsola + plik)

        // Nowa kombinacja w runtime — żadna klasa nie zmieniła się
        // Dodanie nowej opcji = nowa klasa dekoratora, reszta nieruszona
    }
}

Co zyskujemy?

Trzy klasy dekoratorów zastępują siedem podklas z poprzedniego przykładu — i obsługują wszystkie możliwe kombinacje. Każdy dekorator ma jedną odpowiedzialność. Konfigurację możemy zmieniać w czasie działania programu, składając warstwy według potrzeb.


5. Diagram — jak działają warstwy

Wywołanie: pelny.log("Płatność przetworzona")
  ─────────────────────────────────────────────────────────────────

  PlikDekorator          CzasDekorator         KonsolaLogger
  ┌──────────────┐       ┌──────────────┐       ┌──────────────┐
  │              │       │              │       │              │
  │  log(msg)    │──────►│  log(msg)    │──────►│  log(msg)    │
  │              │       │              │       │              │
  │  + zapis     │       │  + dodaj     │       │  println()   │
  │    do pliku  │◄──────│    czas      │◄──────│              │
  │              │       │              │       │              │
  └──────────────┘       └──────────────┘       └──────────────┘
       warstwa 3              warstwa 2              rdzeń

  Każda warstwa:
  1. Opcjonalnie modyfikuje wiadomość (np. dodaje czas)
  2. Deleguje do kolejnej warstwy
  3. Opcjonalnie wykonuje własne działanie (np. zapis do pliku)

  Struktura klas:
  ┌──────────────────────────────────────────────┐
  │              «interface» Logger              │
  │              + log(wiadomosc)                │
  └──────┬───────────────────────────────────────┘
         │ implementuje              │ implementuje
  ┌──────▼──────────┐       ┌────────▼──────────────┐
  │  KonsolaLogger  │       │   LoggerDekorator      │
  │  (rdzeń)        │       │   - opakowany: Logger  │
  └─────────────────┘       └────────┬──────────────-┘
                                     │ rozszerza
                    ┌────────────────┼────────────────┐
                    ▼                ▼                 ▼
             CzasDekorator    PlikDekorator    PoziomDekorator

6. Dlaczego Dekorator jest ważny?

Elastyczność ponad dziedziczeniem — zamiast ustalać zachowanie na etapie kompilacji przez hierarch klas, składamy je dynamicznie w runtime. To jedna z najważniejszych zalet wzorca.

Zgodność z SRP — każdy dekorator ma jedną odpowiedzialność. CzasDekorator wie tylko jak dodać czas. PlikDekorator wie tylko jak zapisać do pliku. Logika nie jest zduplikowana.

Zgodność z OCP — dodanie nowej funkcjonalności (np. SlackDekorator wysyłający alerty do Slacka) to stworzenie nowej klasy. Żaden istniejący kod nie jest modyfikowany.

Przejrzystość kompozycji — czytając new PlikDekorator(new CzasDekorator(new KonsolaLogger())) od razu widzimy, jakie warstwy są aktywne. To dosłowna dokumentacja konfiguracji w kodzie.

Kiedy Dekorator NIE jest najlepszym wyborem:

Wzorzec wprowadza złożoność w debugowaniu — gdy coś idzie nie tak w stosie dekoratorów, trzeba przejść przez każdą warstwę. Jeśli kombinacji jest mało i są stabilne — zwykłe dziedziczenie może być prostsze. Dekorator błyszczy przy wielu opcjach i dynamicznych konfiguracjach.


7. Kiedy stosować?

Dekorator ma sens, gdy:

  • chcesz dodać zachowanie do obiektów bez modyfikowania ich klasy i bez tworzenia eksplozji podklas,
  • zachowania powinny być kompozytowalne — możliwe do łączenia w dowolnych kombinacjach,
  • różne obiekty potrzebują różnych zestawów dodatkowych funkcji,
  • chcesz móc zmieniać konfigurację obiektów w czasie działania programu.

Dekorator w popularnych bibliotekach Javy:

  • java.io — strumienie wejścia/wyjścia — to podręcznikowy przykład Dekoratora w JDK:
    // BufferedReader owijający InputStreamReader owijający FileInputStream
    // Każda warstwa dodaje jedno zachowanie: buforowanie, dekodowanie, czytanie z pliku
    new BufferedReader(new InputStreamReader(new FileInputStream("plik.txt")))
  • Collections.synchronizedList() — owija listę dekoratorem dodającym synchronizację wątków,
  • Collections.unmodifiableList() — owija listę dekoratorem blokującym modyfikacje,
  • Spring BeanPostProcessor — mechanizm owijania beanów w dodatkowe zachowania (np. transakcje przez @Transactional, cache przez @Cacheable) to Dekorator zarządzany przez kontener,
  • Spring Security — łańcuch filtrów bezpieczeństwa (SecurityFilterChain) to stos dekoratorów nakładanych na żądanie HTTP.

Sygnały, że czas wprowadzić Dekorator:

  • liczba podklas rośnie wykładniczo wraz z kolejnymi opcjami funkcjonalnymi,
  • ta sama logika (np. logowanie, pomiar czasu, cache) jest kopiowana między wieloma klasami,
  • klient musi wybierać zachowanie obiektu w czasie działania, nie w kodzie,
  • w code review pojawia się pytanie: „Dlaczego mamy klasę LoggerZCzasemIPlikiem i osobno LoggerZCzasemISlackiem?"

1. Implementuje TEN SAM interfejs co opakowywany obiekt  → można go używać zamiennie
2. Trzyma REFERENCJĘ do opakowanego obiektu              → deleguje wywołania
3. Dodaje JEDNO nowe zachowanie                          → zgodność z SRP