Budowniczy (Builder)

Budowniczy (Builder)

Builder jest kreacyjnym wzorcem projektowym.

Cel

Budowniczy jest kreacyjnym wzorcem projektowym, który daje możliwość tworzenia złożonych obiektów etapami, krok po kroku. Wzorzec ten pozwala produkować różne typy oraz reprezentacje obiektu używając tego samego kodu konstrukcyjnego.

Intencją wzorca Builder jest oddzielenie konstrukcji złożonego obiektu od jego reprezentacji. Dzięki czemu ten sam proces konstrukcyjny może tworzyć różne reprezentacje.

W przeciwieństwie do innych wzorców kreacyjnych Builder nie zakłada definiowania wspólnego interfejsu dla produktów. Dzięki temu da, się wytwarzać różne produkty stosując ten sam proces konstrukcyjny.

Problem

Wyobraź sobie jakiś skomplikowany obiekt, którego inicjalizacja jest pracochłonnym, wieloetapowym procesem obejmującym wiele pól i obiektów zagnieżdżonych. Taki kod inicjalizacyjny jest często wrzucany do wielgachnego konstruktora, przyjmującego mnóstwo parametrów. Albo jeszcze gorzej: kod taki rozrzucono po całym kodzie klienckim. Program może stać się nadmiernie skomplikowany, jeśli każda możliwa konfiguracja oznacza dodanie nowej podklasy.

Na przykład pomyślmy, jak stworzyć Pizze. Do stworzenia najprostszej pizzy wystarczy ciasto, sos pomidorowy i ser. Ale co, jeśli chcemy smaczniejszą pizzę, z wieloma dodatkami (szynka, oregano, bazylia, oliwki, papryka itd.)?

Najprostsze rozwiązanie to rozszerzenie klasy bazowej Pizza i stworzenie zestawu podklas, które spełniałyby każdy możliwy zestaw wymogów. Ale takie podejście doprowadzi do wielkiej liczby podklas, a dodanie kolejnego parametru, jeszcze bardziej rozbuduje tę hierarchię.

Istnieje jednak inne rozwiązanie, które nie wiąże się z mnożeniem podklas. Można stworzyć jeden wielki konstruktor w klasie bazowej Pizza, uwzględniający wszystkie możliwe parametry, które sterują obiektem typu pizza. W ten sposób nie mnożymy liczby klas, ale tworzymy nieco inny problem. Konstruktor przyjmujący mnóstwo parametrów. A to ma swoją wadę, nie wszystkie parametry będą potrzebne za każdym razem.

W większości przypadków parametry pozostaną nieużyte, a wywołania konstruktora będą wyglądać niechlujnie. Na przykład tylko niektóre pizze mają oliwki, więc parametry dotyczące oliwek w dziewięciu na dziesięć przypadków będzie niepotrzebny.

Rozwiązanie

Bardzo typowym przykładem wzorca Builder w prawdziwym życiu jest oprogramowanie do Pizzerii, które w pewnym momencie wymaga konstruowania pizz. Możemy potraktować taką pizzę jako złożony obiekt składający się z różnych składników, powiedzmy ciasta, sera, mięsa, warzyw i przypraw. Ale nie wszystkie pizze są konstruowane w ten sam sposób.

Wzorzec projektowy Budowniczy proponuje ekstrakcję kodu konstrukcyjnego obiekt z jego klasy i umieszczenie go w osobnych obiektach zwanych budowniczymi. Ten wzorzec projektowy dzieli konstrukcję obiektu na pewne etapy (wybierz ciasto, dodaj ser itd.). Aby powołać do życia obiekt, wykonuje się ciąg takich etapów za pośrednictwem obiektu budowniczego. Istotne jest to, że nie musisz wywoływać wszystkich etapów. Możesz bowiem ograniczyć się tylko do tych kroków, które są niezbędne do określenia potrzebnej nam konfiguracji obiektu.

