Decorator
Накратко: Decorator обвива обект в друг със същата форма и добавя поведение, без да пипа класа; в JavaScript функция обвива по същия начин друга функция.
Проблем
Менюто, което расте с всяка добавка
Кафенето продава еспресо за 2 €. Към него клиентът може да иска мляко (+0,40 €), сироп (+0,60 €) и допълнителна доза (+0,80 €). Някой пише по клас за всяка комбинация:
class Espresso { cost() { return 2; }
description() { return "Еспресо"; }}
class EspressoWithMilk extends Espresso { cost() { return super.cost() + 0.4; }
description() { return `${super.description()}, мляко`; }}
class EspressoWithMilkAndSyrup extends EspressoWithMilk { cost() { return super.cost() + 0.6; }
description() { return `${super.description()}, сироп`; }}let drink = new Espresso();console.log(`${drink.description()}: ${drink.cost().toFixed(2)} €`);drink = new EspressoWithMilk();console.log(`${drink.description()}: ${drink.cost().toFixed(2)} €`);drink = new EspressoWithMilkAndSyrup();console.log(`${drink.description()}: ${drink.cost().toFixed(2)} €`);Еспресо: 2.00 €Еспресо, мляко: 2.40 €Еспресо, мляко, сироп: 3.00 €Работи за трите напитки в менюто. Но три добавки дават осем комбинации, а с капучино и чай те стават 24 класа. Още една добавка удвоява броя.
EspressoWithSyrup (без мляко) не може да наследи сиропа от EspressoWithMilkAndSyrup, защото класът има само един родител. Кодът на сиропа се копира, и при поскъпване се сменя на няколко места.А клиентът иска два пъти сироп. Такъв клас няма. Да има по клас за един, два и три сиропа не е решение.
Идеята
Decorator: обвивка със същата форма
Добавката не е вид напитка. Тя е нещо, което обвива напитка: взима я и добавя своето.
Затова всички имат обща форма: методитеcost() и description(). Еспресото ги има и знае своята цена. Млякото също ги има, но пази в поле друга напитка. Когато го питат за цена, то пита вътрешната напитка и добавя 0,40 €. Интерфейс не трябва: всеки обект с тези два метода може да се обвие.Това е шаблонът Decorator (декоратор). Има три роли:
- общата форма: методите
cost()иdescription(); - основата: еспресото, което не обвива нищо;
- декораторите: млякото, сиропът, дозата. Всеки пази вътрешна напитка, вика същия метод на нея и добавя своето.
Щом обвивката има същата форма като напитката, обвивка може да обвие обвивка. Двоен сироп е два слоя сироп. Кодът, който пита за цената, не знае колко слоя има.
Виж го
Слой по слой
Еспресото и две добавки. Всяка добавка получава вътрешната напитка в конструктора:
class Espresso { cost() { return 2; }
description() { return "Еспресо"; }}
class Milk { constructor(inner) { this.inner = inner; }
cost() { return this.inner.cost() + 0.4; }
description() { return `${this.inner.description()}, мляко`; }}
class Syrup { constructor(inner) { this.inner = inner; }
cost() { return this.inner.cost() + 0.6; }
description() { return `${this.inner.description()}, сироп`; }}Поръчката се сглобява отвътре навън: първо еспресото, после всеки слой обвива каквото има досега.
let drink = new Espresso();drink = new Milk(drink);drink = new Syrup(drink);drink = new Syrup(drink);console.log(`${drink.description()}: ${drink.cost().toFixed(2)} €`);Еспресо, мляко, сироп, сироп: 3.60 €let drink = new Espresso();drink = new Milk(drink);drink = new Syrup(drink);drink = new Syrup(drink);console.log(`${drink.description()}: ${drink.cost().toFixed(2)} €`);Стъпка по стъпка, с думи
- Еспресото е основата. То не обвива нищо и знае цената си: 2.00.
- Млякото обвива еспресото и го пази в полето inner. drink вече сочи млякото.
- Сиропът обвива млякото. Млякото и еспресото вътре не се пипат.
- Още веднъж сироп: втори слой от същия клас. Това е двойният сироп.
- cost() отива при най-външния слой. Той още не добавя нищо: първо вика this.inner.cost(), и така всеки слой навътре.
- Еспресото отговаря 2.00. На път навън всеки слой добавя своето към това, което идва отвътре.
- От най-външния слой излиза 3.60. description() минава по същия път и събира думите.
Извикването на cost() влиза слой по слой до еспресото, а сумата расте на път навън. Нито един от трите класа не знае за другите.
Виж го
Нов слой, без да пипаш старите
Допълнителната доза и отстъпката за щастливия час са два нови класа. Отстъпката не прибавя, а умножава всичко вътре по 0,9:
class ExtraShot { constructor(inner) { this.inner = inner; }
cost() { return this.inner.cost() + 0.8; }
description() { return `${this.inner.description()}, допълнителна доза`; }}
class Discount { constructor(inner) { this.inner = inner; }
cost() { return this.inner.cost() * 0.9; }
description() { return `${this.inner.description()}, отстъпка 10%`; }}const order = new Discount(new ExtraShot(new Milk(new Espresso())));console.log(`${order.description()}: ${order.cost().toFixed(2)} €`);Еспресо, мляко, допълнителна доза, отстъпка 10%: 2.88 €Espresso, Milk и Syrup не са сменени. Новият слой се слага около всякаква напитка, включително около вече обвита.
Записът new Discount(new ExtraShot(new Milk(new Espresso()))) се чете отвътре навън: еспресо, обвито в мляко, обвито в доза, обвито в отстъпка. Редът има значение: отстъпката важи само за това, което е вътре в нея.
Тук никой не проверява какво се обвива. Обвивка около текст се създава без грешка, а програмата спира чак когато някой поиска цената:
const drink = new Milk("Еспресо");console.log(drink.cost());TypeError: this.inner.cost is not a function Грешката сочи реда в Milk, а не реда, на който е сглобена грешната поръчка. Затова всяка нова обвивка се пуска поне веднъж с истинска напитка.
Виж го
Обвивка около метод
Същата идея работи и извън кафето. Услугата за поръчки работи, а сега трябва да се вижда всяко извикване: какво е поискано и какво е върнато. Кодът на услугата не трябва да се пипа: тя е тествана, а логовете са временни.
В JavaScript функцията е стойност, затова обвивката е функция, която приема функция и връща нова функция. logged(fn) връща стрелкова функция, която логва, вика fn със същите аргументи (...args) и връща нейния резултат:
function logged(fn) { return (...args) => { console.log(`[лог] ${fn.name}(${args.join(", ")})`); const result = fn(...args); console.log(`[лог] ${fn.name} върна ${result}`); return result; };}
let next = 1001;
const place = logged(function place(drink, quantity) { console.log(`Приготвя се: ${quantity} × ${drink}`); return next++;});Това е същият шаблон като млякото около еспресото, само че слоят е около функция.
place("Капучино", 2);place("Еспресо", 1);[лог] place(Капучино, 2)Приготвя се: 2 × Капучино[лог] place върна 1001[лог] place(Еспресо, 1)Приготвя се: 1 × Еспресо[лог] place върна 1002
Кодът, който вика place, не знае, че е обвита. Махаш логовете, като махнеш обвивката. Същото става с измерване на времето, с кеш или с повторен опит при грешка: всяко е отделен слой.
По-точно, за любопитните
За класове има предложение за запис @decorator, който TypeScript вече поддържа. В Node 22 още го няма, затова тук обвивката е обикновено извикване на функция. Междинните слоеве (middleware) в Express също са обвивки: всеки получава заявката, прави своето и вика следващия.
Пробвай
Предвиди изхода
Отстъпката и сиропът са едни и същи в двете поръчки, само в различен ред. Какво печата програмата?
const a = new Discount(new Syrup(new Espresso()));const b = new Syrup(new Discount(new Espresso()));console.log(`${a.cost().toFixed(2)} €`);console.log(`${b.cost().toFixed(2)} €`);Пробвай
Намери грешката
Някой преписва сиропа:
class Syrup { constructor(inner) { this.inner = inner; }
cost() { return 0.6; }
description() { return `${this.inner.description()}, сироп`; }}const drink = new Syrup(new Milk(new Espresso()));console.log(`${drink.description()}: ${drink.cost().toFixed(2)} €`);Еспресо, мляко, сироп: 0.60 €Описанието е вярно, а сумата е 0,60 €. На кой ред е грешката?
Избери реда с грешката.
Сиропът връща само своята цена и не пита вътрешната напитка. Всичко под него (еспресото и млякото) изчезва от сумата, без грешка. Описанието е вярно, защото description() пита вътрешната напитка, и точно затова грешката се пропуска лесно. Декораторът трябва да вика същия метод на обекта, който обвива: return this.inner.cost() + 0.6;. Тогава сумата е 3 €.
Идеята
Кога не ти трябва
Decorator има цена: по клас за всеки слой, а поръчката е обект от обект от обект. Не си струва, когато:
- всяка добавка само прибавя цена и име. Тогава добавките са данни: списък от двойки „име, цена“, а сумата е цената на основата плюс сбора на списъка. Нов сироп е нов ред в менюто, не нов клас;
- слоят е един и никога не се маха. Кодът му може да е в самия клас;
- обвиваш обект с много методи, а сменяш един. Всяка обвивка трябва да препрати и останалите, иначе те липсват.
Внимавай и с дълбоките купчини: шест слоя са шест реда в stack trace, а редът им се вижда само там, където се сглобяват.
Струва си, когато слоевете правят различни неща (отстъпка, логване, кеш, повторен опит), сглобяват се по различен начин за различни случаи и не искаш да пипаш това, което обвиваш.
Упражнение
Капучино със сметана
Реши задачата в редактора си.
Капучино със сметана. Кафенето добавя капучино за 2,80 € и сметана за 0,50 €. Напиши класовете Cappuccino и WhippedCream и поръчай капучино с два пъти сметана. Старите класове не се пипат.
Капучино, сметана, сметана: 3.80 €Още задачи
- Колко време отнема. Напиши
timed(fn), която засича времето сperformance.now()и печата колко милисекунди е отнело извикването. Сложи я заедно с логването. Кой слой е отвън и защо има значение? - Голяма чаша. Голямата чаша струва 30% повече. Добави слой
Large. Сложи го веднъж около еспресото и веднъж около цялата поръчка с мляко и сироп. Кое е честно към клиента? - Брояч. Обвивка около услугата брои колко поръчки са минали и печата броя накрая. Помисли къде живее броячът: в обвивката или в услугата.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Интерфейси без interface · Изключения · Стрелкови функции · Шаблони за дизайн
- Продължава в: Магазинът: кой шаблон къде · Кеш
- Задачи по темата: Шаблони за дизайн