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} €")Стъпка по стъпка, с думи
- Еспресото е основата. То не обвива нищо и знае цената си: 2.00.
- Млякото обвива еспресото и го пази в полето inner. drink вече сочи млякото.
- Сиропът обвива млякото. Млякото и еспресото вътре не се пипат.
- Още веднъж сироп: втори слой от същия клас. Това е двойният сироп.
- cost() отива при най-външния слой. Той още не добавя нищо: първо вика self.inner.cost(), и така всеки слой навътре.
- Еспресото отговаря 2.00. На път навън всеки слой добавя своето към това, което идва отвътре.
- От най-външния слой излиза 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 functoolsimport 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
@loggeddef 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 €. На кой ред е грешката?
Избери реда с грешката.
Сиропът връща само своята цена и не пита вътрешната напитка. Всичко под него (еспресото и млякото) изчезва от сумата, без грешка. Описанието е вярно, защото description() пита вътрешната напитка, и точно затова грешката се пропуска лесно. Декораторът трябва да вика същия метод на обекта, който обвива: return self.inner.cost() + 0.60. Тогава сумата е 3 €.
Идеята
Кога не ти трябва
Decorator има цена: по клас за всеки слой, а поръчката е обект от обект от обект. Не си струва, когато:
- всяка добавка само прибавя цена и име. Тогава добавките са данни: списък от двойки „име, цена“, а сумата е цената на основата плюс сбора на списъка. Нов сироп е нов ред в менюто, не нов клас;
- слоят е един и никога не се маха. Кодът му може да е в самия клас;
- обвиваш обект с много методи, а сменяш един. Всяка обвивка трябва да препрати и останалите, иначе те липсват.
Внимавай и с дълбоките купчини: шест слоя са шест реда в stack trace, а редът им се вижда само там, където се сглобяват.
Струва си, когато слоевете правят различни неща (отстъпка, логване, кеш, повторен опит), сглобяват се по различен начин за различни случаи и не искаш да пипаш това, което обвиваш.
Упражнение
Капучино със сметана
Реши задачата в редактора си.
Капучино със сметана. Кафенето добавя капучино за 2,80 € и сметана за 0,50 €. Напиши класовете Cappuccino и WhippedCream и поръчай капучино с два пъти сметана. Старите класове не се пипат.
Капучино, сметана, сметана: 3.80 €Още задачи
- Колко време отнема. Напиши декоратор
@timed, който засича времето сtime.perf_counter()и печата колко милисекунди е отнело извикването. Сложи я заедно с логването. Кой слой е отвън и защо има значение? - Голяма чаша. Голямата чаша струва 30% повече. Добави слой
Large. Сложи го веднъж около еспресото и веднъж около цялата поръчка с мляко и сироп. Кое е честно към клиента? - Брояч. Обвивка около услугата брои колко поръчки са минали и печата броя накрая. Помисли къде живее броячът: в обвивката или в услугата.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Интерфейси като абстрактни класове · Изключения · Ламбди · Шаблони за дизайн
- Продължава в: Магазинът: кой шаблон къде · Кеш
- Задачи по темата: Шаблони за дизайн