Niektóre etapy konstrukcji mogą wymagać odmiennych implementacji, zależnie od potrzebnej w danej chwili reprezentacji produktu. W takim przypadku można utworzyć wiele różnych klas budowniczych, które implementują te same etapy konstrukcji, ale w różny sposób. Można następnie korzystać z tych budowniczych podczas procesu konstrukcji (np. odpowiednia kolejność wywołań etapów budowy), aby wytworzyć różne rodzaje obiektów.

Na przykład pizza Margarita ma inne ciasto, inny ser, mięso, warzywa I przyprawy niż pizza Capricciosa, więc ich konstrukcja jest inna. I to jest doskonały przykład tego, gdzie przydaje się wzór builder. Margarita jest reprezentacją pizzy, I Capricciosa jest reprezentacją pizzy.

Tak więc będziemy mieli buildera pizzy z metodami: przygotuj ciasto, dodaj mięso ser i tak dalej. A także sposób na zwrot pizzy, na przykład za pośrednictwem właściwości Pizza. Widząc, że zwracamy Pizza, będziemy mieli również klasę Pizza.

W tej klasie będziemy mieli metody dodawania sera, mięsa, warzyw, przypraw i tak dalej. Sposób, w jaki klasa Pizza dodaje i przechowuje te części, nie ma znaczenia dla naszej implementacji wzorca. A potem będziemy mieli różne implementacje tego buildera.

Jedną do zbudowania Pizza Margherita, jedną do zbudowania Pizza Capricciosa i tak dalej. Ważne jest tutaj to, że te różne implementacje nadal mają te same metody. Tak więc tworzenie złożonej pizzy jest przejrzyste dla konsumenta budowniczego. Bez względu na to, która implementacja buildera jest używana, musi odwoływać się do tych samych metod. Jest to możliwe, ponieważ tak jak w przypadku innych wzorców z kategorii wzorców twórczych, pracujemy nad interfejsami, a nie implementacjami. Znowu uniknęliśmy ścisłego sprzężenia.


Konsumentem w naszym przypadku jest PizzaMaker.

Potrzebujemy budowniczego, aby skonstruować pizze. Jednym ze sposobów zrealizowania tego jest wywołanie metody konstrukcji (Construct()), która akceptuje buildera pizzy. I to tyle, jeśli chodzi o nasz przykład. Teraz zmapujmy to na strukturę wzorca.

Struktura

Pierwszą częścią struktury wzorca jest Builder.

Określa abstrakcyjny interfejs do tworzenia części obiektu produktu. W naszym przypadku jest to mapowane na naszego PizzaBuilder. To interfejs do tworzenia składników pizzy.

Następnie mamy konkret builder.

Który Konstruuje produkt, którym w naszym przypadku jest pizza. To jest produkt. Zapewnia również sposób na zwrot tego produktu. W naszym przykładzie jest to mapowane na nasz MargaritaBuilder i nasz CapriciosaBuilder.

Następnie jest sam produkt.

To reprezentuje złożony obiekt w budowie. W naszym przypadku to mapujemy do Pizzy. Potencjalnie może obejmować klasy, które reprezentują części składowe. W naszym przypadku może to być klasa rodzaj ciasta, sera, mięsa i tak dalej.

I na koniec mamy dyrektora.

Dyrektor konstruuje obiekt za pomocą metody Construct. W naszym przykładzie możemy zmapować to, powiedzmy, do naszego PizzaMaker. Jednak w niektórych implementacjach Dyrektor umożliwia konstrukcjię za pośrednictwem właściwości. W innych umożliwia wywołanie metody konstrukt. To szczegóły realizacji, o którym sami możemy decydować. W końcu szablon wzorca jest właśnie szablonem. Tak długo, jak trzymamy się intencji wzorca, wszystko jest dobrze. Zaimplementujmy to teraz w kodzie.

Przykład implementacji

Zanim zaczniemy, pamiętajcie, że jak zawsze w przypadku wzorców projektowych, powinniśmy traktować je jako szablon. Ważne jest, aby trzymać się intencji wzorca, a implementacje mogą się różnić.

Zaczniemy od produktu – Pizza.

/// <summary>
/// Product
/// </summary>
public class Pizza
{
   private readonly string _name;        
   private CakeType _cakeType;
   private CheeseType _cheeseType;
   private MeatType _meatType;
   private List<string> _vegetables = new();
   private List<string> _condiments = new(); 

