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

Decorator

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

Проблем

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

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

class Espresso:
def cost(self):
return 2.00
def description(self):
return "Еспресо"
class EspressoWithMilk(Espresso):
def cost(self):
return super().cost() + 0.40
def description(self):
return super().description() + ", мляко"
class EspressoWithMilkAndSyrup(EspressoWithMilk):
def cost(self):
return super().cost() + 0.60
def description(self):
return super().description() + ", сироп"
drink = Espresso()
print(f"{drink.description()}: {drink.cost():.2f} €")
drink = EspressoWithMilk()
print(f"{drink.description()}: {drink.cost():.2f} €")
drink = EspressoWithMilkAndSyrup()
print(f"{drink.description()}: {drink.cost():.2f} €")
Изход
Еспресо: 2.00 €
Еспресо, мляко: 2.40 €
Еспресо, мляко, сироп: 3.00 €

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

Python позволява клас с няколко родителя, но това не помага: пак трябва клас за всяка комбинация, а кодът на сиропа живее в няколко класа.

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

Идеята

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

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

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

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

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

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

Виж го

Слой по слой

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

class Espresso:
def cost(self):
return 2.00
def description(self):
return "Еспресо"
class Milk:
def __init__(self, inner):
self.inner = inner
def cost(self):
return self.inner.cost() + 0.40
def description(self):
return self.inner.description() + ", мляко"
class Syrup:
def __init__(self, inner):
self.inner = inner
def cost(self):
return self.inner.cost() + 0.60
def description(self):
return self.inner.description() + ", сироп"

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

drink = Espresso()
drink = Milk(drink)
drink = Syrup(drink)
drink = Syrup(drink)
print(f"{drink.description()}: {drink.cost():.2f} €")
Изход
Еспресо, мляко, сироп, сироп: 3.60 €

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

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

Виж го

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

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

class ExtraShot:
def __init__(self, inner):
self.inner = inner
def cost(self):
return self.inner.cost() + 0.80
def description(self):
return self.inner.description() + ", допълнителна доза"
class Discount:
def __init__(self, inner):
self.inner = inner
def cost(self):
return self.inner.cost() * 0.9
def description(self):
return self.inner.description() + ", отстъпка 10%"
order = Discount(ExtraShot(Milk(Espresso())))
print(f"{order.description()}: {order.cost():.2f} €")
Изход
Еспресо, мляко, допълнителна доза, отстъпка 10%: 2.88 €

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

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

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

drink = Milk("Еспресо")
print(drink.cost())
Грешка
AttributeError: 'str' object has no attribute 'cost'. Did you mean: 'count'?

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

Виж го

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

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

В Python това е толкова честа нужда, че езикът има запис за него: @decorator. Декоратор на функция е функция, която приема функция и връща нова функция, обвивка около нея. @logged над def place значи place = logged(place):

import functools
import itertools
numbers = itertools.count(1001)
def logged(func):
@functools.wraps(func)
def wrapper(*args):
print(f"[лог] {func.__name__}({', '.join(map(str, args))})")
result = func(*args)
print(f"[лог] {func.__name__} върна {result}")
return result
return wrapper
@logged
def place(drink, quantity):
print(f"Приготвя се: {quantity} × {drink}")
return next(numbers)

wrapper приема каквито аргументи дойдат (*args), логва, вика истинската функция и връща нейния резултат. Това е същият шаблон като млякото около еспресото, само че слоят е около функция.

place("Капучино", 2)
place("Еспресо", 1)
Изход
[лог] place(Капучино, 2)
Приготвя се: 2 × Капучино
[лог] place върна 1001
[лог] place(Еспресо, 1)
Приготвя се: 1 × Еспресо
[лог] place върна 1002

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

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

@functools.wraps(func) копира името и описанието на оригиналната функция върху обвивката:

print("Името на функцията: " + place.__name__)
Изход
Името на функцията: place

Без него place.__name__ е wrapper, а грешките и логовете показват името на обвивката вместо на истинската функция. Вградени декоратори има много: @functools.cache пази вече сметнатите резултати, @property прави метод да се чете като поле.

Пробвай

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

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

a = Discount(Syrup(Espresso()))
b = Syrup(Discount(Espresso()))
print(f"{a.cost():.2f} €")
print(f"{b.cost():.2f} €")
Отговор

Пробвай

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

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

class Syrup:
def __init__(self, inner):
self.inner = inner
def cost(self):
return 0.60
def description(self):
return self.inner.description() + ", сироп"
drink = Syrup(Milk(Espresso()))
print(f"{drink.description()}: {drink.cost():.2f} €")
Изход
Еспресо, мляко, сироп: 0.60 €

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

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

Идеята

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

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

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

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

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

Упражнение

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

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

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

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

Още задачи

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

Тест

Провери се

1. Клиент иска еспресо с мляко и два пъти сироп. Какво пишеш при Decorator?
2. Какво прави Milk, когато го питат cost()?
3. place е обвита с @logged (в JavaScript: logged(function place…)). Какво се сменя в тялото на place, за да има логове?
4. Кога Decorator не ти трябва?

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