Към съдържанието
wannacode

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} €");
Стъпка по стъпка, с думи
  1. Deliveries.Create("address") получава само текста от поръчката. Фабриката търси калъпа за него.
  2. От калъпа излиза нов AddressDelivery, но home е от тип IDelivery: викащият вижда само Price и Label().
  3. Същата фабрика, друг текст: "locker" минава покрай другите калъпи и спира на LockerDelivery.
  4. Етикетът вече е за автомат: класът се избира на едно място, а касата, прегледът и етикетът питат все него.
  5. Нов вид: класът PickupDelivery и един ред повече във фабриката. Редовете, които викат Deliveries.Create, не се пипат.
  6. "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 €, а пратката тръгна към адреса ѝ. На кой ред е грешката?

Избери реда с грешката.

Идеята

Кога не ти трябва

Фабриката е още един скок при четене: за да видиш кой клас се създава, отиваш другаде. Не си струва, когато:

  • на това място винаги трябва един и същ клас. new OfficeDelivery() там, където доставката винаги е до офис, е по-ясно от фабрика;
  • видът е известен, докато пишеш кода, а не идва като данни (от поръчка, файл, формуляр);
  • има фабрика за фабриката. DeliveryFactoryFactory почти винаги значи, че една от двете е излишна;
  • Factory Method има един подклас „за бъдещето“.

Струва си, когато видът идва като данни, а изборът на класа иначе би се копирал на няколко места. Или когато около създаването има общ алгоритъм, а различна е само стъпката „кой обект“.

Упражнение

Експресна доставка

Реши задачата в редактора си.

Експресна доставка. Магазинът добавя експресна доставка до адреса: вид "express", 9.90 €, етикет „…, експресно до адреса“. Добави клас ExpressDelivery и го направи достъпен през фабриката. Касата, прегледът и етикетът не се пипат. Създай експресна доставка за Мария и я отпечатай като в картата „Нов вид“:

Изход
Мария, експресно до адреса: 9.90 €

Още задачи

  • Главни букви и интервали. Формулярът праща "Locker" и " locker ". Направи фабриката да ги приема, като промениш само нея. Непознатите видове пак трябва да дават грешка.
  • JSON отчет. Добави към отчета от картата „Factory Method“ формат JSON: [{"product": "Кафе", "total": 12.50}, …]. Кои класове пишеш и кои не пипаш?
  • Цена според теглото. Автоматите приемат пратки до 20 кг, а над 5 кг доставката до адрес струва повече. Къде отива теглото: във фабриката или в метода за цена? Помисли защо.

Тест

Провери се

1. Магазинът добавя доставка с дрон, вид "drone". Какво пишеш при Simple Factory?
2. Фабриката получава вид, който не познава. Какво трябва да направи?
3. Кога Factory Method е по-добър от Simple Factory?
4. За да пести памет, фабриката пази по един обект за всеки вид и връща него при всяко create. Обектът пази получателя в поле. Какво става с две поръчки за автомат една след друга?
5. Кога фабрика не ти трябва?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·