   public Pizza(string name)
   {
      _name = name;
   }   
}

W środku zdefiniujemy zestaw warzyw, przypraw jako Lista. Służy to do celów demonstracyjnych, aby uniknąć konieczności tworzenia oddzielnych klas dla warzyw, przypraw itp. Mamy tu jeszcze pola dla typu ciasta, sera, mięsa itp. Każda pizza ma również nazwę, którą przekazujemy za pośrednictwem konstruktora. W ten sposób możemy przekazać konsumentom znaczenie, informując ich, że oczekujemy nazwy pizzy.

Następnie dodajemy metody. Te metody zostaną wykorzystane do skonstruowania pizzy.

/// <summary>
/// Product
/// </summary>
public class Pizza
{
   private readonly string _name;        
   private CakeType _cakeType;
   private CheeseType _cheeseType;
   private MeatType _meatType;
   private List<string> _vegetables = new();
   private List<string> _condiments = new(); 

   public Pizza(string name)
   {
      _name = name;
   }  

   public void PrepareCake(CakeType cakeType) => _cakeType = cakeType; 
   public void ApplyCheese(CheeseType cheeseType) => _cheeseType = cheeseType;
   public void ApplyMeat(MeatType meatType) => _meatType = meatType;
   public void AddVegetables(string vegetable) => _vegetables.Add(vegetable);
   public void AddCondiments(string condiment) => _condiments.Add(condiment); 
}

I jeszcze, nadpisujemy metodę ToString(), aby ułatwić testowanie. W tej metodzie ToString() wypisujemy jak pizza I z jakimi składnikami została utworzona.

/// <summary>
/// Product
/// </summary>
public class Pizza
{
   private readonly string _name;        
   private CakeType _cakeType;
   private CheeseType _cheeseType;
   private MeatType _meatType;
   private List<string> _vegetables = new();
   private List<string> _condiments = new(); 

   public Pizza(string name)
   {
      _name = name;
   }  

   public void PrepareCake(CakeType cakeType) => _cakeType = cakeType; 
   public void ApplyCheese(CheeseType cheeseType) => _cheeseType = cheeseType;
   public void ApplyMeat(MeatType meatType) => _meatType = meatType;
   public void AddVegetables(string vegetable) => _vegetables.Add(vegetable);
   public void AddCondiments(string condiment) => _condiments.Add(condiment); 

   public override string ToString()
   {
      var stringbuilder = new StringBuilder();
      stringbuilder.Append($"Pizza którą zamówiłeś to {_name}\n");
      stringbuilder.Append($"Ma następujące składniki: \n");
      stringbuilder.Append($"Ciasto: {_cakeType}\n");
      stringbuilder.Append($"Ser: {_cheeseType}\n");
      stringbuilder.Append($"Mieso: {_meatType}\n");

      if (_condiments.Count > 0)
         stringbuilder.Append("Przyprawy:\n");

      foreach (string condiment in _condiments)
         stringbuilder.Append($"\t{condiment}\n");

      stringbuilder.Append($"Warzywa:\n");

      foreach (string vegetable in _vegetables)
         stringbuilder.Append($"\t{vegetable}\n");
       
      return stringbuilder.ToString();
   }
}

I tutaj jeszcze w produkcie korzystamy z enum-ów.

public enum CakeType
{
   Cienkie, 
   Grube
}

public enum CheeseType
{
   Gouda,
   Edam,
   Cheddar,
   Gorgonzola,
   Mozzarella
}

public enum MeatType
{
   Kurczak,
   Szynka,
   Indyk,
   Salami
}

Następna jest klasa Builder, która jest abstrakcyjną klasą bazową dla konkretnych builderów. Ma własność Pizza – to jest produkt, który buduje. Inicjujemy produkt w konstruktorze gdzie, przekazujemy jego nazwę.

/// <summary>
/// Builder  
/// </summary>
public abstract class PizzaBuilder
{
   public Pizza Pizza { get; private set; }

