W tym poście przyjrzymy się parze wzorców projektowych związanych z tworzeniem obiektów: Metodzie Wytwórczej (ang. Factory Method) i Fabryce Abstrakcyjnej (ang. Abstract Factory).
Ten post nie będzie o wzorcu projektowym Fabryka. Dlaczego? Ponieważ na oryginalnej liście dwudziestu trzech wzorców opublikowanych przez Gang of Four w książce "Design Patterns: Elements of Reusable Object-Oriented Software" znalazły się wzorce Fabryka Abstrakcyjna oraz Metoda Wytwórcza. Wzorca nazwanego "Fabryka" tam nie znajdziemy. Nie zmienia to oczywiście faktu, że wielu programistów używa w codziennej pracy nazwy "Fabryka". Zazwyczaj mają wtedy na myśli klasę, która posiada w sobie Metodę Wytwórczą, lub klasę będącą implementacją Fabryki Abstrakcyjnej. Ogólnie rzecz biorąc, w języku potocznym Fabryką nazywamy każdą klasę, której zadaniem jest całościowe tworzenie nowych obiektów. Piszę "całościowe", gdyż tym właśnie Fabryki różnią się od Builderów, które służą do tworzenia obiektów po kawałku.
Wzorce Fabryka Abstrakcyjna i Metoda Wytwórcza są ze sobą powiązane, dlatego postanowiłam opisać je w jednym poście.
Fabryki należą do wzorców kreacyjnych. Więcej o typach wzorców projektowych można poczytać tutaj [under construction].
1) Metoda Wytwórcza
Przykład 1.
namespace CSharpPractices { class Point { double x, y; public Point(double x, double y) { this.x = x; this.y = y; } } }
Póki co konstruktor tej klasy jest jak najbardziej poprawny, a jego obecność w tej klasie jest uzasadniona. Wyobraźmy sobie jednak sytuację, gdy chcemy móc utworzyć obiekt klasy Point także z współrzędnych biegunowych, a nie tylko kartezjańskich. Jak wtedy zmieniłby się konstruktor?
using System; namespace CSharpPractices { enum PointCreationType { Cartesian, Polar } class Point { double x, y; public Point(double x, double y, PointCreationType pointCreationType = PointCreationType.Cartesian) { if (pointCreationType == PointCreationType.Cartesian) { this.x = x; this.y = y; } else { this.x = x * Math.Cos(y); this.y = x * Math.Sin(y); } } } }
Przy konstruowaniu punktu musimy wziąć pod uwagę, którą metodę wybrał użytkownik klasy - czy chce tworzyć punkt ze współrzędnych kartezjańskich, czy biegunowych. Konstruktor znacznie się rozrósł. Wcześniej przypisywał tylko wartości do pól klasy - teraz podejmuje także decyzję na temat metody oraz wykonuje operacje matematyczne.
Co więcej, pojawił się problem z nazwami argumentów konstruktora. Nazwy "x" i "y" są adekwatne w kontekście współrzędnych kartezjańskich, ale przy współrzędnych biegunowych powinniśmy raczej użyć nazw "radius" i "angle", lub "rho" i "theta".
Nasza klasa zaczęła łamać Single Responsibility Principle (więcej o tej zasadzie można przeczytać tutaj [under construction]). Zamiast tylko reprezentować punkt w przestrzeni, zajmuje się też konwersją ze współrzędnych biegunowych na kartezjańskie. Jak rozwiązać ten problem? Najlepiej wyprowadzić logikę tworzenia punktu do osobnej klasy, dla której będzie to jedyna odpowiedzialność. Tak wygląda kod po refactoringu:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | using System; namespace CSharpPractices { class PointFactory { public Point FromCartesian(double x, double y) { return new Point(x, y); } public Point FromPolar(double radius, double angle) { return new Point(radius * Math.Cos(angle), radius * Math.Sin(angle)); } } class Point { double x, y; public Point(double x, double y) { this.x = x; this.y = y; } } } |
Kod zmienił się na lepsze. Usunęliśmy niepotrzebnego enuma, gdyż nazwy metod w fabryce mówią same za siebie. Także nazwy argumentów są adekwatne w obu przypadkach. Klasa Point nie jest już zaśmiecona logiką tworzenia punku na dwa różne sposoby. Klasa PointFactory jest spójna i ma jasno określone zadanie.
Przykład ten reprezentował najprostszy możliwy przypadek: tworzymy obiekt konkretnej klasy Point, nie ma tu żadnego dziedziczenia. Teraz przyjrzyjmy się nieco innemu przypadkowi.
Przykład 2.
Wyobraźmy sobie prostą hierarchię klas reprezentujących zwierzęta - psa i kota:
abstract class Animal { protected abstract string Name { get; } public override string ToString() { return "Jestem zwierzęciem typu " + Name; } } class Dog : Animal { protected override string Name => "Pies"; } class Cat : Animal { protected override string Name => "Kot"; }
Chcemy napisać program, który zapyta użytkownika o to, które zwierzę preferuje, a następnie, w zależności od odpowiedzi, wyświetli odpowiedni opis na ekran. Niechlujny programista mógłby to napisać mniej więcej tak:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | class Program { static void Main() { Console.WriteLine("Wolisz psy czy koty? Wybierz P lub K.\n"); var key = Console.ReadKey(); Console.WriteLine(); if(key.Key == ConsoleKey.P) { Console.WriteLine(new Dog()); } else { Console.WriteLine(new Cat()); } Console.ReadKey(); } } |
Dla uproszczenia nie zajmujemy się weryfikacją tego, co w konsolę wpisał użytkownik. Program ten będzie poza tym działał poprawnie. Jest to, oczywiście, dość paskudne rozwiązanie. Logika wyboru zwierzęcia na podstawie inputu użytkownika jest wciśnięta w funkcję Main na siłę. Zakładamy też, że to oczywiste, że ConsoleKey.P implikuje tworzenie psa, a ConsoleKey.K - kota. Zrefaktorujmy ten kod.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | enum AnimalType { Dog, Cat } class Program { static void Main() { Console.WriteLine("Wolisz psy czy koty? Wybierz P lub K.\n"); var key = Console.ReadKey(); Console.WriteLine(); var animalType = key.Key == ConsoleKey.P ? AnimalType.Dog : AnimalType.Cat; var animal = Create(animalType); Console.WriteLine(animal); Console.ReadKey(); } private static Animal Create(AnimalType animalType) { switch (animalType) { case AnimalType.Dog: return new Dog(); case AnimalType.Cat: return new Cat(); default: throw new ArgumentException("Nonexistant animal type."); } } } |
Kod wygląda już nieco lepiej - logikę tworzenia zwierzęcia wyprowadziliśmy do osobnej metody, dodaliśmy też enuma określającego typ zwierzęcia. Nie zmienia to faktu, że metoda Create pasuje do klasy Program jak pięść do nosa. Złamana jest Single Responsibility Principle - klasa Program zajmuje się zarówno obsługą konsoli, jak i podejmuje decyzję, jakie zwierzę ma zostać utworzone. Wyobraźmy sobie, że będziemy chcieli napisać testy jednostkowe do tego kodu. Będziemy musieli poprawiać testy zarówno gdy zmieni się logika wyświetlania komunikatów na ekran, jak i wtedy, gdy zmieni się zachowanie wewnątrz funkcji Create. Wyprowadźmy tę metodę do osobnej klasy.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | class AnimalFactory { internal Animal Create(AnimalType animalType) { switch (animalType) { case AnimalType.Dog: return new Dog(); case AnimalType.Cat: return new Cat(); default: throw new ArgumentException("Nonexistant animal type."); } } } class Program { static void Main() { Console.WriteLine("Wolisz psy czy koty? Wybierz P lub K.\n"); var key = Console.ReadKey(); Console.WriteLine(); var animalFactory = new AnimalFactory(); var animalType = key.Key == ConsoleKey.P ? AnimalType.Dog : AnimalType.Cat; var animal = animalFactory.Create(animalType); Console.WriteLine(animal); Console.ReadKey(); } } |
Znacznie lepiej. Każda klasa ma teraz ściśle określoną odpowiedzialność, a pisanie i utrzymanie dla nich testów jednostkowych powinno być proste i bezbolesne.
Metoda Create jest właśnie Metodą Wytwórczą - tworzy konkretne obiekty danego typu, eksponując użytkownikowi typ ogólny (w naszym przypadku - Animal).
2) Fabryka Abstrakcyjna
Przykład 1.
Wyobraźmy sobie następującą sytuację: mamy napisać kod, którego zadaniem jest wysyłanie do użytkowników aplikacji powiadomień w formie wiadomości. W zależności od kontekstu, wiadomością tą może być e-mail lub SMS. Tworzymy następującą hierarchię klas:
internal abstract class Message { internal string Text { get; private set; } protected Message(string text) { Text = text; } } internal class Email : Message { public Email(string text) : base(text) { } } internal class Sms : Message { public Sms(string text) : base(text) { } }
E-mail i SMS nie będą formatowane tak samo. SMS będzie bardziej treściwy i krótszy, a jeśli jego długość przekroczy 160 znaków, zostanie obcięty.
Chcemy napisać fabryki tworzące wiadomości odpowiedniego typu.
internal class EmailFactory { public override Email Create(string addressee, string content, DateTime sentAt) { var text = $@"Hello {addressee} You have a new message: {content} Sent to you at {sentAt}"; return new Email(text); } } internal class SmsFactory { public override Sms Create(string addressee, string content, DateTime sentAt) { var text = $"{addressee},{content}, Sent at {sentAt.ToShortTimeString()}"; return new Sms(TruncateToSmsLenght(text)); } const int SmsMaxLength = 160; private string TruncateToSmsLenght(string text) { return text.Substring(0, Math.Min(text.Length, SmsMaxLength)); } }
Jak widzimy, fabryki te zwracają obiekty konkretnych klas - Email i Sms.
Wyobraźmy sobie, że w funkcji Main chcemy wysłać smsa i e-mail do tego samego adresata, w tym samym czasie i o tej samej treści. Wyglądałoby to mniej więcej tak:
class Program { static void Main() { var addressee = "Jan Kowalski"; var content = "Zaproszenie na piwo a także trochę bzdur, by przekroczyć limit 160 znaków na potrzeby przykładu: " + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ultricies ultricies eros," + " vitae rutrum purus porttitor mollis. Vestibulum consectetur fermentum dui, sit amet tempor " + "tellus sagittis ac. Nullam pulvinar mattis ipsum id volutpat. Class aptent taciti sociosqu ad " + "litora torquent per conubia nostra, per inceptos himenaeos. Sed consequat laoreet mauris.";
var emailFactory = new EmailFactory();var email = emailFactory.Create(addressee, content, DateTime.Now); Console.WriteLine(email.Text + "\n"); var smsFactory = new SmsFactory(); var sms = smsFactory.Create(addressee, content, DateTime.Now); Console.WriteLine(email.Text + "\n"); Console.ReadKey(); } }
Kod ten posiada kilka problemów. Linijki w których tworzony i wyświetlany jest email i SMS są niemal identyczne. Używamy tych samych argumentów i operacji. Jak wiemy, klasy Email i Sms dziedziczą z jednej klasy abstrakcyjnej - Message. Spróbujmy zastosować analogiczną hierarchię dziedziczenia do fabryk:
internal abstract class Factory { public abstract Message Create(string addressee, string content, DateTime sentAt); } internal class EmailFactory : Factory { public override Message Create(string addressee, string content, DateTime sentAt) { var text = $@"Hello {addressee} You have a new message: {content} Sent to you at {sentAt}"; return new Email(text); } } internal class SmsFactory : Factory { public override Message Create(string addressee, string content, DateTime sentAt) { var text = $"{addressee},{content},Sent at {sentAt.ToShortTimeString()}"; return new Sms(TruncateToSmsLenght(text)); } const int SmsMaxLength = 160; private string TruncateToSmsLenght(string text) { return text.Substring(0, Math.Min(text.Length, SmsMaxLength)); } }
Dzięki zastosowaniu klasy abstrakcyjnej Factory określiliśmy jednolity interfejs do tworzenia obiektów typu Message. Dzięki temu możemy ładnie zrefaktorować funkcję Main:
class Program { static void Main() { var factories = new Factory[2]; factories[0] = new EmailFactory(); factories[1] = new SmsFactory();
var content = "Zaproszenie na piwo a także trochę bzdur, by przekroczyć limit 160 znaków na potrzeby przykładu: " + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ultricies ultricies eros," + " vitae rutrum purus porttitor mollis. Vestibulum consectetur fermentum dui, sit amet tempor " + "tellus sagittis ac. Nullam pulvinar mattis ipsum id volutpat. Class aptent taciti sociosqu ad " + "litora torquent per conubia nostra, per inceptos himenaeos. Sed consequat laoreet mauris.";foreach (var factory in factories) { var message = factory.Create("Jan Kowalski", content, DateTime.Now); Console.WriteLine(message.Text + "\n"); } Console.ReadKey(); } }
Wygląda na to, że program działa tak jak powinien, wyświetlając najpierw przydługiego e-maila, a potem uciętego SMSa:
Hello Jan Kowalski You have a new message: Zaproszenie na piwo a takze troche bzdur, by przekroczyc limit 160 znaków na potrzeby przykladu: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ultricies ultricies eros, vitae rutrum purus porttitor mol lis. Vestibulum consectetur fermentum dui, sit amet tempor tellus sagittis ac. N ullam pulvinar mattis ipsum id volutpat. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Sed consequat laoreet mauri s. Sent to you at 2018-11-21 20:29:48 Jan Kowalski,Zaproszenie na piwo a takze troche bzdur, by przekroczyc limit 160 znaków na potrzeby przykladu: Lorem ipsum dolor sit amet, consectetur adipiscing
Teraz tworzenie wiadomości ma jednolity interfejs, który implementują odpowiednie klasy dziedziczące. Zachowujemy zarówno Single Responsibility Principle, jak i Open-Closed Principle. Każdą klasę można łatwo przetestować.
W powyższym przykładzie klasa Factory jest właśnie Fabryką Abstrakcyjną - klasą, która dostarcza jednolity interfejs do tworzenia obiektów powiązanych ze sobą typów. Implementacji dostarczają klasy dziedziczące. W przykładzie konkretne Fabryki zostały w funkcji Main stworzone w sposób nieco naiwny, ale w bardziej rzeczywistych zastosowaniach tworzone byłyby dynamicznie, a spójny interfejs pozwoliłby nam korzystać z nich bez wiedzy, jaki konkretnie typ jest aktualnie w użyciu.
Rozpatrzmy nieco bardziej złożony przykład.
Przykład 2.
Piszemy oprogramowanie dla cukierni. Chcemy napisać program, który wyświetli na ekran dane świeżo upieczonego ciasta czekoladowego. Program ten może wyglądać tak:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 | using System; namespace CSharpPractices { internal class Bottom { internal string Aroma { get; set; } internal string FlourType { get; set; } } internal class Filling { internal string Flavor { get; set; } internal string Base { get; set; } } internal class Coating { internal string Name { get; set; } } class Pie { public Pie(string name, Bottom bottom, Filling filling, Coating coating) { Name = name; Bottom = bottom; Filling = filling; Coating = coating; } public string Name { get; private set; } public Bottom Bottom { get; private set; } public Filling Filling { get; private set; } public Coating Coating { get; private set; } public override string ToString() { return $"Oto ciasto {Name}, ma spód o smaku {Bottom.Aroma} upieczony z mąki {Bottom.FlourType}," + $" wypełnienie zrobiono z {Filling.Base} o smaku {Filling.Flavor}," + $" a polewa to {Coating.Name}"; } } class Program { static void Main() { var pie = new Pie( "czekoladowe", new Bottom { Aroma = "kakaowym", FlourType = "kokosowej" }, new Filling{ Base = "śmietanki", Flavor = "czekoladowym"}, new Coating { Name = "gorzka czekolada" }); Console.WriteLine(pie); Console.ReadKey(); } } } |
Na razie program jest dość prosty. Wyobraźmy sobie jednak, że pojawia się potrzeba dodania możliwości wyboru ciasta: czekoladowego lub wiśniowego. Przeróbmy nasz program.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 | using System; namespace CSharpPractices { internal class Bottom { internal string Aroma { get; set; } internal string FlourType { get; set; } } internal class Filling { internal string Flavor { get; set; } internal string Base { get; set; } } internal class Coating { internal string Name { get; set; } } class Pie { public Pie(string name, Bottom bottom, Filling filling, Coating coating) { Name = name; Bottom = bottom; Filling = filling; Coating = coating; } public string Name { get; private set; } public Bottom Bottom { get; private set; } public Filling Filling { get; private set; } public Coating Coating { get; private set; } public override string ToString() { return $"Oto ciasto {Name}, ma spód o smaku {Bottom.Aroma} upieczony z mąki {Bottom.FlourType}," + $" wypełnienie zrobiono z {Filling.Base} o smaku {Filling.Flavor}," + $" a polewa to {Coating.Name}"; } } class Program { static void Main() { Console.WriteLine("Czekoladowe (C) czy wiśniowe (W)?\n"); var key = Console.ReadKey(); Console.WriteLine(); if (key.Key == ConsoleKey.C) { var pie = new Pie( "czekoladowe", new Bottom { Aroma = "kakaowym", FlourType = "kokosowej" }, new Filling{ Base = "śmietanki", Flavor = "czekoladowym"}, new Coating { Name = "gorzka czekolada" }); Console.WriteLine(pie); } else { var pie = new Pie( "wiśniowe", new Bottom { Aroma = "kakaowym", FlourType = "pszennej" }, new Filling { Base = "kandyzowanych wiśni", Flavor = "(zaskoczenie!) wiśniowym" }, new Coating { Name = "lukier" }); Console.WriteLine(pie); } Console.ReadKey(); } } } |
Jest to podejście dość naiwne. Fakt, że ciasto jest czekoladowe lub wiśniowe zależy od tego, co w funkcji Main przekazaliśmy do argumentów konstruktora. Moglibyśmy wpisać tam kompletne bzdury, uzyskując np ciasto "czekoladowe" o wypełnieniu z kiełbasy chorizo. Lepiej wyprowadźmy logikę tworzenia odpowiednich ciast do osobnej klasy.
abstract class PieFactory { internal abstract string Name {get;} internal abstract Bottom CreateBottom(); internal abstract Filling CreateFilling(); internal abstract Coating CreateCoating(); } class ChocolatePieFactory : PieFactory { internal override string Name => "czekoladowe"; internal override Bottom CreateBottom() { return new Bottom { Aroma = "kakaowym", FlourType = "kokosowej" }; } internal override Filling CreateFilling() { return new Filling { Base = "śmietanki", Flavor = "czekoladowym" }; } internal override Coating CreateCoating() { return new Coating { Name = "gorzka czekolada" }; } } class CherryPieFactory : PieFactory { internal override string Name => "wiśniowe"; internal override Bottom CreateBottom() { return new Bottom { Aroma = "kakaowym", FlourType = "pszennej" }; } internal override Filling CreateFilling() { return new Filling { Base = "kandyzowanych wiśni", Flavor = "(zaskoczenie!) wiśniowym" }; } internal override Coating CreateCoating() { return new Coating { Name = "lukier" }; } }
Teraz możemy przerobić funkcję Main:
class Program { static void Main() { Console.WriteLine("Czekoladowe (C) czy wiśniowe (W)?\n"); var key = Console.ReadKey(); Console.WriteLine(); PieFactory pieFactory; if (key.Key == ConsoleKey.C) { pieFactory = new ChocolatePieFactory(); } else { pieFactory = new CherryPieFactory(); } Console.WriteLine(new Pie( pieFactory.Name, pieFactory.CreateBottom(), pieFactory.CreateFilling(), pieFactory.CreateCoating())); Console.ReadKey(); } }
Funkcję Main wypadałoby jeszcze zrefactorować, ale pozostawię to czytelnikom jako zadanie dla chętnych.
Zastanówmy się, co osiągnęliśmy po refactorze. Klasa PieFactory eksponuje prosty i przejrzysty interfejs do robienia ciast. Dodając nowy tym ciasta do programu trzeba będzie tylko dodać nową klasę dziedziczącą z PieFactory. Każda klasa ma jasno określoną odpowiedzialność. Zasady SRP i OCP są zachowane.
Metoda Wytwórcza a Fabryka Abstrakcyjna
Zadajmy sobie jeszcze pytanie, czym różnią się wzorce Metoda Wytwórcza i Fabryka Abstrakcyjna:
- Metoda Wytwórcza zajmuje się tworzeniem obiektów jednego typu, w przeciwieństwie do Fabryki Abstrakcyjnej, która może dostarczać interfejs do tworzenia całych rodzin obiektów (w naszym przykładzie: elementów tworzących ciasto)
- Fabryka Abstrakcyjna, jak sama nazwa wskazuje, jest klasą abstrakcyjną (lub interfejsem), z której dziedziczą klasy odpowiedzialne za odpowiednie typy obiektów (u nas: Fabryka Ciast Czekoladowych i Fabryka Ciast Wiśniowych). W Metodzie Wytwórczej mamy do czynienia tylko z jedną klasą, bez dziedziczenia.
- Metoda Wytwórcza bardzo często ma postać metody ze switchem w środku, w zależności od argumentu zwracającej rożne obiekty dziedziczące z jednego typu
W poście tym korzystałam z poniższych materiałów:
- https://www.udemy.com/design-patterns-csharp-dotnet
- https://www.dofactory.com/net/factory-method-design-pattern
- http://tomaszjarzynski.pl/metoda-wytworcza-wzorzec-projektowy-factory-method/
- http://tomaszjarzynski.pl/fabryka-abstrakcyjna-wzorzec-projektowy-abstract-factory/
Komentarze
Prześlij komentarz