Wzorce Projektowe: Fabryka cz. 2
Po przeczytaniu poprzedniego wpisu powinieneś wiedzieć, czym jest fabryka, jakie są jej rodzaje oraz jak wygląda jeden z nich, czyli Simple Factory. Dzisiaj przejdziemy do opisania pozostałych z nich.
Factory Method
Wróćmy do naszego przykładu. Tym razem zwróciliśmy się do zespołu inżynierów z pytaniem. Jak rozbudować nasza produkcję samochodów, żeby koszty wdrożenia nowego typu samochodu nas nie przerosły? Jako propozycję otrzymaliśmy tworzenie fabryki dla każdego typu samochodu, aby rozdzielić logikę tworzenia dla poszczególnych typów obiektów. Jak to przełoży się na nasz kod:
Przypomnijmy sobie, jak wyglądał interfejs Car:
interface Car { horsePower: number; model: string; year: number; }
Tworzymy wspólny interfejs fabryki z jedną metodą createCar:
interface CarFactory { createCar(): Car; }
Następnie dla każdego typu auta tworzymy odpowiednie implementacje:
class SportCarFactory implements CarFactory { createCar(): SportCar { return new SportCar(660, 'Sport', 2020, 340); } } class EstateCarFactory implements CarFactory { createCar(): EstateCar { return new EstateCar(320, 'Estate', 2022, 500); } } class SUVCarFactory implements CarFactory { createCar(): SUVCar { return new SUVCar(400, 'SUV', 2022, 'excellent'); } }
A kod klasy produkcyjnej powinien wyglądać tak:
class CarProduction { carFactory: CarFactory; constructor(carFactory: CarFactory) { this.carFactory = carFactory; } produceCar(): Car { return this.carFactory.createCar(); } }
W powyższym przykładzie Car definiuje typ produktów, a CarFactory definiuje interfejs dla fabryk. Każdy typ samochodu, taki jak SportCar, EstateCar i SUVCar, jest implementacją Car. Każda fabryka, taka jak SportCarFactory, EstateCarFactory i SUVCarFactory, implementuje CarFactory i odpowiada za tworzenie określonego typu samochodu.
Klasa CarProduction jest klasą kliencką i używa fabryki do tworzenia samochodów. W konstruktorze przyjmuje ona jedną z fabryk i przechowuje ją w polu factory. Metoda createCar wykorzystuje fabrykę, aby stworzyć i zwrócić obiekt typu Car.
Dzięki takiemu podejściu, dodanie nowej fabryki do kodu nie wymaga modyfikacji kodu w klasie CarProduction, co jest zgodne z zasadą open/close. Wystarczy dodać nową fabrykę i zastosować ją jako argument dla konstruktora klasy CarProduction.
Spójrzmy poniżej:
const sportCarFactory = new SportCarFactory(); const carProduction = new CarProduction(sportCarFactory); const car = carProduction.produceCar();
Tworzymy obiekt fabryki SportCarFactory i przekazujemy go do konstruktora klasy CarProduction. Następnie wywołujemy metodę produceCar, która uruchamia metodę createCar w SportCarFactory i zwraca obiekt SportCar.
Jak widać, Factory Method składa się z czterech elementów:
- Wspólny interfejs, określający cechy każdego z wytwarzanych produktów fabryki
- Konkretne implementacje klas produktów bazujących na wcześniej zdefiniowanym typie
- Abstrakcyjna klasa fabryki
- Klasy fabryk dla konkretnych produktów, które implementują wspólny interfejs fabryki
Dla lepszego zrozumienia posłużmy się diagramem:
Spójrzmy na wady i zalety z zastosowania podejścia z wykorzystaniem dedykowanych fabryk.
Zalety
- zachowanie zasady Open/Close
- ukrycie logiki tworzenia obiektów przed klientem, co zwiększa czytelność i przejrzystość kodu
- łatwe utrzymanie kodu, różne sposoby tworzenia obiektów i detale implementacyjne są przechowywane w odpowiednich klasach fabryk, co umożliwia szybkie wprowadzanie zmian
Wady
- każdorazowo, gdy dodajemy nowy typ produktu, nie unikniemy tworzenia kolejnych klas konkretnych obiektów i klas fabryk.
Abstract Factory
Zastosowanie wzorca metody wytwórczej okazało się strzałem w dziesiątkę. Dzięki niemu nasza fabryka samochodów może w elastyczny sposób reagować na rynkowe trendy. Chcąc iść za ciosem, postanowiliśmy wdrożyć to podejście w kolejnych procesach produkcyjnych. Wybór padł na tworzenie elementów karoserii. Fanów motoryzacji z góry przepraszamy za uproszczenie, którego użyjemy na potrzeby artykułu.
Elementy karoserii, jakich potrzebujemy do produkcji to maska, drzwi, korpus, klapa bagażnika. Każdy rodzaj auta wymaga innego podejścia przy tworzeniu tych części. Np. do samochodów sportowych potrzebny jest specjalny lekki metal, który zmniejszy wagę całego auta, dzięki czemu zwiększymy osiągi. Idąc podejściem metody wytwórczej, jeżeli per każda część potrzebna byłaby osobna fabryka, to przy trzech typach samochodów potrzebowalibyśmy aż 12 klas fabryk. Całe szczęście nasz zespół inżynierów ponownie stanął na wysokości zadania i zaproponował nam użycie Fabryki Abstrakcyjnej. Abstract Factory to wariacja wzorca fabryki, która umożliwia tworzenie obiektów w grupach powiązanych ze sobą, bez określania konkretnych klas produktów. Można nawet pokusić się o stwierdzenie, że Abstract Factory jest "fabryką fabryk". Zdefiniujmy zatem interfejs dla naszej abstrakcyjnej fabryki:
interface CarPartsFactory { createHood(): Hood; createBody(): Body; createDoor(): Door; createTrunk(): Trunk; }
Uprościliśmy maksymalnie poniższe snippety, żeby nie przytłoczyć Cię ilością kodu:
class SportCarPartsFactory implements CarPartsFactory { createHood(): Hood { // Logika tworzenia obiektu typu Hood dla fabryki samochodów sportowych return new SportCarHood(); } createBody(): Body { // Logika tworzenia obiektu typu Body dla fabryki samochodów sportowych return new SportCarBody(); } createDoor(): Door { // Logika tworzenia obiektu typu Door dla fabryki samochodów sportowych return new SportCarDoor(); } createTrunk(): Trunk { // Logika tworzenia obiektu typu Trunk dla fabryki samochodów sportowych return new SportCarTrunk(); } }
class SportCarHood implements Hood { // Implementacja klasy Hood dla samochodów sportowych } class SportCarBody implements Body { // Implementacja klasy Body dla samochodów sportowych } class SportCarDoor implements Door { // Implementacja klasy Door dla samochodów sportowych } class SportCarTrunk implements Trunk { // Implementacja klasy Trunk dla samochodów sportowych }
W naszym przykładzie konkretna fabryka SportCarPartsFactory implementuje interfejs CarPartsFactory. Jak widać, w nowej fabryce poszczególne metody zwracają konkretne implementacje typów abstrakcyjnych. Kod kliencki może korzystać z CarPartsFactory, aby tworzyć obiekty części bez konieczności wiedzy o konkretnych klasach.
W ten sposób Abstract Factory umożliwia elastyczne i modularne podejście do tworzenia obiektów, pozwalając na łatwe dodawanie nowych linii produkcyjnych i produktów bez wpływu na klienta. Jest to szczególnie korzystne w przypadku, gdy istnieje potrzeba tworzenia różnych grup produktów, które muszą być ze sobą powiązane i muszą działać jako jednolita całość.
Zalety
- elastyczność: pozwala na łatwe dodawanie nowych linii produkcyjnych i produktów bez łamania zasady open-close
- odseparowanie kodu klienckiego od konkretnych produktów: klient jest w stanie tworzyć obiekty produktów bez wiedzy o konkretnych klasach, co zapewnia lepszą separację i testowalność
Wady
- zwiększona złożoność: wprowadzenie Abstract Factory może spowodować wzrost złożoności aplikacji poprzez utworzenie nowej warstwy abstrakcji i hierarchii klas, które muszą być zdefiniowane i zarządzane.
- wzrost kosztów i czasu implementacji: tak jak wspomnieliśmy wyżej, wymaga więcej kodu i projektowania niż proste rozwiązania. Może to powodować wzrost kosztów i czasu implementacji. Dodatkowo deweloperzy muszą rozumieć zasady działania wzorca, żeby rozwijać taki kod.
Static Factory Method
W przypadku tego wzorca nie potrzebujemy tworzyć dodatkowych abstrakcji. Jest to alternatywa dla konstruktora. Statyczna metoda, która umożliwia utworzenie obiektu danej klasy. Z jej przykładami możemy się spotkać np. w standardowej bibliotece JavaScript. Jednym z przykładów użycia jest klasa Array. Zawiera ona statyczną metodę from, która umożliwia tworzenie nowej tablicy na podstawie istniejących iterable lub "tablico podobnych obiektów":
const array = Array.from([1, 2, 3, 4, 5]); console.log(array); // [1, 2, 3, 4, 5]
Dla lepszego zrozumienia pokażemy Ci prostą implementację:
class Vehicle { private constructor(public name: string) {} static create(name: string): Vehicle { return new Vehicle(name); } } const instance = Vehicle.create('example');
Jak widać, klasa Vehicle ma prywatny konstruktor, a jedynym sposobem utworzenia jej instancji jest użycie statycznej metody create.
Zalety
- fabryka statyczna umożliwia ukrycie szczegółów implementacyjnych tworzenia obiektów
- możliwość nazwania metody w dowolny sposób, czego nie możemy zrobić w przypadku konstruktora. Daje nam to większą elastyczność. Popatrzmy np. na lekko zmienioną klasę Vehicle:
class Vehicle { private constructor(public someField: string) {} static createFromName(name: string): Vehicle { return new Vehicle(name); } static createFromId(id: string): Vehicle { return new Vehicle(id); } }
W powyższym przykładzie mamy dwie różne metody fabryczne createFromName i createFromId, które umożliwiają tworzenie obiektów Vehicle. Dzięki odpowiednim nazwom możemy łatwo zrozumieć, jaki parametr powinniśmy przekazać oraz jakie operacje są wykonywane wewnątrz.
- możliwość zwracania podtypów, metody static factory mogą zwracać nie tylko obiekty klasy bazowej, ale również jej podtypy. Dzięki temu możemy zwracać bardziej wyspecjalizowane obiekty, dostosowane do konkretnych potrzeb klienta. Ponownie spójrzmy na Vehicle:
class Vehicle { private constructor(public someField: string) {} static createFromName(name: string): Car { return new Car(name); } static createFromId(id: string): Truck { return new Truck(id); } } class Car extends Vehicle { // pola i metody specyficzne dla samochodów } class Truck extends Vehicle { // pola i metody specyficzne dla ciężarówek } const car = Vehicle.createFromName("Audi"); const truck = Vehicle.createFromId("12345");
W powyższym przykładzie dodaliśmy dwie klasy dziedziczące po klasie Vehicle, czyli Car i Truck. Metody statyczne createFromName i createFromId nadal zwracają obiekty typu Vehicle, ale dzięki mechanizmowi dziedziczenia i polimorfizmu możemy przypisać te obiekty do zmiennych typu Car i Truck.
Wady
- wykluczenie możliwość dziedziczenia klas, które posiadają statyczne fabryki ze względu na to, że konstruktor wewnątrz zostaje ustawiony jako prywatny.
- w przypadku bardziej rozbudowanych klas takie metody wytwórcze mogą zginąć w gąszczu innych metod
Zakończenie
Podsumowując, wzorzec fabryczny jest potężnym narzędziem do tworzenia obiektów w elastyczny i łatwy w utrzymaniu sposób. Posiada kilka wariacji, a każda z nich jest dobra do rozwiązania konkretnych problemów architektonicznych. Enkapsuluje tworzenie obiektów, promuje luźne powiązania i zmniejsza zależność od konkretnych klas. Niezależnie od tego, czy wybierzesz tradycyjną metodę fabryczną, czy statyczną metodę fabryczną, kluczem do sukcesu jest zrozumienie problemu, który próbujesz rozwiązać, i wybranie podejścia, które najlepiej odpowiada Twoim potrzebom. Używając wzorca fabryki, możesz pisać kod, który jest łatwiejszy w utrzymaniu, testowaniu i rozbudowie w czasie, co czyni go niezbędnym narzędziem dla każdego programisty.