   public PizzaBuilder(string name)
   {
      Pizza = new Pizza(name);
   }  
}

Następnie, ważne jest, że definiujemy również 4 abstrakcyjne metody: PrepareCake, AddMeatAndCheese, AddVegetables i AddCondiments. Są to metody, które będą musiały zostać wdrożone przez konkretne buildery, aby faktycznie zbudować pizze.

/// <summary>
/// Builder  
/// </summary>
public abstract class PizzaBuilder
{
   public Pizza Pizza { get; private set; }

   public PizzaBuilder(string name)
   {
      Pizza = new Pizza(name);
   }  

   public abstract void PrepareCake();
   public abstract void AddMeatAndCheese();
   public abstract void AddVegetables();
   public abstract void AddCondiments();
}

I to prowadzi nas do konkretnych builderów. Zacznijmy od MargheritaPizzaBuilder.

/// <summary>
/// ConcreteBuilder
/// </summary>
public class MargheritaPizzaBuilder : PizzaBuilder
{
   public MargheritaPizzaBuilder() : base("Pizza Margherita")
   {
   }       
}

MargheritaPizzaBuilder dziedziczy po PizzaBuilder. A to oznacza, że musimy zaimplementować wszystkie abstrakcyjne metody. Zacznijmy od konstruktora. Konstruujemy ten MargheritaPizzaBuilder, przekazując nazwę pizzy do konstruktora bazowego. To coś, co możemy zrobić dzięki temu, że PizzaBuilder jest abstrakcyjną klasą bazową, a nie tylko interfejsem.

I implementacja metod która jest odpowiedzialna za dodawanie składników do pizzy. W naszym przypadku to tylko ciągi tekstowe. W bardziej rozbudowanej implementacji efektywnie wykorzystałbyś do tego klasy.

/// <summary>
/// ConcreteBuilder
/// </summary>
public class MargheritaPizzaBuilder : PizzaBuilder
{
   public MargheritaPizzaBuilder() : base("Pizza Margherita")
   {
   } 
  
   public override void PrepareCake()
   {
      Pizza.PrepareCake(CakeType.Grube);
   }

   public override void AddMeatAndCheese()
   {
      Pizza.ApplyCheese(CheeseType.Gouda);
      Pizza.ApplyMeat(MeatType.Szynka);
   }

   public override void AddVegetables()
   {
      Pizza.AddVegetables("Pomidory");
      Pizza.AddVegetables("Oregano");
   }

   public override void AddCondiments()
   {
      Pizza.AddCondiments("Sos pomidorowy");
   }    
}

Następnie analogicznie robimy drugiego buildera.

/// <summary>
/// ConcreteBuilder
/// </summary>
public class CapricciosaPizzaBuilder : PizzaBuilder
{
   public CapricciosaPizzaBuilder() : base("Pizza Capricciosa")
   {
   }

   public override void PrepareCake()
   {
      Pizza.PrepareCake(CakeType.Cienkie);
   }

   public override void AddMeatAndCheese()
   {
      Pizza.ApplyCheese(CheeseType.Mozzarella);
      Pizza.ApplyMeat(MeatType.Salami);
   }

   public override void AddVegetables()
   {
      Pizza.AddVegetables("Pieczarki");
      Pizza.AddVegetables("Oregano");
      Pizza.AddVegetables("Papryka");
   }

   public override void AddCondiments()
   {
      Pizza.AddCondiments("Musztarda");
      Pizza.AddCondiments("Majonez");
   }
}

Wreszcie mamy Directora.

/// <summary>
/// Director
/// </summary>
public class PizzaMaker
{
   private PizzaBuilder? _builder;

   public PizzaMaker()
   {
   }

   public void Construct(PizzaBuilder builder)
   {
      _builder = builder;
      _builder.PrepareCake();
      _builder.AddMeatAndCheese();
      _builder.AddCondiments();
      _builder.AddVegetables();
   }

   public void Show()
   {
      Console.WriteLine(_builder?.Pizza.ToString());
   }
}

