Strategy

Wzorce behawioralne

ukończona

1. Definicja

Strategy (Strategia) to wzorzec behawioralny, który definiuje rodzinę algorytmów, hermetyzuje każdy z nich osobno i sprawia, że są one wzajemnie wymienne w trakcie działania programu.

Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it. — Gang of Four, Design Patterns

Najprościej rzecz ujmując: ten sam cel, różne sposoby jego osiągnięcia — wybierane w locie.

Zamiast zaszywać w jednej klasie serię instrukcji if-else czy switch, które decydują "jak coś zrobić", wydzielamy każdy sposób działania do osobnej klasy implementującej wspólny interfejs. Klasa korzystająca ze strategii nie wie i nie musi wiedzieć, jak dokładnie algorytm został zrealizowany, wie tylko, że może go wywołać.


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

Wyobraźmy sobie, że planujemy podróż i otwieramy aplikację nawigacyjną. Wpisujemy cel podróży, a następnie wybieramy sposób dotarcia do niego: samochodem, pieszo, rowerem albo komunikacją miejską.

Punkt startowy i punkt docelowy są takie same. To, co się zmienia, to strategia wyznaczania trasy:

  • nawigacja samochodowa unika ulic jednokierunkowych pod prąd i uwzględnia korki,
  • trasa piesza może prowadzić przez parki i skróty niedostępne dla aut,
  • trasa rowerowa priorytetyzuje ścieżki rowerowe,
  • trasa komunikacją miejską uwzględnia rozkłady jazdy autobusów i tramwajów.

Aplikacja nawigacyjna (kontekst) nie musi wiedzieć, w jaki sposób każdy z tych algorytmów wylicza trasę. Wystarczy, że zna jeden wspólny "kontrakt": podaj punkt A i B, zwróć trasę. Wybór konkretnej strategii następuje dopiero w momencie, gdy użytkownik kliknie odpowiednią ikonkę — samochodu, pieszego czy roweru.

Ćwiczenie myślowe: wyobraźmy sobie, że ktoś zaimplementował tę nawigację jako jedną ogromną metodę wyznaczTrase(), wewnątrz której znajduje się blok if (typTransportu == SAMOCHOD) {...} else if (typTransportu == ROWER) {...} i tak dalej. Co się stanie, gdy producent aplikacji zechce dodać nawigację dla hulajnogi elektrycznej? Trzeba będzie otworzyć tę samą metodę, zrozumieć cały istniejący kod i dopisać kolejny warunek, ryzykując zepsucie istniejącej logiki. Właśnie ten problem rozwiązuje Strategia.

Dokładnie ten sam mechanizm przenosimy teraz do naszego sklepu internetowego, który rozwijaliśmy przy okazji SRP.


3. Przykład - naruszenie zasady Strategii

Rozwijamy nasz sklep internetowy i musimy obsłużyć wiele metod płatności: kartą, przelewem, BLIK-iem oraz PayPal.

Naturalną, ale niebezpieczną pokusą jest umieszczenie całej logiki w jednej klasie:

class PaymentProcessor {

    public void processPayment(String paymentType, double amount) {
        // Klasa musi znać WSZYSTKIE możliwe sposoby płatności
        if (paymentType.equals("KARTA")) {
            System.out.println("Autoryzacja karty na kwotę: " + amount);
            System.out.println("Łączenie z bramką płatności bankowej...");
            // logika autoryzacji karty
        } else if (paymentType.equals("BLIK")) {
            System.out.println("Generowanie kodu BLIK dla kwoty: " + amount);
            // logika BLIK
        } else if (paymentType.equals("PRZELEW")) {
            System.out.println("Generowanie numeru konta do przelewu na: " + amount);
            // logika przelewu
        } else if (paymentType.equals("PAYPAL")) {
            System.out.println("Przekierowanie do PayPal, kwota: " + amount);
            // logika PayPal
        } else {
            throw new IllegalArgumentException("Nieznana metoda płatności");
        }
    }
}

