Factory Method и Simple Factory
Накратко: Factory събира на едно място решението кой клас да се създаде: Simple Factory държи избора на едно място, а Factory Method оставя подкласа да реши кой обект да направи.
Проблем
Три места знаят всеки вид доставка
Поръчката казва как стига до клиента: "address" (до адреса), "office" (в офис на куриера) или "locker" (в автомат). За всеки вид има клас със своя цена и свой етикет:
interface Delivery { double price();
String label(String recipient);}
class AddressDelivery implements Delivery { @Override public double price() { return 5.90; }
@Override public String label(String recipient) { return recipient + ", до адреса"; }}
class OfficeDelivery implements Delivery { @Override public double price() { return 3.90; }
@Override public String label(String recipient) { return recipient + ", в офис на куриера"; }}
class LockerDelivery implements Delivery { @Override public double price() { return 2.90; }
@Override public String label(String recipient) { return recipient + ", в автомат"; }}Три части от магазина имат нужда от доставката: касата, прегледът на цената и принтерът за етикети. Всяка сама решава кой клас да създаде:
static double checkout(String type, double items) { Delivery delivery = switch (type) { case "address" -> new AddressDelivery(); case "office" -> new OfficeDelivery(); case "locker" -> new LockerDelivery(); default -> throw new IllegalArgumentException(type); }; return items + delivery.price();}
static double previewPrice(String type) { Delivery delivery = switch (type) { case "address" -> new AddressDelivery(); case "office" -> new OfficeDelivery(); case "locker" -> new LockerDelivery(); default -> throw new IllegalArgumentException(type); }; return delivery.price();}
static String printLabel(String type, String recipient) { Delivery delivery = switch (type) { case "office" -> new OfficeDelivery(); default -> new AddressDelivery(); }; return delivery.label(recipient);}System.out.printf("Поръчка: %.2f €%n", checkout("locker", 40));System.out.printf("Преглед: доставка %.2f €%n", previewPrice("locker"));System.out.println("Етикет: " + printLabel("locker", "Мария"));Поръчка: 42.90 €Преглед: доставка 2.90 €Етикет: Мария, до адресаАвтоматите дойдоха миналия месец. Някой добави "locker" в касата и в прегледа. Принтерът за етикети е писан преди това и има default за адрес. Мария плати доставка до автомат, а куриерът тръгна към адреса ѝ.
Нов вид е три промени, и една се забравя. Изборът „кой клас за кой вид“ е копиран, а копията не знаят едно за друго.
Идеята
Simple Factory: new живее на едно място
Изборът „кой клас за кой вид“ е едно решение, затова е на едно място: фабрика. Тя получава вида и връща нов обект. Викащият не пише new и не знае класа: получава интерфейса.
Ролите:
- продуктът: общата форма, интерфейсът
Delivery; - конкретните продукти:
AddressDelivery,OfficeDelivery,LockerDelivery; - фабриката:
Deliveries.create(type), единственото място, което знае кой клас за кой вид; - клиентите: касата, прегледът, етикетът. Те само питат.
Това се казва Simple Factory (проста фабрика). Тя не е от книгата на „бандата на четиримата“, но е най-честата форма. Factory Method от книгата е малко друго нещо и идва по-нататък в урока.
Едно правило: непознат вид е грешка, а не тихо подразбиране. Фабрика, която не познава вида, хвърля изключение с ясно съобщение. Иначе грешката не изчезва, а тръгва с куриера към грешния адрес.
Таблицата „име → стратегия“ от урока Strategy е същото нещо: фабрика, която по текст дава обект.
Виж го
Една фабрика за трите места
class Deliveries { public static Delivery create(String type) { return switch (type) { case "address" -> new AddressDelivery(); case "office" -> new OfficeDelivery(); case "locker" -> new LockerDelivery(); default -> throw new IllegalArgumentException("Непознат вид доставка: " + type); }; }}Касата, прегледът и етикетът вече не избират, а питат:
static double checkout(String type, double items) { return items + Deliveries.create(type).price();}
static double previewPrice(String type) { return Deliveries.create(type).price();}
static String printLabel(String type, String recipient) { return Deliveries.create(type).label(recipient);}System.out.printf("Поръчка: %.2f €%n", checkout("locker", 40));System.out.printf("Преглед: доставка %.2f €%n", previewPrice("locker"));System.out.println("Етикет: " + printLabel("locker", "Мария"));Поръчка: 42.90 €Преглед: доставка 2.90 €Етикет: Мария, в автоматЕтикетът е за автомат. Изборът е на едно място, затова не може да е верен в касата и грешен в етикета.
По-точно, за любопитните
Фабриката може да е и таблица: Map<String, Supplier<Delivery>> със стойности AddressDelivery::new. Таблицата е удобна, когато видовете се добавят отвън (всеки модул регистрира своите). switch е по-ясен, когато всички видове са известни на едно място, и компилаторът проверява, че има default (виж картата „Непознат вид“).
Виж го
Нов вид: един клас и един ред
Магазинът добавя вземане от магазина: вид "pickup", без такса. Новото е един клас:
class PickupDelivery implements Delivery { @Override public double price() { return 0.00; }
@Override public String label(String recipient) { return recipient + ", от магазина"; }}и един ред във фабриката, маркиран тук:
class Deliveries { public static Delivery create(String type) { return switch (type) { case "address" -> new AddressDelivery(); case "office" -> new OfficeDelivery(); case "locker" -> new LockerDelivery(); case "pickup" -> new PickupDelivery(); default -> throw new IllegalArgumentException("Непознат вид доставка: " + type); }; }}Delivery home = Deliveries.create("address");System.out.printf("%s: %.2f €%n", home.label("Мария"), home.price());
Delivery locker = Deliveries.create("locker");System.out.printf("%s: %.2f €%n", locker.label("Мария"), locker.price());
Delivery pickup = Deliveries.create("pickup");System.out.printf("%s: %.2f €%n", pickup.label("Мария"), pickup.price());Мария, до адреса: 5.90 €Мария, в автомат: 2.90 €Мария, от магазина: 0.00 €Delivery home = Deliveries.create("address");System.out.printf("%s: %.2f €%n", home.label("Мария"), home.price());
Delivery locker = Deliveries.create("locker");System.out.printf("%s: %.2f €%n", locker.label("Мария"), locker.price());
Delivery pickup = Deliveries.create("pickup");System.out.printf("%s: %.2f €%n", pickup.label("Мария"), pickup.price());Стъпка по стъпка, с думи
- Deliveries.create("address") получава само текста от поръчката. Фабриката търси калъпа за него.
- От калъпа излиза нов AddressDelivery, но home е от тип Delivery: викащият вижда само price() и label().
- Същата фабрика, друг текст: "locker" минава покрай другите калъпи и спира на LockerDelivery.
- Етикетът вече е за автомат: класът се избира на едно място, а касата, прегледът и етикетът питат все него.
- Нов вид: класът PickupDelivery и един ред повече във фабриката. Редовете, които викат Deliveries.create, не се пипат.
- "pickup" минава като всеки друг вид. Новият вид е добавен на едно място.
Касата, прегледът и етикетът не се пипат. Те разбират за новия вид, когато поръчка го поиска.
Виж го
Непознат вид
Видът идва отвън: от формуляр, от сайт на партньор, от стар файл. Рано или късно идва вид, който го няма:
try { Delivery delivery = Deliveries.create("drone"); System.out.println(delivery.label("Мария"));} catch (IllegalArgumentException e) { System.out.println(e.getMessage());}Непознат вид доставка: droneГрешката казва кой вид е непознат. Хваща се там, където поръчката влиза в магазина, и клиентът вижда съобщение, вместо пратка на грешното място.
А ако фабриката нямаdefault? Java не те оставя да го забравиш: switch като израз трябва да покрие всяка стойност, иначе програмата не се компилира:class Deliveries { public static Delivery create(String type) { return switch (type) { case "address" -> new AddressDelivery(); case "office" -> new OfficeDelivery(); case "locker" -> new LockerDelivery(); }; }}error: the switch expression does not cover all possible input valuesВиж го
Factory Method: подкласът решава
Simple Factory избира по текст. Понякога изборът не е по текст, а по това кой обект пита.
Отчетът за продажбите се праща като CSV (за счетоводството) и като HTML таблица (в имейла). Двата правят едно и също: всеки продукт е ред, а накрая има ред „Общо“. Различен е само файлът, в който се пишат редовете:
interface ReportFile { void add(String product, double total);
String text();}
class CsvFile implements ReportFile { private final List<String> lines = new ArrayList<>(List.of("продукт;сума"));
@Override public void add(String product, double total) { lines.add(product + ";" + String.format("%.2f", total)); }
@Override public String text() { return String.join("\n", lines); }}
class HtmlFile implements ReportFile { private final List<String> rows = new ArrayList<>();
@Override public void add(String product, double total) { rows.add("<tr><td>" + product + "</td><td>" + String.format("%.2f", total) + "</td></tr>"); }
@Override public String text() { return "<table>\n" + String.join("\n", rows) + "\n</table>"; }}record Sale(String product, double total) {}
abstract class ReportExporter { protected abstract ReportFile createFile();
public String export(List<Sale> sales) { ReportFile file = createFile(); double overall = 0; for (Sale sale : sales) { file.add(sale.product(), sale.total()); overall += sale.total(); } file.add("Общо", overall); return file.text(); }}
class CsvExporter extends ReportExporter { @Override protected ReportFile createFile() { return new CsvFile(); }}
class HtmlExporter extends ReportExporter { @Override protected ReportFile createFile() { return new HtmlFile(); }}List<Sale> sales = List.of(new Sale("Кафе", 12.50), new Sale("Чай", 8.00));System.out.println(new CsvExporter().export(sales));System.out.println(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 е по-ясна. А подклас, който съществува само за да върне нов обект, е церемония. Тогава подай конструктора като Supplier<ReportFile>: new ReportExporter(CsvFile::new).
Пробвай
Предвиди изхода
Видовете идват от формуляр на сайта. Какво печата програмата?
String[] types = { "office", "Locker", "locker" };for (String type : types) { try { System.out.println(Deliveries.create(type).label("Мария")); } catch (IllegalArgumentException e) { System.out.println(e.getMessage()); }}Пробвай
Намери грешката
Друг програмист е написал фабриката така:
class Deliveries { public static Delivery create(String type) { return switch (type) { case "office" -> new OfficeDelivery(); case "locker" -> new LockerDelivery(); default -> new AddressDelivery(); }; }}Поръчката идва от сайт на партньор, който праща вида с главна буква:
Delivery delivery = Deliveries.create("Locker");System.out.println("Етикет: " + delivery.label("Мария"));System.out.printf("Доставка: %.2f €%n", delivery.price());Етикет: Мария, до адресаДоставка: 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 кг доставката до адрес струва повече. Къде отива теглото: във фабриката или в метода за цена? Помисли защо.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: HashMap и HashSet · record, switch и шаблони · Шаблони за дизайн · Strategy
- Продължава в: Builder · Магазинът: кой шаблон къде
- Задачи по темата: Шаблони за дизайн