WZORCE PROJEKTOWE

Builder (Budowniczy)

Opis: Wzorzec Budowniczy pozwala na tworzenie złożonych obiektów, z wieloma rozbudowanymi konstruktorami, krok po kroku, oddzielając proces budowy od ostatecznej reprezentacji obiektu.


Przykład przed zastosowaniem wzorca Budowniczy

Poniżej przykład klasy Pizza, która pozwala tworzyć pizzę z różnymi składnikami bez zastosowania wzorca Budowniczy:

class Pizza {
    private String size;
    private boolean cheese;
    private boolean pepperoni;
    private boolean bacon;

    public Pizza(String size, boolean cheese, boolean pepperoni, boolean bacon) {
        this.size = size;
        this.cheese = cheese;
        this.pepperoni = pepperoni;
        this.bacon = bacon;
    }

    @Override
    public String toString() {
        return "Pizza [size=" + size + ", cheese=" + cheese + ", pepperoni=" + pepperoni + ", bacon=" + bacon + "]";
    }
}

public class Main {
    public static void main(String[] args) {
        Pizza pizza = new Pizza("Large", true, true, false);
        System.out.println(pizza);
    }
}

Problemy:

  • Konstruktor staje się nieczytelny przy wielu parametrach.
  • Dodanie nowego składnika wymaga modyfikacji klasy Pizza.
  • Tworzenie obiektów z różnymi kombinacjami składników jest trudne do zarządzania.


Przykład po zastosowaniu wzorca Budowniczy

Klasa Pizza - obiekt budowany
class Pizza {
    private String size;
    private boolean cheese;
    private boolean pepperoni;
    private boolean bacon;

    // Prywatny konstruktor
    private Pizza(Builder builder) {
        this.size = builder.size;
        this.cheese = builder.cheese;
        this.pepperoni = builder.pepperoni;
        this.bacon = builder.bacon;
    }

    @Override
    public String toString() {
        return "Pizza [size=" + size + ", cheese=" + cheese + ", pepperoni=" + pepperoni + ", bacon=" + bacon + "]";
    }

    // Klasa wewnętrzna Builder
    public static class Builder {
        private String size;
        private boolean cheese;
        private boolean pepperoni;
        private boolean bacon;

        public Builder(String size) {
            this.size = size;
        }

        public Builder addCheese() {
            this.cheese = true;
            return this;
        }

        public Builder addPepperoni() {
            this.pepperoni = true;
            return this;
        }

        public Builder addBacon() {
            this.bacon = true;
            return this;
        }

        public Pizza build() {
            return new Pizza(this);
        }
    }
}

Użycie wzorca Budowniczy

public class Main {
    public static void main(String[] args) {
        Pizza pizza = new Pizza.Builder("Large")
                .addCheese()
                .addPepperoni()
                .build();

        System.out.println(pizza);
    }
}


Wnioski

  1. Czytelność i wygoda: Dzięki wzorcowi Budowniczy tworzenie obiektów z wieloma opcjami jest bardziej przejrzyste i zrozumiałe.
  2. Łatwość rozbudowy: Można łatwo dodawać nowe składniki do obiektu (np. oliwki, warzywa) bez zmiany istniejącego kodu.
  3. Fluent Interface: Łańcuchowe wywoływanie metod (.addCheese().addPepperoni()) poprawia ergonomię kodu.
  4. Oddzielenie konstrukcji od reprezentacji: Możemy mieć różne konfiguracje obiektu Pizza, korzystając z tego samego procesu budowy.



Factory (Fabryka)

Opis: Wzorzec Fabryka (Factory) służy do tworzenia obiektów bez bezpośredniego używania operatora new. Pozwala na delegowanie logiki tworzenia obiektów do specjalnych metod fabrycznych, co ułatwia zarządzanie kodem i jego rozszerzalność.


Przykład przed zastosowaniem wzorca Fabryka

W tym przypadku każda instancja klasy Animal jest tworzona bezpośrednio przy użyciu new, co sprawia, że kod jest mniej elastyczny.

// Klasa bazowa
abstract class Animal {
    abstract void makeSound();
}

// Konkretne klasy
class Dog extends Animal {
    @Override
    void makeSound() {
        System.out.println("Woof!");
    }
}

class Cat extends Animal {
    @Override
    void makeSound() {
        System.out.println("Meow!");
    }
}

public class Main {
    public static void main(String[] args) {
        Animal dog = new Dog();
        Animal cat = new Cat();

        dog.makeSound();
        cat.makeSound();
    }
}

Problemy:

  • Użycie new w kodzie głównym (Main) oznacza, że jeśli dodamy nową klasę np. Bird, trzeba będzie edytować kod w wielu miejscach.
  • Ograniczona skalowalność — trudno zarządzać wieloma typami obiektów.

Przykład po zastosowaniu wzorca Fabryka

Dzięki fabryce, kod staje się bardziej elastyczny, a logika tworzenia obiektów jest oddzielona od kodu aplikacji.

// Klasa bazowa
abstract class Animal {
    abstract void makeSound();
}

// Konkretne klasy
class Dog extends Animal {
    @Override
    void makeSound() {
        System.out.println("Woof!");
    }
}

