Decorator
Накратко: Decorator обвива обект в друг обект със същия интерфейс и добавя поведение, без да променя класа и без клас за всяка комбинация.
Проблем
Менюто, което расте с всяка добавка
Кафенето продава еспресо за 2 €. Към него клиентът може да иска мляко (+0,40 €), сироп (+0,60 €) и допълнителна доза (+0,80 €). Някой пише по клас за всяка комбинация:
class Espresso{ public virtual decimal Cost() => 2.00m; public virtual string Description() => "Еспресо";}
class EspressoWithMilk : Espresso{ public override decimal Cost() => base.Cost() + 0.40m; public override string Description() => base.Description() + ", мляко";}
class EspressoWithMilkAndSyrup : EspressoWithMilk{ public override decimal Cost() => base.Cost() + 0.60m; public override string Description() => base.Description() + ", сироп";}Espresso drink = new Espresso();Console.WriteLine($"{drink.Description()}: {drink.Cost():F2} €");drink = new EspressoWithMilk();Console.WriteLine($"{drink.Description()}: {drink.Cost():F2} €");drink = new EspressoWithMilkAndSyrup();Console.WriteLine($"{drink.Description()}: {drink.Cost():F2} €");Еспресо: 2.00 €Еспресо, мляко: 2.40 €Еспресо, мляко, сироп: 3.00 €Работи за трите напитки в менюто. Но три добавки дават осем комбинации, а с капучино и чай те стават 24 класа. Още една добавка удвоява броя.
EspressoWithSyrup (без мляко) не може да наследи сиропа от EspressoWithMilkAndSyrup, защото класът има само един родител. Кодът на сиропа се копира, и при поскъпване се сменя на няколко места.А клиентът иска два пъти сироп. Такъв клас няма. Да има по клас за един, два и три сиропа не е решение.
Идеята
Decorator: обвивка със същата форма
Добавката не е вид напитка. Тя е нещо, което обвива напитка: взима я и добавя своето.
Затова всички имат обща форма: интерфейсътDrink (в C# IDrink) с цена и описание. Еспресото го имплементира и знае своята цена. Млякото също го имплементира, но пази в поле друга напитка, която е Drink. Когато го питат за цена, то пита вътрешната напитка и добавя 0,40 €.Това е шаблонът Decorator (декоратор). Има три роли:
- общата форма: интерфейсът, който имат и напитките, и обвивките;
- основата: еспресото, което не обвива нищо;
- декораторите: млякото, сиропът, дозата. Всеки пази вътрешна напитка, вика същия метод на нея и добавя своето.
Щом обвивката има същата форма като напитката, обвивка може да обвие обвивка. Двоен сироп е два слоя сироп. Кодът, който пита за цената, не знае колко слоя има.
Виж го
Слой по слой
Общата форма:
interface IDrink{ decimal Cost(); string Description();}Еспресото и две добавки. Всяка добавка получава вътрешната напитка в конструктора:
class Espresso : IDrink{ public decimal Cost() => 2.00m; public string Description() => "Еспресо";}
class Milk : IDrink{ private readonly IDrink inner;
public Milk(IDrink inner) { this.inner = inner; }
public decimal Cost() => inner.Cost() + 0.40m; public string Description() => inner.Description() + ", мляко";}
class Syrup : IDrink{ private readonly IDrink inner;
public Syrup(IDrink inner) { this.inner = inner; }
public decimal Cost() => inner.Cost() + 0.60m; public string Description() => inner.Description() + ", сироп";}Поръчката се сглобява отвътре навън: първо еспресото, после всеки слой обвива каквото има досега.
IDrink drink = new Espresso();drink = new Milk(drink);drink = new Syrup(drink);drink = new Syrup(drink);Console.WriteLine($"{drink.Description()}: {drink.Cost():F2} €");Еспресо, мляко, сироп, сироп: 3.60 €IDrink drink = new Espresso();drink = new Milk(drink);drink = new Syrup(drink);drink = new Syrup(drink);Console.WriteLine($"{drink.Description()}: {drink.Cost():F2} €");Стъпка по стъпка, с думи
- Еспресото е основата. То не обвива нищо и знае цената си: 2.00.
- Млякото обвива еспресото и го пази в полето inner. drink вече сочи млякото, което също е IDrink.
- Сиропът обвива млякото. Млякото и еспресото вътре не се пипат.
- Още веднъж сироп: втори слой от същия клас. Това е двойният сироп.
- Cost() отива при най-външния слой. Той още не добавя нищо: първо вика inner.Cost(), и така всеки слой навътре.
- Еспресото отговаря 2.00. На път навън всеки слой добавя своето към това, което идва отвътре.
- От най-външния слой излиза 3.60. Description() минава по същия път и събира думите.
Извикването на Cost() влиза слой по слой до еспресото, а сумата расте на път навън. Нито един от трите класа не знае за другите.
Виж го
Нов слой, без да пипаш старите
Допълнителната доза и отстъпката за щастливия час са два нови класа. Отстъпката не прибавя, а умножава всичко вътре по 0,9:
class ExtraShot : IDrink{ private readonly IDrink inner;
public ExtraShot(IDrink inner) { this.inner = inner; }
public decimal Cost() => inner.Cost() + 0.80m; public string Description() => inner.Description() + ", допълнителна доза";}
class Discount : IDrink{ private readonly IDrink inner;
public Discount(IDrink inner) { this.inner = inner; }
public decimal Cost() => inner.Cost() * 0.9m; public string Description() => inner.Description() + ", отстъпка 10%";}IDrink order = new Discount(new ExtraShot(new Milk(new Espresso())));Console.WriteLine($"{order.Description()}: {order.Cost():F2} €");Еспресо, мляко, допълнителна доза, отстъпка 10%: 2.88 €Espresso, Milk и Syrup не са сменени. Новият слой се слага около всякаква напитка, включително около вече обвита.
Записът new Discount(new ExtraShot(new Milk(new Espresso()))) се чете отвътре навън: еспресо, обвито в мляко, обвито в доза, обвито в отстъпка. Редът има значение: отстъпката важи само за това, което е вътре в нея.
Обвивка около нещо, което не е напитка, не се компилира:
IDrink drink = new Milk("Еспресо");error CS1503: Argument 1: cannot convert from 'string' to 'IDrink'Конструкторът иска IDrink, и компилаторът спира още преди първата поръчка.
Виж го
Обвивка около метод
Същата идея работи и извън кафето. Услугата за поръчки работи, а сега трябва да се вижда всяко извикване: какво е поискано и какво е върнато. Кодът на услугата не трябва да се пипа: тя е тествана, а логовете са временни.
Услугата е зад интерфейс:
interface IOrderService{ int Place(string drink, int quantity);}
class CafeOrderService : IOrderService{ private int next = 1001;
public int Place(string drink, int quantity) { Console.WriteLine($"Приготвя се: {quantity} × {drink}"); return next++; }}Обвивката има същия интерфейс, логва и вика истинската услуга:
class LoggingOrderService : IOrderService{ private readonly IOrderService inner;
public LoggingOrderService(IOrderService inner) { this.inner = inner; }
public int Place(string drink, int quantity) { Console.WriteLine($"[лог] Place({drink}, {quantity})"); int number = inner.Place(drink, quantity); Console.WriteLine($"[лог] Place върна {number}"); return number; }}IOrderService orders = new LoggingOrderService(new CafeOrderService());orders.Place("Капучино", 2);orders.Place("Еспресо", 1);[лог] Place(Капучино, 2)Приготвя се: 2 × Капучино[лог] Place върна 1001[лог] Place(Еспресо, 1)Приготвя се: 1 × Еспресо[лог] Place върна 1002Кодът, който приема поръчки, получава IOrderService и не знае, че е обвит. Махаш логовете, като подадеш услугата без обвивка.
Същото става с измерване на времето, с кеш или с повторен опит при грешка: всяко е отделен слой.
По-точно, за любопитните
В .NET декоратори има в самата библиотека: GZipStream и BufferedStream обвиват друг Stream и го ползват отвътре. В ASP.NET Core всеки middleware получава заявката, прави своето и вика следващия, а DelegatingHandler обвива обработчика, през който HttpClient праща заявките.
Пробвай
Предвиди изхода
Отстъпката и сиропът са едни и същи в двете поръчки, само в различен ред. Какво печата програмата?
IDrink a = new Discount(new Syrup(new Espresso()));IDrink b = new Syrup(new Discount(new Espresso()));Console.WriteLine($"{a.Cost():F2} €");Console.WriteLine($"{b.Cost():F2} €");Пробвай
Намери грешката
Някой преписва сиропа:
class Syrup : IDrink{ private readonly IDrink inner;
public Syrup(IDrink inner) { this.inner = inner; }
public decimal Cost() => 0.60m; public string Description() => inner.Description() + ", сироп";}IDrink drink = new Syrup(new Milk(new Espresso()));Console.WriteLine($"{drink.Description()}: {drink.Cost():F2} €");Еспресо, мляко, сироп: 0.60 €Описанието е вярно, а сумата е 0,60 €. На кой ред е грешката?
Избери реда с грешката.
Сиропът връща само своята цена и не пита вътрешната напитка. Всичко под него (еспресото и млякото) изчезва от сумата, без грешка. Описанието е вярно, защото Description() пита вътрешната напитка, и точно затова грешката се пропуска лесно. Декораторът трябва да вика същия метод на обекта, който обвива: => inner.Cost() + 0.60m;. Тогава сумата е 3 €.
Идеята
Кога не ти трябва
Decorator има цена: по клас за всеки слой, а поръчката е обект от обект от обект. Не си струва, когато:
- всяка добавка само прибавя цена и име. Тогава добавките са данни: списък от двойки „име, цена“, а сумата е цената на основата плюс сбора на списъка. Нов сироп е нов ред в менюто, не нов клас;
- слоят е един и никога не се маха. Кодът му може да е в самия клас;
- обвитият интерфейс има двадесет метода. Всяка обвивка трябва да препрати и двадесетте, дори когато сменя само един.
Внимавай и с дълбоките купчини: шест слоя са шест реда в stack trace, а редът им се вижда само там, където се сглобяват.
Струва си, когато слоевете правят различни неща (отстъпка, логване, кеш, повторен опит), сглобяват се по различен начин за различни случаи и не искаш да пипаш това, което обвиваш.
Упражнение
Капучино със сметана
Реши задачата в редактора си.
Капучино със сметана. Кафенето добавя капучино за 2,80 € и сметана за 0,50 €. Напиши класовете Cappuccino и WhippedCream и поръчай капучино с два пъти сметана. Старите класове не се пипат.
Капучино, сметана, сметана: 3.80 €Още задачи
- Колко време отнема. Напиши
TimingOrderService, която засича времето на всякоplaceи печата колко милисекунди е отнело. Сложи я заедно с логването. Кой слой е отвън и защо има значение? - Голяма чаша. Голямата чаша струва 30% повече. Добави слой
Large. Сложи го веднъж около еспресото и веднъж около цялата поръчка с мляко и сироп. Кое е честно към клиента? - Брояч. Обвивка около услугата брои колко поръчки са минали и печата броя накрая. Помисли къде живее броячът: в обвивката или в услугата.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Интерфейси · Изключения · Ламбди и делегати · Шаблони за дизайн
- Продължава в: Магазинът: кой шаблон къде · Кеш
- Задачи по темата: Шаблони за дизайн