Wzorzec projektowy Builder należy do grupy wzorców kreacyjnych i jest narzędziem służącym do konstruowania skomplikowanych obiektów w sposób stopniowy. Oddziela on proces budowy obiektu od jego ostatecznej reprezentacji, co umożliwia tworzenie różnych wariantów tego samego typu obiektu przy użyciu identycznego mechanizmu konstrukcji. Dzięki temu unika się problemów związanych z tradycyjnymi konstruktorami, takimi jak nadmiar parametrów czy brak elastyczności. Wzorzec ten promuje zasadę pojedynczej odpowiedzialności (Single Responsibility Principle), gdzie budowniczy zajmuje się wyłącznie konstrukcją, a produkt końcowy pozostaje niezmienny po utworzeniu.
Kiedy Warto Użyć Wzorca Builder?
Builder jest szczególnie przydatny w sytuacjach, gdy:
- Obiekt posiada wiele opcjonalnych lub wymaganych parametrów, co sprawia, że klasyczny konstruktor staje się nieczytelny (tzw. “teleskopowy konstruktor”).
- Proces tworzenia obiektu wymaga wykonania kroków w określonej sekwencji, ale z możliwością pominięcia niektórych.
- Potrzebujemy generować różne konfiguracje obiektu, np. różne modele samochodu, pizzy czy budynku.
- Chcemy zapewnić immutable (niezmienne) obiekty, co jest kluczowe w programowaniu wielowątkowym.
Przykładowo, wyobraźmy sobie budowę domu: możemy chcieć domy małe, duże, z garażem lub bez, z różnymi materiałami. Builder pozwala na elastyczną konfigurację bez modyfikowania klasy samego obiektu. Jest to wzorzec często stosowany w bibliotekach i frameworkach, gdzie obiekty wymagają złożonej inicjalizacji.
Schematy Implementacji Wzorca Builder
Wzorzec Builder można wdrożyć na kilka sposobów. Podstawowa wersja wykorzystuje zagnieżdżoną klasę budowniczego wewnątrz klasy produktu, co umożliwia bezpośrednie użycie przez klienta. Bardziej zaawansowana implementacja wprowadza interfejs budowniczego oraz klasę dyrektora, która zarządza procesem budowy, zapewniając spójność i automatyzację. Obie wersje opierają się na fluent interface, gdzie metody budowniczego zwracają referencję do siebie, umożliwiając łańcuchowanie wywołań.
Wersja z Klasą Wewnętrzną (Bezpośrednie Użycie Buildera)
W tej podejściu klient sam kontroluje proces budowy, ustawiając atrybuty w dowolnej kolejności. Budowniczy jest zagnieżdżony w klasie produktu jako klasa statyczna, co zapewnia enkapsulację. Konstruktor produktu jest prywatny, co uniemożliwia bezpośrednie tworzenie obiektów bez użycia buildera. Ta implementacja jest elastyczna, ale wymaga od klienta zarządzania kompletnością danych – pominięte atrybuty pozostaną null, co może prowadzić do niekompletnych obiektów, jeśli nie ma walidacji.
Przykład Kodu: Budowa Samochodu
Poniżej przykład implementacji w Java, gdzie budujemy obiekt klasy Car reprezentujący samochód z atrybutami takimi jak silnik, nadwozie, koła, kolor i wyposażenie dodatkowe. Atrybuty są ustawiane za pomocą metod przyjmujących parametry typu String, co pozwala na dowolne wartości.
public class Car {
private String engine;
private String body;
private String wheels;
private String color;
private String extras;
// Prywatny konstruktor przyjmujący budowniczego
private Car(CarBuilder builder) {
this.engine = builder.engine;
this.body = builder.body;
this.wheels = builder.wheels;
this.color = builder.color;
this.extras = builder.extras;
}
// Gettery
public String getEngine() { return engine; }
public String getBody() { return body; }
public String getWheels() { return wheels; }
public String getColor() { return color; }
public String getExtras() { return extras; }
@Override
public String toString() {
return "Car{" +
"engine='" + engine + '\'' +
", body='" + body + '\'' +
", wheels='" + wheels + '\'' +
", color='" + color + '\'' +
", extras='" + extras + '\'' +
'}';
}
// Zagnieżdżona klasa budowniczego
public static class CarBuilder {
private String engine;
private String body;
private String wheels;
private String color;
private String extras;
public CarBuilder buildEngine(String engine) {
this.engine = engine;
return this;
}
public CarBuilder buildBody(String body) {
this.body = body;
return this;
}
public CarBuilder buildWheels(String wheels) {
this.wheels = wheels;
return this;
}
public CarBuilder buildColor(String color) {
this.color = color;
return this;
}
public CarBuilder buildExtras(String extras) {
this.extras = extras;
return this;
}
public Car build() {
// Opcjonalna walidacja: np. sprawdzenie wymaganych pól
if (this.engine == null || this.body == null) {
throw new IllegalStateException("Silnik i nadwozie są wymagane!");
}
return new Car(this);
}
}
}Analiza Metod w CarBuilder
Metody budowy atrybutów (np. buildEngine, buildBody, buildWheels, buildColor, buildExtras):
- Działanie: Każda metoda ustawia odpowiedni atrybut w obiekcie budowniczego na wartość przekazaną jako parametr. Zwraca
this, umożliwiając fluent interface. Brak walidacji w metodach pozwala na elastyczność, ale może prowadzić do błędów – dlatego wbuild()dodano prostą walidację wymaganych pól. - Implementacja: Przypisanie wartości do pola, np.
this.engine = engine; return this;. Pozostałe metody działają analogicznie, różniąc się tylko nazwą pola. - Analiza: Metody są elastyczne, przyjmując String, co pozwala na niestandardowe wartości (np. “V8 turbo” dla silnika). Fluent interface poprawia czytelność kodu, umożliwiając łańcuchowe wywoływanie w jednym wyrażeniu. Jednak bez walidacji klient może pominąć kroki, co skutkuje null lub błędami w runtime.
Metoda build():
- Działanie: Tworzy nowy obiekt
Car, kopiując wartości z buildera do produktu poprzez prywatny konstruktor. Dodana walidacja zapewnia minimalną kompletność. - Implementacja:
return new Car(this);z kopiowaniem pól w konstruktorzeCar. - Analiza: Finalizuje budowę, tworząc immutable obiekt (brak setterów w
Car). Walidacja zapobiega tworzeniu niekompletnych obiektów, co jest ulepszeniem w porównaniu do surowej implementacji.
Przykład Użycia
W klasie Main:
public class Main {
public static void main(String[] args) {
Car sportsCar = new Car.CarBuilder()
.buildEngine("V8 turbo")
.buildBody("coupe")
.buildWheels("sportowe 18-calowe")
.buildColor("czerwony")
.buildExtras("system audio premium")
.build();
System.out.println(sportsCar); // Wyjście: Car{engine='V8 turbo', body='coupe', wheels='sportowe 18-calowe', color='czerwony', extras='system audio premium'}
// Przykład z pominięciem opcjonalnych
Car basicCar = new Car.CarBuilder()
.buildEngine("benzynowy 1.6")
.buildBody("sedan")
.build();
System.out.println(basicCar); // Wyjście: Car{engine='benzynowy 1.6', body='sedan', wheels='null', color='null', extras='null'}
}
}Analiza: Klient ma pełną kontrolę, co pozwala na customizację, ale wymaga świadomości wymaganych pól. W porównaniu do teleskopowego konstruktora, kod jest czytelniejszy i skalowalny.
Wersja Klasyczna z Interfejsem i Dyrektorem
Tutaj wprowadzamy interfejs Builder, konkretne implementacje (np. dla małego i dużego obiektu) oraz klasę dyrektora, która steruje procesem. Wartości atrybutów są predefiniowane w budowniczych, co zapewnia spójność, ale ogranicza elastyczność. Dyrektor wywołuje metody w ustalonej sekwencji, eliminując ryzyko pominięcia kroków. Ta wersja jest idealna dla predefiniowanych wariantów, gdzie proces budowy musi być standaryzowany.
Przykład Kodu: Budowa Pizzy
Własny przykład w Java: budujemy obiekt Pizza z atrybutami takimi jak ciasto, sos, ser, dodatki i rozmiar. Używamy interfejsu PizzaBuilder, konkretnych budowniczych (SmallPizzaBuilder, LargePizzaBuilder) i dyrektora PizzaDirector. Metody budowniczego nie przyjmują parametrów – wartości są hardcoded.
public class Pizza {
private String dough;
private String sauce;
private String cheese;
private String toppings;
private String size;
// Gettery i settery
public String getDough() { return dough; }
public void setDough(String dough) { this.dough = dough; }
public String getSauce() { return sauce; }
public void setSauce(String sauce) { this.sauce = sauce; }
public String getCheese() { return cheese; }
public void setCheese(String cheese) { this.cheese = cheese; }
public String getToppings() { return toppings; }
public void setToppings(String toppings) { this.toppings = toppings; }
public String getSize() { return size; }
public void setSize(String size) { this.size = size; }
@Override
public String toString() {
return "Pizza{" +
"dough='" + dough + '\'' +
", sauce='" + sauce + '\'' +
", cheese='" + cheese + '\'' +
", toppings='" + toppings + '\'' +
", size='" + size + '\'' +
'}';
}
}
public interface PizzaBuilder {
void buildDough();
void buildSauce();
void buildCheese();
void buildToppings();
void buildSize();
Pizza getPizza();
}
public class SmallPizzaBuilder implements PizzaBuilder {
private Pizza pizza = new Pizza();
@Override
public void buildDough() { pizza.setDough("cienkie ciasto"); }
@Override
public void buildSauce() { pizza.setSauce("pomidorowy"); }
@Override
public void buildCheese() { pizza.setCheese("mozzarella"); }
@Override
public void buildToppings() { pizza.setToppings("pieczarki, szynka"); }
@Override
public void buildSize() { pizza.setSize("mała"); }
@Override
public Pizza getPizza() { return pizza; }
}
public class LargePizzaBuilder implements PizzaBuilder {
private Pizza pizza = new Pizza();
@Override
public void buildDough() { pizza.setDough("grube ciasto"); }
@Override
public void buildSauce() { pizza.setSauce("ostry pomidorowy"); }
@Override
public void buildCheese() { pizza.setCheese("podwójna mozzarella"); }
@Override
public void buildToppings() { pizza.setToppings("salami, oliwki, papryka"); }
@Override
public void buildSize() { pizza.setSize("duża"); }
@Override
public Pizza getPizza() { return pizza; }
}
public class PizzaDirector {
private PizzaBuilder builder;
public PizzaDirector(PizzaBuilder builder) {
this.builder = builder;
}
public void buildPizza() {
builder.buildDough();
builder.buildSauce();
builder.buildCheese();
builder.buildToppings();
builder.buildSize();
}
public Pizza getPizza() {
return builder.getPizza();
}
}Analiza Metod w PizzaBuilder i Implementacjach
Metody budowy atrybutów (np. buildDough, buildSauce):
- Działanie: Ustawiają predefiniowane wartości w obiekcie
Pizzaza pomocą setterów. Nie przyjmują parametrów, co zapewnia spójność, ale ogranicza customizację. - Implementacja: Np.
pizza.setDough("cienkie ciasto");wSmallPizzaBuilder. Podobnie wLargePizzaBuilderz innymi wartościami. - Analiza: Metody są sztywne, ale gwarantują, że wszystkie atrybuty zostaną ustawione po wywołaniu przez dyrektora. Brak parametrów eliminuje błędy wejścia, ale wymaga tworzenia nowych budowniczych dla wariantów.
Metoda getPizza():
- Działanie: Zwraca skonstruowany obiekt
Pizza. - Implementacja:
return pizza;. - Analiza: Zakłada, że wszystkie kroki budowy zostały wykonane; używana po
buildPizza()dyrektora.
Analiza Metod w PizzaDirector
Konstruktor PizzaDirector(PizzaBuilder builder):
- Działanie: Inicjalizuje dyrektora z konkretnym budowniczym, umożliwiając wstrzykiwanie zależności.
- Implementacja:
this.builder = builder;. - Analiza: Pozwala na reuse dyrektora z różnymi budowniczymi, np. do tworzenia małych lub dużych pizz.
Metoda buildPizza():
- Działanie: Wywołuje metody budowniczego w ustalonej sekwencji, zapewniając kompletność.
- Implementacja: Sekwencyjne wywołania
builder.buildDough();itd. - Analiza: Standaryzuje proces, eliminując błędy klienta, ale narzuca sztywną kolejność.
Metoda getPizza():
- Działanie: Zwraca gotowy obiekt z budowniczego.
- Implementacja:
return builder.getPizza();. - Analiza: Kończy budowę; wymaga uprzedniego wywołania
buildPizza().
Przykład Użycia
W klasie Main:
public class Main {
public static void main(String[] args) {
PizzaDirector smallDirector = new PizzaDirector(new SmallPizzaBuilder());
smallDirector.buildPizza();
Pizza smallPizza = smallDirector.getPizza();
PizzaDirector largeDirector = new PizzaDirector(new LargePizzaBuilder());
largeDirector.buildPizza();
Pizza largePizza = largeDirector.getPizza();
System.out.println(smallPizza); // Wyjście: Pizza{dough='cienkie ciasto', sauce='pomidorowy', cheese='mozzarella', toppings='pieczarki, szynka', size='mała'}
System.out.println(largePizza); // Wyjście: Pizza{dough='grube ciasto', sauce='ostry pomidorowy', cheese='podwójna mozzarella', toppings='salami, oliwki, papryka', size='duża'}
}
}Analiza: Proces jest zautomatyzowany, co zapewnia spójność dla predefiniowanych wariantów, ale nie pozwala na runtime customizację bez modyfikacji budowniczych.
Zalety i Wady Wzorca Builder
Zalety
- Elastyczność i czytelność: Pozwala na krok po kroku budowę, unikając teleskopowych konstruktorów i poprawiając czytelność dzięki fluent interface.
- Separacja odpowiedzialności: Oddziela konstrukcję od reprezentacji, umożliwiając reuse kodu budowy dla różnych wariantów.
- Wsparcie dla immutability: Łatwo tworzyć niezmienne obiekty, co jest kluczowe w środowiskach wielowątkowych.
- Kontrola nad procesem: Dyrektor zapewnia sekwencyjną budowę, minimalizując błędy.
Wady
- Zwiększona złożoność: Wymaga więcej klas i kodu, co komplikuje prosty projekt.
- Duplikacja pól: Builder kopiuje pola produktu, co zwiększa maintenance.
- Niepotrzebny dla prostych obiektów: Dla obiektów z kilkoma polami może być overkill, clutterując kod.
- Brak elastyczności w wersjach predefiniowanych: W implementacji z dyrektorem zmiany wymagają nowych budowniczych.
Przykłady Zastosowania w Świecie Rzeczywistym
Wzorzec Builder jest szeroko stosowany w praktyce. Na przykład:
- StringBuilder w .NET i Java: Klasa
StringBuilderpozwala na krok po kroku budowanie ciągów znaków, unikając nieefektywnej konkatenacji. - Online shopping systems: W aplikacjach e-commerce, jak “Happy Shopping Store”, Builder służy do konfigurowania zamówień z opcjonalnymi elementami (np. adres dostawy, płatność, promocje).
- Parser XML/HTML: W bibliotekach jak DOM Builder, obiekt dokumentu budowany jest stopniowo z elementów, atrybutów i tekstu.
- Frameworki GUI: W narzędziach jak Swing czy Android, budowanie złożonych komponentów UI (np. dialogi z opcjonalnymi przyciskami).
- Konfiguracja obiektów w PHP: W nowoczesnych aplikacjach PHP, Builder używany do tworzenia zapytań SQL lub obiektów konfiguracyjnych.
Te przykłady pokazują, jak Builder upraszcza złożone inicjalizacje w realnych projektach.
Wnioski dla Wzorca Builder
Wzorzec Builder zapewnia elastyczność i czytelność przy tworzeniu złożonych obiektów, szczególnie w scenariuszach z wieloma parametrami. Wersja z klasą wewnętrzną nadaje się do pełnej customizacji, podczas gdy wersja z dyrektorem sprawdza się w predefiniowanych konfiguracjach, minimalizując błędy. Mimo zalet, jak separacja odpowiedzialności, wzorzec może zwiększać złożoność kodu, dlatego stosuj go świadomie. W praktyce, Builder często łączy się z innymi wzorcami, jak Factory Method, dla modularności. Jego użycie w bibliotekach jak StringBuilder potwierdza wartość w codziennym programowaniu.