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

Decorator

Накратко: Decorator обвива обект в друг обект със същия интерфейс и добавя поведение, без да променя класа и без клас за всяка комбинация.

Проблем

Менюто, което расте с всяка добавка

Кафенето продава еспресо за 2 €. Към него клиентът може да иска мляко (+0,40 €), сироп (+0,60 €) и допълнителна доза (+0,80 €). Някой пише по клас за всяка комбинация:

class Espresso {
public double cost() {
return 2.00;
}
public String description() {
return "Еспресо";
}
}
class EspressoWithMilk extends Espresso {
@Override
public double cost() {
return super.cost() + 0.40;
}
@Override
public String description() {
return super.description() + ", мляко";
}
}
class EspressoWithMilkAndSyrup extends EspressoWithMilk {
@Override
public double cost() {
return super.cost() + 0.60;
}
@Override
public String description() {
return super.description() + ", сироп";
}
}
Espresso drink = new Espresso();
System.out.printf("%s: %.2f €%n", drink.description(), drink.cost());
drink = new EspressoWithMilk();
System.out.printf("%s: %.2f €%n", drink.description(), drink.cost());
drink = new EspressoWithMilkAndSyrup();
System.out.printf("%s: %.2f €%n", drink.description(), drink.cost());
Изход
Еспресо: 2.00 €
Еспресо, мляко: 2.40 €
Еспресо, мляко, сироп: 3.00 €

Работи за трите напитки в менюто. Но три добавки дават осем комбинации, а с капучино и чай те стават 24 класа. Още една добавка удвоява броя.

EspressoWithSyrup (без мляко) не може да наследи сиропа от EspressoWithMilkAndSyrup, защото класът има само един родител. Кодът на сиропа се копира, и при поскъпване се сменя на няколко места.

А клиентът иска два пъти сироп. Такъв клас няма. Да има по клас за един, два и три сиропа не е решение.

Идеята

Decorator: обвивка със същата форма

Добавката не е вид напитка. Тя е нещо, което обвива напитка: взима я и добавя своето.