class Cat extends Animal {
    @Override
    void makeSound() {
        System.out.println("Meow!");
    }
}

// Klasa Fabryka
class AnimalFactory {
    public static Animal createAnimal(String type) {
        if (type.equalsIgnoreCase("dog")) {
            return new Dog();
        } else if (type.equalsIgnoreCase("cat")) {
            return new Cat();
        }
        throw new IllegalArgumentException("Unknown animal type: " + type);
    }
}

// Kod główny
public class Main {
    public static void main(String[] args) {
        Animal dog = AnimalFactory.createAnimal("dog");
        Animal cat = AnimalFactory.createAnimal("cat");

        dog.makeSound();
        cat.makeSound();
    }
}


Wnioski:

  • Enkapsulacja procesu tworzenia obiektów – Obiekty są tworzone przez fabrykę, a nie bezpośrednio przez new, co ułatwia zarządzanie nimi.
  • Łatwiejsza rozbudowa – Jeśli dodamy nową klasę (Bird), wystarczy dodać ją w fabryce, zamiast modyfikować kod w wielu miejscach.
  • Większa elastyczność – Możemy dynamicznie tworzyć różne obiekty na podstawie warunków wejściowych (String type).
  • Wzorzec Fabryka jest szczególnie przydatny, gdy chcemy oddzielić logikę tworzenia obiektów od ich użycia i zwiększyć modularność kodu.



Strategy (Strategia)

Opis: Wzorzec Strategia (Strategy) pozwala na dynamiczną zmianę algorytmów w trakcie działania programu. Zamiast stosować instrukcje warunkowe (if-else lub switch), różne warianty algorytmu są implementowane jako osobne klasy i mogą być łatwo zamieniane w czasie działania programu.

Przykład przed zastosowaniem wzorca Strategia

W tym przypadku mamy klasę PaymentProcessor, która obsługuje różne metody płatności za pomocą instrukcji if-else.

// Klasa przetwarzająca płatności
class PaymentProcessor {
    public void processPayment(String paymentType, double amount) {
        if (paymentType.equalsIgnoreCase("creditCard")) {
            System.out.println("Processing credit card payment: $" + amount);
        } else if (paymentType.equalsIgnoreCase("paypal")) {
            System.out.println("Processing PayPal payment: $" + amount);
        } else {
            System.out.println("Invalid payment method.");
        }
    }
}

// Kod główny
public class Main {
    public static void main(String[] args) {
        PaymentProcessor paymentProcessor = new PaymentProcessor();
        paymentProcessor.processPayment("creditCard", 100.0);
        paymentProcessor.processPayment("paypal", 50.0);
    }
}

Problemy:

  • Jeśli dodamy nową metodę płatności (np. Bitcoin), musimy modyfikować PaymentProcessor, łamiąc zasadę Open/Closed (klasa powinna być otwarta na rozszerzenia, ale zamknięta na modyfikacje).
  • Trudna skalowalność – kod staje się nieczytelny, gdy pojawia się więcej warunków.

Przykład po zastosowaniu wzorca Strategia

Każda metoda płatności zostaje wydzielona jako osobna klasa implementująca interfejs PaymentStrategy.

// Interfejs strategii płatności
interface PaymentStrategy {
    void pay(double amount);
}

// Implementacje różnych metod płatności
class CreditCardPayment implements PaymentStrategy {
    @Override
    public void pay(double amount) {
        System.out.println("Processing credit card payment: $" + amount);
    }
}

class PayPalPayment implements PaymentStrategy {
    @Override
    public void pay(double amount) {
        System.out.println("Processing PayPal payment: $" + amount);
    }
}

// Kontekst – klasa wykorzystująca strategię
class PaymentProcessor {
    private PaymentStrategy paymentStrategy;

    public void setPaymentStrategy(PaymentStrategy paymentStrategy) {
        this.paymentStrategy = paymentStrategy;
    }

    public void processPayment(double amount) {
        if (paymentStrategy == null) {
            System.out.println("No payment strategy set.");
            return;
        }
        paymentStrategy.pay(amount);
    }
}

// Kod główny
public class Main {
    public static void main(String[] args) {
        PaymentProcessor paymentProcessor = new PaymentProcessor();

        // Płatność kartą kredytową
        paymentProcessor.setPaymentStrategy(new CreditCardPayment());
        paymentProcessor.processPayment(100.0);

        // Płatność przez PayPal
        paymentProcessor.setPaymentStrategy(new PayPalPayment());
        paymentProcessor.processPayment(50.0);
    }
}


Wnioski:

  1. Zasada Open/Closed – Możemy łatwo dodać nowe metody płatności (np. BitcoinPayment), bez modyfikowania istniejącego kodu.
  2. Lepsza organizacja kodu – Każda metoda płatności jest wydzielona do osobnej klasy, co ułatwia zarządzanie kodem.
  3. Dynamiczna zmiana strategii – Możemy zmieniać sposób płatności w trakcie działania programu bez konieczności modyfikowania kodu PaymentProcessor.
  4. Wzorzec Strategia sprawdza się w sytuacjach, gdy mamy wiele wariantów algorytmu i chcemy umożliwić ich dynamiczne wybieranie.