Problemy, jakie to powoduje, są wielorakie:

  • Naruszenie Open/Closed — dodanie nowej metody płatności (np. Apple Pay) wymaga edycji istniejącej klasy PaymentProcessor, zamiast jedynie dopisania nowego kodu.
  • Rosnąca złożoność cyklomatyczna — każdy kolejny else if zwiększa liczbę możliwych ścieżek wykonania, co utrudnia testowanie i zrozumienie kodu.
  • Brak izolacji — błąd we implementacji BLIK-a (np. literówka w warunku) znajduje się w tym samym pliku co logika PayPal, więc łatwo przypadkiem coś zepsuć podczas edycji.
  • Trudne testowanie jednostkowe — aby przetestować logikę PayPal, musimy uruchomić całą metodę processPayment() z odpowiednim parametrem String, zamiast przetestować wyizolowaną klasę.
  • Duplikacja przy podobnym kodzie w innych miejscach — jeśli gdzie indziej w systemie (np. przy zwrotach) potrzebujemy podobnej logiki wyboru metody płatności, kopiujemy ten sam łańcuch if-else ponownie.

4. Przykład - zgodny ze Strategią

Wydzielmy każdą metodę płatności do osobnej klasy implementującej wspólny interfejs PaymentStrategy.

// Wspólny "kontrakt" dla wszystkich metod płatności.
// Kontekst (PaymentProcessor) będzie znał tylko tę metodę.
interface PaymentStrategy {
    void pay(double amount);
}
class CardPayment implements PaymentStrategy {
    @Override
    public void pay(double amount) {
        System.out.println("Autoryzacja karty na kwotę: " + amount);
        // logika autoryzacji karty - izolowana i testowalna osobno
    }
}

class BlikPayment implements PaymentStrategy {
    @Override
    public void pay(double amount) {
        System.out.println("Generowanie kodu BLIK dla kwoty: " + amount);
    }
}

class BankTransferPayment implements PaymentStrategy {
    @Override
    public void pay(double amount) {
        System.out.println("Generowanie numeru konta do przelewu na: " + amount);
    }
}

class PayPalPayment implements PaymentStrategy {
    @Override
    public void pay(double amount) {
        System.out.println("Przekierowanie do PayPal, kwota: " + amount);
    }
}
// Kontekst - nie zna szczegółów działania żadnej strategii,
// jedynie deleguje wywołanie do aktualnie ustawionej implementacji.
class PaymentProcessor {

    private PaymentStrategy strategy;

    // Strategia jest wstrzykiwana z zewnątrz (np. na podstawie wyboru klienta w koszyku)
    public PaymentProcessor(PaymentStrategy strategy) {
        this.strategy = strategy;
    }

    // Umożliwia zmianę strategii w trakcie działania programu
    public void setStrategy(PaymentStrategy strategy) {
        this.strategy = strategy;
    }

    public void processPayment(double amount) {
        strategy.pay(amount);
    }
}
// Użycie - klient wybiera BLIK w koszyku
PaymentProcessor processor = new PaymentProcessor(new BlikPayment());
processor.processPayment(129.99);

// Klient zmienia zdanie i wybiera kartę - podmieniamy strategię w locie
processor.setStrategy(new CardPayment());
processor.processPayment(129.99);

Co zmieniliśmy krok po kroku?

  1. Wydzieliliśmy wspólny interfejs PaymentStrategy — to "kontrakt", którego przestrzega każda metoda płatności.
  2. Każda konkretna metoda płatności (CardPayment, BlikPayment, BankTransferPayment, PayPalPayment) stała się osobną, niezależną klasą.
  3. Klasa PaymentProcessor (kontekst) nie zawiera już żadnej logiki if-else — jedynie przechowuje referencję do aktualnej strategii i deleguje do niej wywołanie.
  4. Dodanie Apple Pay oznacza teraz dopisanie nowej klasy ApplePayPayment implements PaymentStrategy, bez dotykania istniejącego kodu — dokładnie zgodnie z zasadą Open/Closed.

5. Diagram struktury

┌───────────────────┐        ┌───────────────────────┐
│  PaymentProcessor │──────▶│  <<interface>>         │
│  (Context)        │ używa  │  PaymentStrategy       │
│                   │        │  + pay(amount): void   │
└───────────────────┘        └────────────────────────┘
                                       ▲
                    ┌──────────────────┼────────────────────┬─────────────────┐
                    │                  │                    │                 │
          ┌──────────────────┐ ┌───────────────┐  ┌────────────────────┐ ┌──────────────┐
          │  CardPayment     │ │ BlikPayment   │  │ BankTransferPayment│ │ PayPalPayment│
          └──────────────────┘ └───────────────┘  └────────────────────┘ └──────────────┘