Director potrzebuje buildera, żeby coś skutecznie skonstruować, w naszym przypadku jest, to pizza więc przechowujemy to w polu PizzaBuilder. Następnie musimy być w stanie jakoś wstrzyknąć tego Buildera. W tym przykładzie robimy to za pomocą metody Construct. Builder jest w ten sposób wstrzykiwany i za pomocą tego buildera budowana jest pizza. Widząc, że pracujemy tutaj nad abstrakcyjną klasą bazową PizzaBuilder, a nie nad konkretną implementacją, możemy wstrzyknąć dowolnego konkretnego buildera.

To teraz możemy przetestować nasze rozwiązanie I przechodzimy do program.cs.

using Builder;

Console.Title = "Builder";

var pizzaMaker = new PizzaMaker();
var margheritaPizzaBuilder = new MargheritaPizzaBuilder();
var capricciosaPizzaBuilder = new CapricciosaPizzaBuilder();

pizzaMaker.Construct(margheritaPizzaBuilder);
pizzaMaker.Show();

pizzaMaker.Construct(capricciosaPizzaBuilder);
pizzaMaker.Show();

Console.ReadKey();

Najpierw tworzymy dyrektora – to nasz PizzaMaker. Aby to zrobić, musimy zaimportować przestrzeń nazw Builder. Następnie potrzebujemy instancji naszych builderów, ponieważ oczekuje tego metoda Construct w PizzaMaker. Dlatego tworzymy instancję MargheritaPizzaBuilder i CapricciosaPizzaBuilder. I aby stworzyć pizze i to przetestować, wywołujemy metodę Construct z pizzaMaker, dla obu pizz. I teraz możemy to uruchomić.

Wynik działania

Pizza którą zamówiłeś to Pizza Margherita
Ma następujące składniki:
Ciasto: Grube
Ser: Gouda
Mieso: Szynka
Przyprawy:
        Sos pomidorowy
Warzywa:
        Pomidory
        Oregano

Pizza którą zamówiłeś to Pizza Capricciosa
Ma następujące składniki:
Ciasto: Cienkie
Ser: Mozzarella
Mieso: Salami
Przyprawy:
        Musztarda
        Majonez
Warzywa:
       Pieczarki
       Oregano
       Papryka

Zastosowanie

Wzorzec Budowniczy można użyć gdy konstruowanie różnorakich reprezentacji produktu obejmuje podobne etapy, które różnią się jedynie szczegółami.

Stosujemy wzorzec Budowniczy, gdy potrzebujemy możliwości tworzenia różnych reprezentacji jakiegoś produktu (na przykład, pizza Margherita i pizza Capricciosa).

Wzorzec Budowniczy pozwala konstruować obiekty krok po kroku, w miarę jak staje się to w programie potrzebne. Po zaimplementowaniu tego wzorca nie musisz przekazywać konstruktorowi tuzina parametrów.

Stosujemy wzorzec Budowniczy, aby pozbyć się “teleskopowych konstruktorów”. Załóżmy, że mamy konstruktor, przyjmujący 10 opcjonalnych parametrów. Wywołanie takiego potwora jest co najmniej niewygodne, dlatego przeciążamy konstruktor i tworzymy wiele jego krótszych wersji, wymagających mniej parametrów. Będą one wciąż odwoływać się do głównego konstruktora, przekazując jakieś domyślne wartości w miejsce pominiętych argumentów.

Bazowy interfejs budowniczego definiuje wszelkie możliwe etapy konstrukcji, a konkretni budowniczy implementują te kroki, by móc tworzyć poszczególne reprezentacje obiektów. Natomiast klasa kierownik pilnuje właściwego porządku konstruowania.

Zalety i wady

Zasada pojedynczej odpowiedzialności. Można odizolować skomplikowany kod konstrukcyjny od logiki biznesowej produktu.

Możemy konstruować obiekty etapami, odkładać niektóre etapy, lub wykonywać je rekursywnie.

Możemy, wykorzystać ponownie ten sam kod konstrukcyjny budując kolejne reprezentacje produktów.

Kod staje się bardziej skomplikowany, gdyż wdrożenie tego wzorca wiąże się z dodaniem wielu nowych klas.

Kod źródłowy

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *