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

Factory Method и Simple Factory

Накратко: Factory събира на едно място решението кой клас да се създаде: Simple Factory държи избора на едно място, а Factory Method оставя подкласа да реши кой обект да направи.

Проблем

Три места знаят всеки вид доставка

Поръчката казва как стига до клиента: "address" (до адреса), "office" (в офис на куриера) или "locker" (в автомат). За всеки вид има клас със своя цена и свой етикет:

class AddressDelivery {
price = 5.9;
label(recipient) {
return `${recipient}, до адреса`;
}
}
class OfficeDelivery {
price = 3.9;
label(recipient) {
return `${recipient}, в офис на куриера`;
}
}
class LockerDelivery {
price = 2.9;
label(recipient) {
return `${recipient}, в автомат`;
}
}

Три части от магазина имат нужда от доставката: касата, прегледът на цената и принтерът за етикети. Всяка сама решава кой клас да създаде:

function checkout(type, items) {
let delivery;
if (type === "address") {
delivery = new AddressDelivery();
} else if (type === "office") {
delivery = new OfficeDelivery();
} else if (type === "locker") {
delivery = new LockerDelivery();
} else {
throw new Error(type);
}
return items + delivery.price;
}
function previewPrice(type) {
let delivery;
if (type === "address") {
delivery = new AddressDelivery();
} else if (type === "office") {
delivery = new OfficeDelivery();
} else if (type === "locker") {
delivery = new LockerDelivery();
} else {
throw new Error(type);
}
return delivery.price;
}
function printLabel(type, recipient) {
let delivery;
if (type === "office") {
delivery = new OfficeDelivery();
} else {
delivery = new AddressDelivery();
}
return delivery.label(recipient);
}
console.log(`Поръчка: ${checkout("locker", 40).toFixed(2)} €`);
console.log(`Преглед: доставка ${previewPrice("locker").toFixed(2)} €`);
console.log("Етикет: " + printLabel("locker", "Мария"));
Изход
Поръчка: 42.90 €
Преглед: доставка 2.90 €
Етикет: Мария, до адреса

Автоматите дойдоха миналия месец. Някой добави "locker" в касата и в прегледа. Принтерът за етикети е писан преди това и има else за адрес. Мария плати доставка до автомат, а куриерът тръгна към адреса ѝ.

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

Идеята

Simple Factory: new живее на едно място

Изборът „кой клас за кой вид“ е едно решение, затова е на едно място: фабрика. Тя получава вида и връща нов обект. Викащият не пише new и не знае класа: получава обект, който има price и label.

Ролите:

  • продуктът: общата форма, price и label(recipient), които има всеки клас;
  • конкретните продукти: AddressDelivery, OfficeDelivery, LockerDelivery;
  • фабриката: createDelivery(type), единственото място, което знае кой клас за кой вид;
  • клиентите: касата, прегледът, етикетът. Те само питат.

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

Едно правило: непознат вид е грешка, а не тихо подразбиране. Фабрика, която не познава вида, хвърля изключение с ясно съобщение. Иначе грешката не изчезва, а тръгва с куриера към грешния адрес.

В JavaScript фабриката е таблица вид → клас. Класът е стойност като всяка друга, затова таблицата пази самите класове, а new Delivery() прави обект от този, който е взет.

Таблицата „име → стратегия“ от урока Strategy е същото нещо: фабрика, която по текст дава обект.

Виж го

Една фабрика за трите места

const deliveries = new Map([
["address", AddressDelivery],
["office", OfficeDelivery],
["locker", LockerDelivery],
]);
function createDelivery(type) {
const Delivery = deliveries.get(type);
if (Delivery === undefined) {
throw new Error(`Непознат вид доставка: ${type}`);
}
return new Delivery();
}

Касата, прегледът и етикетът вече не избират, а питат:

function checkout(type, items) {
return items + createDelivery(type).price;
}
function previewPrice(type) {
return createDelivery(type).price;
}
function printLabel(type, recipient) {
return createDelivery(type).label(recipient);
}
console.log(`Поръчка: ${checkout("locker", 40).toFixed(2)} €`);
console.log(`Преглед: доставка ${previewPrice("locker").toFixed(2)} €`);
console.log("Етикет: " + printLabel("locker", "Мария"));
Изход
Поръчка: 42.90 €
Преглед: доставка 2.90 €
Етикет: Мария, в автомат

Етикетът е за автомат. Изборът е на едно място, затова не може да е верен в касата и грешен в етикета.

По-точно, за любопитните

Защо new Delivery(), а не просто Delivery()? Класът в JavaScript не може да се вика като функция. Без new програмата спира с:

Грешка
TypeError: Class constructor AddressDelivery cannot be invoked without 'new'

Затова таблицата може да пази и функции, които правят обекта: ["address", () => new AddressDelivery()]. Тогава фабриката вика make() без new.

Виж го

Нов вид: един клас и един ред

Магазинът добавя вземане от магазина: вид "pickup", без такса. Новото е един клас:

class PickupDelivery {
price = 0;
label(recipient) {
return `${recipient}, от магазина`;
}
}

и един ред във фабриката, маркиран тук:

const deliveries = new Map([
["address", AddressDelivery],
["office", OfficeDelivery],
["locker", LockerDelivery],
["pickup", PickupDelivery],
]);
function createDelivery(type) {
const Delivery = deliveries.get(type);
if (Delivery === undefined) {
throw new Error(`Непознат вид доставка: ${type}`);
}
return new Delivery();
}
const home = createDelivery("address");
console.log(`${home.label("Мария")}: ${home.price.toFixed(2)} €`);
const locker = createDelivery("locker");
console.log(`${locker.label("Мария")}: ${locker.price.toFixed(2)} €`);
const pickup = createDelivery("pickup");
console.log(`${pickup.label("Мария")}: ${pickup.price.toFixed(2)} €`);
Изход
Мария, до адреса: 5.90 €
Мария, в автомат: 2.90 €
Мария, от магазина: 0.00 €

const home = createDelivery("address");
console.log(`${home.label("Мария")}: ${home.price.toFixed(2)} €`);
const locker = createDelivery("locker");
console.log(`${locker.label("Мария")}: ${locker.price.toFixed(2)} €`);
const pickup = createDelivery("pickup");
console.log(`${pickup.label("Мария")}: ${pickup.price.toFixed(2)} €`);
Стъпка по стъпка, с думи
  1. createDelivery("address") получава само текста от поръчката. Фабриката търси калъпа за него.
  2. От калъпа излиза нов AddressDelivery. Викащият не пита кой клас е: ползва само price и label().
  3. Същата фабрика, друг текст: "locker" минава покрай другите калъпи и спира на LockerDelivery.
  4. Етикетът вече е за автомат: класът се избира на едно място, а касата, прегледът и етикетът питат все него.
  5. Нов вид: класът PickupDelivery и един ред повече във фабриката. Редовете, които викат createDelivery, не се пипат.
  6. "pickup" минава като всеки друг вид. Новият вид е добавен на едно място.

Касата, прегледът и етикетът не се пипат. Те разбират за новия вид, когато поръчка го поиска.

Виж го

Непознат вид

Видът идва отвън: от формуляр, от сайт на партньор, от стар файл. Рано или късно идва вид, който го няма:

try {
const delivery = createDelivery("drone");
console.log(delivery.label("Мария"));
} catch (e) {
console.log(e.message);
}
Изход
Непознат вид доставка: drone

Грешката казва кой вид е непознат. Хваща се там, където поръчката влиза в магазина, и клиентът вижда съобщение, вместо пратка на грешното място.