Kontekst trzyma referencję do interfejsu, nigdy do konkretnej implementacji. Dzięki temu strategie można podmieniać jak baterie w latarce — bez ingerencji w samą latarkę.


6. Dlaczego Strategia jest ważna? Kiedy NIE jej stosować?

Korzyści:

  • Zgodność z Open/Closed — nowe algorytmy dodajemy przez nowe klasy, nie modyfikując istniejącego kodu.
  • Eliminacja rozbudowanych warunków — koniec z rozrastającymi się blokami if-else czy switch, które trudno czytać i testować.
  • Niezależny rozwój i testowanie — każdą strategię można rozwijać, testować i wdrażać osobno, co ułatwia pracę w zespole (mniej konfliktów przy mergowaniu, jasny podział odpowiedzialności między programistami).
  • Elastyczność w runtime — strategię można podmienić w trakcie działania programu, np. na podstawie preferencji użytkownika czy konfiguracji.

Kiedy NIE stosować Strategii:

  • Gdy mamy tylko jeden, stały sposób wykonania algorytmu i nie przewidujemy żadnych wariantów — wtedy wzorzec wprowadza niepotrzebną złożoność (dodatkowy interfejs i klasy zamiast prostej metody).
  • Gdy liczba strategii jest bardzo mała (np. dwie) i nie zmienia się w czasie — czasem prosty warunek if-else jest czytelniejszy niż rozbudowana hierarchia klas.
  • Gdy klient musi znać szczegóły każdej strategii, by dokonać wyboru — wzorzec zakłada, że wybór strategii jest podejmowany raz, na zewnątrz, a nie ciągle negocjowany przez klienta kontekstu.

Rzeczywiste konsekwencje zaniedbania: w dużych systemach e-commerce klasa realizująca płatności "na piechotę" (jak w naruszeniu powyżej) potrafi urosnąć do tysięcy linii kodu, gdy dochodzą kolejne rynki, waluty i dostawcy płatności. Każda zmiana staje się ryzykowna, a testowanie pojedynczej metody płatności wymaga uruchomienia całej klasy.


7. Kiedy stosować? Przykłady z frameworków

Strategia sprawdza się szczególnie dobrze, gdy:

  • System płatności/dostaw — różne metody płatności (jak w naszym przykładzie) lub różne firmy kurierskie z odmienną logiką kalkulacji kosztu wysyłki.
  • Systemy sortowania i porównywania danych — różne kryteria sortowania listy (po cenie, po dacie, po popularności).
  • Walidacja danych — różne reguły walidacji formularza w zależności od typu konta użytkownika.
  • Systemy rabatowe — różne strategie naliczania zniżek (procentowa, kwotowa, "kup 2 zapłać za 1").

W praktyce JDK i frameworków:

  • java.util.Comparator to klasyczny przykład Strategii wbudowanej w JDK — metoda Collections.sort(list, comparator) przyjmuje strategię porównywania jako parametr, nie wiedząc nic o konkretnej logice sortowania.
  • java.io.FileFilter i FilenameFilter pozwalają wstrzyknąć różne strategie filtrowania plików.
  • W Springu wzorzec ten leży u podstaw takich mechanizmów jak PasswordEncoder — możemy podmienić strategię kodowania haseł (BCrypt, Argon2, SCrypt) bez zmiany kodu, który z niej korzysta.

Powiązane wzorce

Strategia bywa mylona z Template Method, ponieważ oba wzorce dotyczą "rodzin algorytmów". Kluczowa różnica:

  • Strategy opiera się na kompozycji — cały algorytm jest wymieniany jako jeden obiekt, wstrzyknięty do kontekstu, a podmiana odbywa się w czasie działania programu.
  • Template Method opiera się na dziedziczeniu — szkielet algorytmu jest stały i zdefiniowany w klasie bazowej, a podklasy nadpisują jedynie wybrane kroki tego szkieletu.

Innymi słowy: Strategia pyta "jaki cały algorytm chcesz użyć?", a Template Method pyta "jak chcesz zrealizować ten jeden konkretny krok w z góry ustalonym procesie?".