Шаблони за дизайн
Проблем
Функцията, която расте с всяка промоция
Цената в магазина зависи от промоцията:
function finalPrice(price, promo) { if (promo === "студент") { return price * 0.9; } else if (promo === "черен петък") { return Math.max(price - 20, 0); } else if (promo === "лоялен клиент") { return price * 0.95; } return price;}console.log(`${finalPrice(120, "студент").toFixed(2)} €`);console.log(`${finalPrice(120, "черен петък").toFixed(2)} €`);console.log(`${finalPrice(120, "без").toFixed(2)} €`);108.00 €100.00 €120.00 €else if в тази функция, а често и в още две: в тази за фактурата и в тази за имейла с потвърждение. Функцията знае за всички промоции наведнъж и всяка промяна я пипа. Тази задача е толкова честа, че решението ѝ си има име.Идеята
Шаблон за дизайн
Шаблоните се делят на три групи:
- Създаващи: как се правят обекти. Например Singleton (само един обект от класа) и Factory Method (кой клас да се създаде решава друг).
- Структурни: как класове и обекти се сглобяват в по-големи. Например Adapter (свързва несъвместими интерфейси) и Composite (дърво от части, с които се работи като с едно цяло).
- Поведенчески: как обектите си разпределят работата и си говорят. Например Strategy (сменяемо правило) и Observer (абонати за събитие).
Защо си струва да ги знаеш:
- не измисляш наново решение, което другите вече са изпитали;
- кодът се променя с по-малко риск, защото променливата част е отделена;
- имената са общ език. „Тук сложи адаптер“ казва на колегата цяла структура с една дума.
По-точно, за любопитните
if върши работа, шаблон не трябва.Виж го
Strategy: сменяемо правило
class Checkout { constructor(discount) { this.discount = discount; }
total(price) { return Math.max(this.discount(price), 0); }}const checkout = new Checkout((price) => price);console.log(`Без отстъпка: ${checkout.total(120).toFixed(2)} €`);
checkout.discount = (price) => price * 0.9;console.log(`Студент: ${checkout.total(120).toFixed(2)} €`);
checkout.discount = (price) => price - 20;console.log(`Черен петък: ${checkout.total(120).toFixed(2)} €`);Без отстъпка: 120.00 €Студент: 108.00 €Черен петък: 100.00 €Checkout не се променя. Правилото може да се смени и докато програмата работи: записваш нова функция в checkout.discount.Това е Strategy: семейство от правила, всяко със същите параметри и резултат, така че да са взаимозаменяеми. Функцията за сравнение, която подаваш на sort, е също стратегия: sort знае как се подрежда, а ти казваш по какво.По-точно, за любопитните
В книгите класът, който ползва стратегията, се казва „контекст“ (тук Checkout). Ако стратегията има свои данни, като процента, тя е функция, която прави функция. Върнатата стрелкова функция помни percent и след като percentDiscount е свършила:
function percentDiscount(percent) { return (price) => price * (1 - percent / 100);}const checkout = new Checkout(percentDiscount(10));console.log(`Студент: ${checkout.total(120).toFixed(2)} €`);Студент: 108.00 €Виж го
Observer: абонати за събитие
Когато наличността на продукт се промени, сайтът трябва да покаже новата бройка, а складът да поръча още, ако бройките са малко. Утре може и имейл до клиентите. Продуктът не трябва да знае за никого от тях.
class Product { constructor(name, quantity) { this.name = name; this.quantity = quantity; this.listeners = []; }
subscribe(listener) { this.listeners.push(listener); }
setQuantity(quantity) { if (quantity === this.quantity) { return; } this.quantity = quantity; for (const listener of this.listeners) { listener(this.name, quantity); } }}const phone = new Product("Телефон", 0);phone.subscribe((name, qty) => console.log(`Сайт: ${name}, наличност ${qty} бр.`));phone.subscribe((name, qty) => { if (qty < 3) { console.log(`Склад: поръчай още ${name}`); }});
phone.setQuantity(10);phone.setQuantity(2);Сайт: Телефон, наличност 10 бр.Сайт: Телефон, наличност 2 бр.Склад: поръчай още Телефон- Който иска да знае, се абонира с
subscribeи подава функция. - При промяна продуктът обикаля списъка и вика всяка функция с името и бройката. Кои са абонатите и какво правят, не го интересува.
- Ако бройката не се е променила, никой не се безпокои.
const phone = new Product("Телефон", 0);phone.subscribe((name, qty) => console.log(`Сайт: ${name}, наличност ${qty} бр.`));phone.subscribe((name, qty) => { if (qty < 3) { console.log(`Склад: поръчай още ${name}`); }});
phone.setQuantity(10);phone.setQuantity(2);Стъпка по стъпка, с думи
- Телефон с наличност 0 и празен списък listeners.
- Сайтът се абонира: стрелковата функция му влиза в списъка.
- Складът също. Продуктът не знае кои са, само че може да ги извика с име и бройка.
- Наличността става 10. Продуктът обикаля списъка и вика всеки абонат по реда на абонирането. Сайтът показва новата бройка.
- Складът проверява 10 < 3: не, и не прави нищо.
- Наличността става 2. Продуктът обикаля списъка и вика всеки абонат по реда на абонирането. Сайтът показва новата бройка.
- Складът проверява 2 < 3: да, и поръчва още.
Това е Observer (наблюдател). Продуктът е наблюдаваният обект, а слушателите са наблюдатели. Бутоните в графичните програми работят точно така: „при натискане извикай това“.
По-точно, за любопитните
- Нужен е и
unsubscribe, който маха слушателя от списъка. Махаш същата функция, която си абонирал, затова я пазиш в променлива. Абонат, когото никой не маха, живее в паметта, докато живее продуктът.
const phone = new Product("Телефон", 5);const site = (name, qty) => console.log(`Сайт: ${qty} бр.`);phone.subscribe(site);phone.setQuantity(4);phone.listeners = phone.listeners.filter((l) => l !== site);phone.setQuantity(3);console.log("Сайтът вече не чува");Сайт: 4 бр.Сайтът вече не чува- В старата лекция наблюдаваният клас се казва
Subject, с методиattachиnotifyAllObservers, а наблюдателят е абстрактен клас с методupdate(). Идеята е същата, а с функции не трябва клас за всеки наблюдател. - Слушателите се викат един след друг, вътре в метода, който е сменил бройката. Ако един от тях е бавен, чакат всички.
Пробвай
Кой ще чуе?
Product е този от картата преди тази.
Какво ще отпечата програмата?
const coffee = new Product("Кафе", 5);coffee.subscribe((name, qty) => console.log(`А: ${qty}`));coffee.setQuantity(4);coffee.setQuantity(4);coffee.subscribe((name, qty) => console.log(`Б: ${qty}`));coffee.setQuantity(0);setQuantity(4) уведомява А. Второто не е промяна и никой не чува. Б се абонира след това и не научава за минали промени. При 0 чуват и двамата, по реда на абонирането.Виж го
Factory Method: кой клас да се създаде
new EmailNotification() на десет места, всяко със своя if. Интерфейс в JavaScript няма: известие е всеки обект с метод send.// Известие е всеки обект с метод send(to, text).class EmailNotification { send(to, text) { console.log(`Имейл до ${to}: ${text}`); }}
class SmsNotification { send(to, text) { console.log(`SMS до ${to}: ${text}`); }}function createNotification(channel) { switch (channel) { case "email": return new EmailNotification(); case "sms": return new SmsNotification(); default: throw new Error(`Непознат канал: ${channel}`); }}const customers = [ ["email", "maria@example.com"], ["sms", "+359 88 123 4567"], ["гълъб", "ул. Шипка 5"],];for (const [channel, to] of customers) { try { const n = createNotification(channel); n.send(to, "Поръчката ти е изпратена."); } catch (e) { console.log(e.message); }}Имейл до maria@example.com: Поръчката ти е изпратена.SMS до +359 88 123 4567: Поръчката ти е изпратена.Непознат канал: гълъбsend. Кой точно клас стои отзад, знае само фабриката. Нов канал, например push, е нов клас и нов case в switch. Изпращането не се пипа.По-точно, за любопитните
- Това е най-честата форма в практиката. По-точното ѝ име е „проста фабрика“. Factory Method от книгата на GoF е друго: родителят има метод, който създава обекта, а наследниците решават какъв.
abstractв JavaScript няма, затова методът в родителя хвърля грешка: ако наследникът не го напише, грешката се вижда при първото извикване:
class Shop { createNotification() { throw new Error("createNotification() трябва да се напише в наследника"); }
confirmOrder(to) { this.createNotification().send(to, "Поръчката е приета."); }}
class OnlineShop extends Shop { createNotification() { return new EmailNotification(); }}
class PhoneShop extends Shop { createNotification() { return new SmsNotification(); }}new OnlineShop().confirmOrder("maria@example.com");new PhoneShop().confirmOrder("+359 88 123 4567");Имейл до maria@example.com: Поръчката е приета.SMS до +359 88 123 4567: Поръчката е приета.confirmOrder е написан веднъж, в Shop, и не знае какво уведомление праща.
- Ако фабриката не стигне до
return, тя връщаundefined. Тогава грешката идва чак приsend, далеч от истинската причина:
function createNotification(channel) { if (channel === "email") { return new EmailNotification(); } if (channel === "sms") { return new SmsNotification(); }}
const n = createNotification("гълъб");try { n.send("ул. Шипка 5", "Поръчката ти е изпратена.");} catch (e) { console.log(`${e.name}: ${e.message}`);}TypeError: Cannot read properties of undefined (reading 'send')Изключение с ясно съобщение във фабриката е по-добре.
Виж го
Singleton: само един обект
Настройките на приложението, като валутата, се четат веднъж. Ако всяка част на програмата прави свой обект с настройки, те се четат много пъти и може да се разминат.
class AppConfig { static instance = null;
constructor() { if (AppConfig.instance !== null) { return AppConfig.instance; } console.log("Чета настройките..."); this.currency = "EUR"; AppConfig.instance = this; }}const a = new AppConfig();const b = new AppConfig();console.log(a === b);console.log(b.currency);Чета настройките...trueEURprivateконструктор в JavaScript няма: всеки може да напишеnew AppConfig(). Затова пазачът е в самия конструктор.- Единственият обект е в статичното поле
instance. Ако вече има обект, конструкторът го връща сreturn. Когато конструктор върне обект,newдава него, а не новия. - „Чета настройките...“ се отпечатва веднъж, а
a === bеtrue: обектът е един.
Това е Singleton.
По-точно, за любопитните
- Най-простият Singleton в JavaScript е обикновен обект. Клас, от който да се направи втори, няма, а
Object.freezeпази полетата му:
const settings = Object.freeze({ currency: "EUR",});console.log(settings.currency);console.log(Object.isFrozen(settings));EURtrue- В JavaScript по-често се прави точно това: обектът се създава веднъж, в модула, а другите части го внасят с
import. - Singleton е обща променлива за цялата програма с хубаво име. Всеки може да го ползва отвсякъде, затова е трудно да се тества и да се смени. Често е по-добре обектът да се създаде веднъж в началото на програмата и да се подаде на тези, които го ползват, като параметър.
Пробвай
Намери грешката
Номерата на поръчките трябва да са уникални, затова броячът е Singleton. Програмата обаче даде номер 1001 два пъти:
const numbers = new OrderNumbers();console.log(numbers.nextNumber());console.log(numbers.nextNumber());console.log(new OrderNumbers().nextNumber());100110021001На кой ред в класа е грешката?
Избери реда с грешката.
this.instance = this; записва обекта в поле на самия обект, а не на класа. OrderNumbers.instance си остава null, проверката на ред 5 никога не е вярна и всяко new OrderNumbers() прави нов брояч от 1001. Поправката е OrderNumbers.instance = this;.Виж го
Adapter: свързва несъвместими интерфейси
Магазинът плаща през интерфейс със сума в евро и отговор „да или не“. Банката дава стара библиотека, която иска центове и връща код на грешка. Библиотеката не е твоя и не може да я промениш.
// Стара библиотека на банката. Не можеш да я променяш.class LegacyBankClient { // Сумата е в центове. Връща 0 при успех и код на грешка иначе. charge(cents, reference) { console.log(`Банка: ${cents} цента по ${reference}`); return cents <= 50000 ? 0 : 51; }}Пишеш клас, който изглежда като това, което магазинът очаква, а вътре вика банката:
// Това очаква магазинът: обект с метод pay(orderId, amountEur), който връща true или false.class BankAdapter { constructor(bank) { this.bank = bank; }
pay(orderId, amountEur) { const cents = Math.round(amountEur * 100); return this.bank.charge(cents, `ORDER-${orderId}`) === 0; }}const gateway = new BankAdapter(new LegacyBankClient());console.log(gateway.pay("1001", 49.9));console.log(gateway.pay("1002", 1299));Банка: 4990 цента по ORDER-1001trueБанка: 129900 цента по ORDER-1002falsetrue по пътя обратно. Магазинът не знае, че отзад е стара библиотека: за него е важно само обектът да има pay. Ако утре смениш банката, пишеш нов адаптер, а магазинът не се пипа.Това е Adapter: като преходник за контакт, който не променя нито щепсела, нито стената.
По-точно, за любопитните
Класическият пример в книгите, и в старата лекция, е медиен плейър. AudioPlayer може да пуска само mp3, а съществуващ клас AdvancedMediaPlayer има методи playVlc и playMp4. MediaAdapter имплементира интерфейса на плейъра и препраща към правилния метод. Структурата е същата като тук.
Упражнение
Шаблоните в магазина
Реши задачите в редактора си.
1. Отстъпки като стратегии. Три функции, които връщат стрелкова функция: none(), percent(p) и fixed(amount) (никога под 0 €). Клас Cart с масив цени и сменяема отстъпка в полето discount. Добави кафе на зърна (18,50 €), мед (12 €) и чай (4,20 €) и отпечатай сумата без отстъпка, с −10%, с −5 € и с −50 €.
Без отстъпка: 34.70 €-10%: 31.23 €-5 €: 29.70 €-50 €: 0.00 €2. „Уведоми ме, когато е налично“. Продуктът пази списък с клиенти, които чакат. Когато наличността стане от 0 повече от 0, той уведомява всички и изчиства списъка, защото известието е еднократно. Конзолата е с 0 бройки, Мария и Георги се абонират, после наличността става 5, 0 и 3.
Имейл до Мария: Конзола е налична (5 бр.)SMS до Георги: Конзола е наличнаКрай3. Фабрика за известия. Добави PushNotification и фабрика за трите канала, която не гледа главни и малки букви. Прочети брой редове и после редове „канал;адрес“. На всеки адрес прати „Резервацията ти е потвърдена.“, а за непознат канал отпечатай съобщението на изключението.
4Email;maria@example.comsms;+359 88 123 4567push;телефонът на Еленафакс;02 123 456Имейл до maria@example.com: Резервацията ти е потвърдена.SMS до +359 88 123 4567: Резервацията ти е потвърдена.Push до телефонът на Елена: Резервацията ти е потвърдена.Непознат канал: факсOldThermometer връща температурата във Фаренхайт с readFahrenheit(). Новата система работи със сензори: обекти с метод celsius(). Напиши ThermometerAdapter (°C = (°F − 32) × 5 / 9). В масив със сензори сложи два стари термометъра (71,6 °F и 39,2 °F) през адаптера и един нов сензор като обект { celsius: () => 21.5 }.Офис: 22.0 °CСклад: 4.0 °CСървърна: 21.5 °CОще задачи
- Доставка като стратегия. Направи функции за цена на доставка до офис, до адрес и от магазина и сложи избраната в касата заедно с отстъпката.
- Дневник като Singleton. Клас
Logger, чийто конструктор връща единствения обект, и методlog(text), който номерира редовете. Провери, че номерата продължават, когато го викаш от две различни функции. - Наблюдател за цена. Към продукта добави абонати за промяна на цената и прати известие само когато цената падне с поне 10%.
Тест