А без проверката? deliveries.get(type) връща undefined, а грешката не казва нито кой вид, нито какво е търсено:
function createDelivery(type) {
const Delivery = deliveries.get(type);
return new Delivery();
}
Грешка
TypeError: Delivery is not a constructor

Виж го

Factory Method: подкласът решава

Simple Factory избира по текст. Понякога изборът не е по текст, а по това кой обект пита.

Отчетът за продажбите се праща като CSV (за счетоводството) и като HTML таблица (в имейла). Двата правят едно и също: всеки продукт е ред, а накрая има ред „Общо“. Различен е само файлът, в който се пишат редовете:

class CsvFile {
lines = ["продукт;сума"];
add(product, total) {
this.lines.push(`${product};${total.toFixed(2)}`);
}
text() {
return this.lines.join("\n");
}
}
class HtmlFile {
rows = [];
add(product, total) {
this.rows.push(`<tr><td>${product}</td><td>${total.toFixed(2)}</td></tr>`);
}
text() {
return "<table>\n" + this.rows.join("\n") + "\n</table>";
}
}
Общият алгоритъм е в базовия клас. Стъпката „направи файл“ е методът createFile, който подкласът презаписва.
class ReportExporter {
createFile() {
return new CsvFile();
}
export(sales) {
const file = this.createFile();
let overall = 0;
for (const [product, total] of sales) {
file.add(product, total);
overall += total;
}
file.add("Общо", overall);
return file.text();
}
}
class HtmlExporter extends ReportExporter {
createFile() {
return new HtmlFile();
}
}
const sales = [["Кафе", 12.5], ["Чай", 8]];
console.log(new ReportExporter().export(sales));
console.log(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: базовият клас вика метод, който създава обекта (this.createFile()), а кой клас точно, решава подкласът.

Защо не се подава готов файл? Файлът събира редове. Всяко export трябва да започне с празен файл, затова отчетът не пази файл, а знае как да направи нов.

Кога Simple Factory стига? Factory Method си заслужава, когато около създаването има общ алгоритъм, който подкласовете не трябва да повтарят (тук: редовете и общата сума). Ако искаш само обект по вид, без алгоритъм около него, Simple Factory е по-ясна. А подклас, който съществува само за да върне нов обект, е церемония. Тогава подай самия клас: new ReportExporter(HtmlFile).

Пробвай

Предвиди изхода

Видовете идват от формуляр на сайта. Какво печата програмата?

for (const type of ["office", "Locker", "locker"]) {
try {
console.log(createDelivery(type).label("Мария"));
} catch (e) {
console.log(e.message);
}
}
Отговор

Пробвай

Намери грешката

Друг програмист е написал фабриката така:

const deliveries = new Map([
["office", OfficeDelivery],
["locker", LockerDelivery],
]);
function createDelivery(type) {
const Delivery = deliveries.get(type) ?? AddressDelivery;
return new Delivery();
}

Поръчката идва от сайт на партньор, който праща вида с главна буква:

const delivery = createDelivery("Locker");
console.log("Етикет: " + delivery.label("Мария"));
console.log(`Доставка: ${delivery.price.toFixed(2)} €`);
Изход
Етикет: Мария, до адреса
Доставка: 5.90 €

Мария избра автомат. Плати доставка 5.90 € вместо 2.90 €, а пратката тръгна към адреса ѝ. На кой ред е грешката?

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

Идеята

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

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

  • на това място винаги трябва един и същ клас. new OfficeDelivery() там, където доставката винаги е до офис, е по-ясно от фабрика;
  • видът е известен, докато пишеш кода, а не идва като данни (от поръчка, файл, формуляр);
  • пишеш клас DeliveryFactory само със статичен метод. Таблица и една функция стигат;
  • 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. За да пести памет, таблицата пази по един готов обект за всеки вид, а фабриката връща него. Обектът пази получателя в поле. Какво става с две поръчки за автомат една след друга?
5. Кога фабрика не ти трябва?

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