Затова всички имат обща форма: интерфейсът Drink (в C# IDrink) с цена и описание. Еспресото го имплементира и знае своята цена. Млякото също го имплементира, но пази в поле друга напитка, която е Drink. Когато го питат за цена, то пита вътрешната напитка и добавя 0,40 €.

Това е шаблонът Decorator (декоратор). Има три роли:

  • общата форма: интерфейсът, който имат и напитките, и обвивките;
  • основата: еспресото, което не обвива нищо;
  • декораторите: млякото, сиропът, дозата. Всеки пази вътрешна напитка, вика същия метод на нея и добавя своето.

Щом обвивката има същата форма като напитката, обвивка може да обвие обвивка. Двоен сироп е два слоя сироп. Кодът, който пита за цената, не знае колко слоя има.

Виж го

Слой по слой

Общата форма:

interface Drink {
double cost();
String description();
}

Еспресото и две добавки. Всяка добавка получава вътрешната напитка в конструктора:

class Espresso implements Drink {
@Override
public double cost() {
return 2.00;
}
@Override
public String description() {
return "Еспресо";
}
}
class Milk implements Drink {
private final Drink inner;
public Milk(Drink inner) {
this.inner = inner;
}
@Override
public double cost() {
return inner.cost() + 0.40;
}
@Override
public String description() {
return inner.description() + ", мляко";
}
}
class Syrup implements Drink {
private final Drink inner;
public Syrup(Drink inner) {
this.inner = inner;
}
@Override
public double cost() {
return inner.cost() + 0.60;
}
@Override
public String description() {
return inner.description() + ", сироп";
}
}

Поръчката се сглобява отвътре навън: първо еспресото, после всеки слой обвива каквото има досега.

Drink drink = new Espresso();
drink = new Milk(drink);
drink = new Syrup(drink);
drink = new Syrup(drink);
System.out.printf("%s: %.2f €%n", drink.description(), drink.cost());
Изход
Еспресо, мляко, сироп, сироп: 3.60 €

Drink drink = new Espresso();
drink = new Milk(drink);
drink = new Syrup(drink);
drink = new Syrup(drink);
System.out.printf("%s: %.2f €%n", drink.description(), drink.cost());
Стъпка по стъпка, с думи
  1. Еспресото е основата. То не обвива нищо и знае цената си: 2.00.
  2. Млякото обвива еспресото и го пази в полето inner. drink вече сочи млякото, което също е Drink.
  3. Сиропът обвива млякото. Млякото и еспресото вътре не се пипат.
  4. Още веднъж сироп: втори слой от същия клас. Това е двойният сироп.
  5. cost() отива при най-външния слой. Той още не добавя нищо: първо вика inner.cost(), и така всеки слой навътре.
  6. Еспресото отговаря 2.00. На път навън всеки слой добавя своето към това, което идва отвътре.
  7. От най-външния слой излиза 3.60. description() минава по същия път и събира думите.

Извикването на cost() влиза слой по слой до еспресото, а сумата расте на път навън. Нито един от трите класа не знае за другите.

Виж го

Нов слой, без да пипаш старите

Допълнителната доза и отстъпката за щастливия час са два нови класа. Отстъпката не прибавя, а умножава всичко вътре по 0,9:

class ExtraShot implements Drink {
private final Drink inner;
public ExtraShot(Drink inner) {
this.inner = inner;
}
@Override
public double cost() {
return inner.cost() + 0.80;
}
@Override
public String description() {
return inner.description() + ", допълнителна доза";
}
}
class Discount implements Drink {
private final Drink inner;
public Discount(Drink inner) {
this.inner = inner;
}
@Override
public double cost() {
return inner.cost() * 0.9;
}
@Override
public String description() {
return inner.description() + ", отстъпка 10%";
}
}
Drink order = new Discount(new ExtraShot(new Milk(new Espresso())));
System.out.printf("%s: %.2f €%n", order.description(), order.cost());
Изход
Еспресо, мляко, допълнителна доза, отстъпка 10%: 2.88 €

Espresso, Milk и Syrup не са сменени. Новият слой се слага около всякаква напитка, включително около вече обвита.

Записът new Discount(new ExtraShot(new Milk(new Espresso()))) се чете отвътре навън: еспресо, обвито в мляко, обвито в доза, обвито в отстъпка. Редът има значение: отстъпката важи само за това, което е вътре в нея.

Обвивка около нещо, което не е напитка, не се компилира:

Drink drink = new Milk("Еспресо");
Грешка
error: incompatible types: String cannot be converted to Drink

Конструкторът иска Drink, и компилаторът спира още преди първата поръчка.

Виж го

Обвивка около метод

Същата идея работи и извън кафето. Услугата за поръчки работи, а сега трябва да се вижда всяко извикване: какво е поискано и какво е върнато. Кодът на услугата не трябва да се пипа: тя е тествана, а логовете са временни.

Услугата е зад интерфейс:

interface OrderService {
int place(String drink, int quantity);
}
class CafeOrderService implements OrderService {
private int next = 1001;
@Override
public int place(String drink, int quantity) {
System.out.println("Приготвя се: " + quantity + " × " + drink);
return next++;
}
}

Обвивката има същия интерфейс, логва и вика истинската услуга:

class LoggingOrderService implements OrderService {
private final OrderService inner;
public LoggingOrderService(OrderService inner) {
this.inner = inner;
}
@Override
public int place(String drink, int quantity) {
System.out.println("[лог] place(" + drink + ", " + quantity + ")");
int number = inner.place(drink, quantity);
System.out.println("[лог] place върна " + number);
return number;
}
}
OrderService orders = new LoggingOrderService(new CafeOrderService());
orders.place("Капучино", 2);
orders.place("Еспресо", 1);
Изход
[лог] place(Капучино, 2)
Приготвя се: 2 × Капучино
[лог] place върна 1001
[лог] place(Еспресо, 1)
Приготвя се: 1 × Еспресо
[лог] place върна 1002

Кодът, който приема поръчки, получава OrderService и не знае, че е обвит. Махаш логовете, като подадеш услугата без обвивка. Същото става с измерване на времето, с кеш или с повторен опит при грешка: всяко е отделен слой.

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

В Java декоратори има в самата стандартна библиотека: new BufferedReader(new InputStreamReader(System.in)) е входен поток, обвит в поток, който чете на редове. Spring слага обвивки около твоите класове, за да добави транзакции или кеш (@Transactional, @Cacheable).

Пробвай

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

Отстъпката и сиропът са едни и същи в двете поръчки, само в различен ред. Какво печата програмата?

Drink a = new Discount(new Syrup(new Espresso()));
Drink b = new Syrup(new Discount(new Espresso()));
System.out.printf("%.2f €%n", a.cost());
System.out.printf("%.2f €%n", b.cost());
Отговор

Пробвай

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

Някой преписва сиропа:

class Syrup implements Drink {
private final Drink inner;
public Syrup(Drink inner) {
this.inner = inner;
}
@Override
public double cost() {
return 0.60;
}
@Override
public String description() {
return inner.description() + ", сироп";
}
}
Drink drink = new Syrup(new Milk(new Espresso()));
System.out.printf("%s: %.2f €%n", drink.description(), drink.cost());
Изход
Еспресо, мляко, сироп: 0.60 €

Описанието е вярно, а сумата е 0,60 €. На кой ред е грешката?

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

Идеята

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

Decorator има цена: по клас за всеки слой, а поръчката е обект от обект от обект. Не си струва, когато:

  • всяка добавка само прибавя цена и име. Тогава добавките са данни: списък от двойки „име, цена“, а сумата е цената на основата плюс сбора на списъка. Нов сироп е нов ред в менюто, не нов клас;
  • слоят е един и никога не се маха. Кодът му може да е в самия клас;
  • обвитият интерфейс има двадесет метода. Всяка обвивка трябва да препрати и двадесетте, дори когато сменя само един.

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

Струва си, когато слоевете правят различни неща (отстъпка, логване, кеш, повторен опит), сглобяват се по различен начин за различни случаи и не искаш да пипаш това, което обвиваш.

Упражнение

Капучино със сметана

Реши задачата в редактора си.

Капучино със сметана. Кафенето добавя капучино за 2,80 € и сметана за 0,50 €. Напиши класовете Cappuccino и WhippedCream и поръчай капучино с два пъти сметана. Старите класове не се пипат.

Изход
Капучино, сметана, сметана: 3.80 €

Още задачи

  • Колко време отнема. Напиши TimingOrderService, която засича времето на всяко place и печата колко милисекунди е отнело. Сложи я заедно с логването. Кой слой е отвън и защо има значение?
  • Голяма чаша. Голямата чаша струва 30% повече. Добави слой Large. Сложи го веднъж около еспресото и веднъж около цялата поръчка с мляко и сироп. Кое е честно към клиента?
  • Брояч. Обвивка около услугата брои колко поръчки са минали и печата броя накрая. Помисли къде живее броячът: в обвивката или в услугата.

Тест

Провери се

1. Клиент иска еспресо с мляко и два пъти сироп. Какво пишеш при Decorator?
2. Какво прави Milk, когато го питат cost()?
3. LoggingOrderService обвива CafeOrderService. Какво се сменя в CafeOrderService, за да има логове?
4. Кога Decorator не ти трябва?

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