Factory Method и Simple Factory
Накратко: Factory събира на едно място решението кой клас да се създаде: Simple Factory държи избора на едно място, а Factory Method оставя подкласа да реши кой обект да направи.
Проблем
Три места знаят всеки вид доставка
Поръчката казва как стига до клиента: "address" (до адреса), "office" (в офис на куриера) или "locker" (в автомат). За всеки вид има клас със своя цена и свой етикет:
interface IDelivery{ decimal Price { get; }
string Label(string recipient);}
class AddressDelivery : IDelivery{ public decimal Price => 5.90m;
public string Label(string recipient) => $"{recipient}, до адреса";}
class OfficeDelivery : IDelivery{ public decimal Price => 3.90m;
public string Label(string recipient) => $"{recipient}, в офис на куриера";}
class LockerDelivery : IDelivery{ public decimal Price => 2.90m;
public string Label(string recipient) => $"{recipient}, в автомат";}Три части от магазина имат нужда от доставката: касата, прегледът на цената и принтерът за етикети. Всяка сама решава кой клас да създаде:
static decimal Checkout(string type, decimal items){ IDelivery delivery = type switch { "address" => new AddressDelivery(), "office" => new OfficeDelivery(), "locker" => new LockerDelivery(), _ => throw new ArgumentException(type), }; return items + delivery.Price;}
static decimal PreviewPrice(string type){ IDelivery delivery = type switch { "address" => new AddressDelivery(), "office" => new OfficeDelivery(), "locker" => new LockerDelivery(), _ => throw new ArgumentException(type), }; return delivery.Price;}
static string PrintLabel(string type, string recipient){ IDelivery delivery = type switch { "office" => new OfficeDelivery(), _ => new AddressDelivery(), }; return delivery.Label(recipient);}Console.WriteLine($"Поръчка: {Checkout("locker", 40):F2} €");Console.WriteLine($"Преглед: доставка {PreviewPrice("locker"):F2} €");Console.WriteLine("Етикет: " + PrintLabel("locker", "Мария"));Поръчка: 42.90 €Преглед: доставка 2.90 €Етикет: Мария, до адресаАвтоматите дойдоха миналия месец. Някой добави "locker" в касата и в прегледа. Принтерът за етикети е писан преди това и има _ за адрес. Мария плати доставка до автомат, а куриерът тръгна към адреса ѝ.
Нов вид е три промени, и една се забравя. Изборът „кой клас за кой вид“ е копиран, а копията не знаят едно за друго.
Идеята
Simple Factory: new живее на едно място
Изборът „кой клас за кой вид“ е едно решение, затова е на едно място: фабрика. Тя получава вида и връща нов обект. Викащият не пише new и не знае класа: получава интерфейса.
Ролите:
- продуктът: общата форма, интерфейсът
IDelivery; - конкретните продукти:
AddressDelivery,OfficeDelivery,LockerDelivery; - фабриката:
Deliveries.Create(type), единственото място, което знае кой клас за кой вид; - клиентите: касата, прегледът, етикетът. Те само питат.
Това се казва Simple Factory (проста фабрика). Тя не е от книгата на „бандата на четиримата“, но е най-честата форма. Factory Method от книгата е малко друго нещо и идва по-нататък в урока.
Едно правило: непознат вид е грешка, а не тихо подразбиране. Фабрика, която не познава вида, хвърля изключение с ясно съобщение. Иначе грешката не изчезва, а тръгва с куриера към грешния адрес.
Таблицата „име → стратегия“ от урока Strategy е същото нещо: фабрика, която по текст дава обект.
Виж го
Една фабрика за трите места
static class Deliveries{ public static IDelivery Create(string type) => type switch { "address" => new AddressDelivery(), "office" => new OfficeDelivery(), "locker" => new LockerDelivery(), _ => throw new ArgumentException($"Непознат вид доставка: {type}"), };}Касата, прегледът и етикетът вече не избират, а питат:
static decimal Checkout(string type, decimal items){ return items + Deliveries.Create(type).Price;}
static decimal PreviewPrice(string type){ return Deliveries.Create(type).Price;}
static string PrintLabel(string type, string recipient){ return Deliveries.Create(type).Label(recipient);}Console.WriteLine($"Поръчка: {Checkout("locker", 40):F2} €");Console.WriteLine($"Преглед: доставка {PreviewPrice("locker"):F2} €");Console.WriteLine("Етикет: " + PrintLabel("locker", "Мария"));Поръчка: 42.90 €Преглед: доставка 2.90 €Етикет: Мария, в автоматЕтикетът е за автомат. Изборът е на едно място, затова не може да е верен в касата и грешен в етикета.
По-точно, за любопитните
Фабриката може да е и таблица: Dictionary<string, Func<IDelivery>> със стойности () => new AddressDelivery(). Таблицата е удобна, когато видовете се добавят отвън (всеки модул регистрира своите). switch е по-ясен, когато всички видове са известни на едно място.
Виж го
Нов вид: един клас и един ред
Магазинът добавя вземане от магазина: вид "pickup", без такса. Новото е един клас:
class PickupDelivery : IDelivery{ public decimal Price => 0.00m;
public string Label(string recipient) => $"{recipient}, от магазина";}и един ред във фабриката, маркиран тук:
static class Deliveries{ public static IDelivery Create(string type) => type switch { "address" => new AddressDelivery(), "office" => new OfficeDelivery(), "locker" => new LockerDelivery(), "pickup" => new PickupDelivery(), _ => throw new ArgumentException($"Непознат вид доставка: {type}"), };}IDelivery home = Deliveries.Create("address");Console.WriteLine($"{home.Label("Мария")}: {home.Price:F2} €");
IDelivery locker = Deliveries.Create("locker");Console.WriteLine($"{locker.Label("Мария")}: {locker.Price:F2} €");
IDelivery pickup = Deliveries.Create("pickup");Console.WriteLine($"{pickup.Label("Мария")}: {pickup.Price:F2} €");Мария, до адреса: 5.90 €Мария, в автомат: 2.90 €Мария, от магазина: 0.00 €IDelivery home = Deliveries.Create("address");Console.WriteLine($"{home.Label("Мария")}: {home.Price:F2} €");
IDelivery locker = Deliveries.Create("locker");Console.WriteLine($"{locker.Label("Мария")}: {locker.Price:F2} €");
IDelivery pickup = Deliveries.Create("pickup");Console.WriteLine($"{pickup.Label("Мария")}: {pickup.Price:F2} €");Стъпка по стъпка, с думи
- Deliveries.Create("address") получава само текста от поръчката. Фабриката търси калъпа за него.
- От калъпа излиза нов AddressDelivery, но home е от тип IDelivery: викащият вижда само Price и Label().
- Същата фабрика, друг текст: "locker" минава покрай другите калъпи и спира на LockerDelivery.
- Етикетът вече е за автомат: класът се избира на едно място, а касата, прегледът и етикетът питат все него.
- Нов вид: класът PickupDelivery и един ред повече във фабриката. Редовете, които викат Deliveries.Create, не се пипат.
- "pickup" минава като всеки друг вид. Новият вид е добавен на едно място.
Касата, прегледът и етикетът не се пипат. Те разбират за новия вид, когато поръчка го поиска.
Виж го
Непознат вид
Видът идва отвън: от формуляр, от сайт на партньор, от стар файл. Рано или късно идва вид, който го няма:
try{ IDelivery delivery = Deliveries.Create("drone"); Console.WriteLine(delivery.Label("Мария"));}catch (ArgumentException e){ Console.WriteLine(e.Message);}Непознат вид доставка: droneГрешката казва кой вид е непознат. Хваща се там, където поръчката влиза в магазина, и клиентът вижда съобщение, вместо пратка на грешното място.
А ако фабриката няма_? C# дава само предупреждение. Програмата се компилира и пада, когато дойде непознат вид:static class Deliveries{ public static IDelivery Create(string type) => type switch { "address" => new AddressDelivery(), "office" => new OfficeDelivery(), "locker" => new LockerDelivery(), };}warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern '""' is not covered.
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.Unmatched value was drone.Виж го
Factory Method: подкласът решава
Simple Factory избира по текст. Понякога изборът не е по текст, а по това кой обект пита.
Отчетът за продажбите се праща като CSV (за счетоводството) и като HTML таблица (в имейла). Двата правят едно и също: всеки продукт е ред, а накрая има ред „Общо“. Различен е само файлът, в който се пишат редовете:
interface IReportFile{ void Add(string product, decimal total);
string Text();}
class CsvFile : IReportFile{ private readonly List<string> lines = new() { "продукт;сума" };
public void Add(string product, decimal total) => lines.Add($"{product};{total:F2}");
public string Text() => string.Join("\n", lines);}
class HtmlFile : IReportFile{ private readonly List<string> rows = new();
public void Add(string product, decimal total) => rows.Add($"<tr><td>{product}</td><td>{total:F2}</td></tr>");
public string Text() => "<table>\n" + string.Join("\n", rows) + "\n</table>";}record Sale(string Product, decimal Total);
abstract class ReportExporter{ protected abstract IReportFile CreateFile();
public string Export(List<Sale> sales) { IReportFile file = CreateFile(); decimal overall = 0; foreach (var sale in sales) { file.Add(sale.Product, sale.Total); overall += sale.Total; } file.Add("Общо", overall); return file.Text(); }}
class CsvExporter : ReportExporter{ protected override IReportFile CreateFile() => new CsvFile();}
class HtmlExporter : ReportExporter{ protected override IReportFile CreateFile() => new HtmlFile();}var sales = new List<Sale> { new("Кафе", 12.50m), new("Чай", 8.00m) };Console.WriteLine(new CsvExporter().Export(sales));Console.WriteLine(new HtmlExporter().Export(sales));продукт;сумаКафе;12.50Чай;8.00Общо;20.50<table><tr><td>Кафе</td><td>12.50</td></tr><tr><td>Чай</td><td>8.00</td></tr><tr><td>Общо</td><td>20.50</td></tr></table>Това е Factory Method: базовият клас вика метод, който създава обекта (CreateFile()), а кой клас точно, решава подкласът.
Защо не се подава готов файл? Файлът събира редове. Всяко Export трябва да започне с празен файл, затова отчетът не пази файл, а знае как да направи нов.
Кога Simple Factory стига? Factory Method си заслужава, когато около създаването има общ алгоритъм, който подкласовете не трябва да повтарят (тук: редовете и общата сума). Ако искаш само обект по вид, без алгоритъм около него, Simple Factory е по-ясна. А подклас, който съществува само за да върне нов обект, е церемония. Тогава подай Func<IReportFile>: new ReportExporter(() => new CsvFile()).
Пробвай
Предвиди изхода
Видовете идват от формуляр на сайта. Какво печата програмата?
string[] types = { "office", "Locker", "locker" };foreach (var type in types){ try { Console.WriteLine(Deliveries.Create(type).Label("Мария")); } catch (ArgumentException e) { Console.WriteLine(e.Message); }}Пробвай
Намери грешката
Друг програмист е написал фабриката така:
static class Deliveries{ public static IDelivery Create(string type) => type switch { "office" => new OfficeDelivery(), "locker" => new LockerDelivery(), _ => new AddressDelivery(), };}Поръчката идва от сайт на партньор, който праща вида с главна буква:
IDelivery delivery = Deliveries.Create("Locker");Console.WriteLine("Етикет: " + delivery.Label("Мария"));Console.WriteLine($"Доставка: {delivery.Price:F2} €");Етикет: Мария, до адресаДоставка: 5.90 €Мария избра автомат. Плати доставка 5.90 € вместо 2.90 €, а пратката тръгна към адреса ѝ. На кой ред е грешката?
Избери реда с грешката.
Този ред тихо превръща всеки непознат вид в доставка до адрес. Главната буква е само поводът: утре ще е "lockr" или нов вид, който някой е забравил тук. Фабриката трябва да хвърля ясна грешка за непознат вид. Ако искаш да приемаш и "Locker", сведи вида до малки букви в самата фабрика, на едно място, а не във всеки, който я вика.
Идеята
Кога не ти трябва
Фабриката е още един скок при четене: за да видиш кой клас се създава, отиваш другаде. Не си струва, когато:
- на това място винаги трябва един и същ клас.
new OfficeDelivery()там, където доставката винаги е до офис, е по-ясно от фабрика; - видът е известен, докато пишеш кода, а не идва като данни (от поръчка, файл, формуляр);
-
има фабрика за фабриката.
DeliveryFactoryFactoryпочти винаги значи, че една от двете е излишна; - Factory Method има един подклас „за бъдещето“.
Струва си, когато видът идва като данни, а изборът на класа иначе би се копирал на няколко места. Или когато около създаването има общ алгоритъм, а различна е само стъпката „кой обект“.
Упражнение
Експресна доставка
Реши задачата в редактора си.
Експресна доставка. Магазинът добавя експресна доставка до адреса: вид "express", 9.90 €, етикет „…, експресно до адреса“. Добави клас ExpressDelivery и го направи достъпен през фабриката. Касата, прегледът и етикетът не се пипат. Създай експресна доставка за Мария и я отпечатай като в картата „Нов вид“:
Мария, експресно до адреса: 9.90 €Още задачи
- Главни букви и интервали. Формулярът праща
"Locker"и" locker ". Направи фабриката да ги приема, като промениш само нея. Непознатите видове пак трябва да дават грешка. - JSON отчет. Добави към отчета от картата „Factory Method“ формат JSON:
[{"product": "Кафе", "total": 12.50}, …]. Кои класове пишеш и кои не пипаш? - Цена според теглото. Автоматите приемат пратки до 20 кг, а над 5 кг доставката до адрес струва повече. Къде отива теглото: във фабриката или в метода за цена? Помисли защо.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Dictionary и HashSet · record, switch и шаблони · Шаблони за дизайн · Strategy
- Продължава в: Builder · Магазинът: кой шаблон къде
- Задачи по темата: Шаблони за дизайн