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

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

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

Идеята

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

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

  • на това място винаги трябва един и същ клас. 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 за достъпно обучение по програмиране ·