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

Шаблони за дизайн

Проблем

Функцията, която расте с всяка промоция

Цената в магазина зависи от промоцията:

def final_price(price, promo):
if promo == "студент":
return price * 0.9
elif promo == "черен петък":
return max(price - 20, 0)
elif promo == "лоялен клиент":
return price * 0.95
return price
print(f"{final_price(120, 'студент'):.2f} €")
print(f"{final_price(120, 'черен петък'):.2f} €")
print(f"{final_price(120, 'без'):.2f} €")
Изход
108.00 €
100.00 €
120.00 €
Работи. Но маркетингът измисля нова промоция всеки месец. Всяка значи нов elif в тази функция, а често и в още две: в тази за фактурата и в тази за имейла с потвърждение. Функцията знае за всички промоции наведнъж и всяка промяна я пипа. Тази задача е толкова честа, че решението ѝ си има име.

Идеята

Шаблон за дизайн

Шаблон за дизайн (на английски design pattern) е проверено решение на задача, която се повтаря в много програми. Не е библиотека и не е код за копиране. Той е начин да подредиш класовете и функциите, който вече е минал през хиляди проекти.

Шаблоните се делят на три групи:

  • Създаващи: как се правят обекти. Например Singleton (само един обект от класа) и Factory Method (кой клас да се създаде решава друг).
  • Структурни: как класове и обекти се сглобяват в по-големи. Например Adapter (свързва несъвместими интерфейси) и Composite (дърво от части, с които се работи като с едно цяло).
  • Поведенчески: как обектите си разпределят работата и си говорят. Например Strategy (сменяемо правило) и Observer (абонати за събитие).

Защо си струва да ги знаеш:

  • не измисляш наново решение, което другите вече са изпитали;
  • кодът се променя с по-малко риск, защото променливата част е отделена;
  • имената са общ език. „Тук сложи адаптер“ казва на колегата цяла структура с една дума.
По-точно, за любопитните
Класическият каталог е книгата „Design Patterns“ от 1994 г. на четирима автори, наричани „бандата на четиримата“ (Gang of Four, GoF). В нея има 23 шаблона, написани за езици като C++ и Java. Тук са петте, които ще срещаш най-често. В Python някои стават по-кратки, защото функцията може да се подаде като стойност: където в Java трябва интерфейс и клас, тук стига функция. Шаблонът не е цел: ако един if върши работа, шаблон не трябва.

Виж го

Strategy: сменяемо правило

Отстъпката става функция, която касата пази в поле. Касата не знае коя е промоцията, а само вика функцията:
class Checkout:
def __init__(self, discount):
self.discount = discount
def total(self, price):
return max(self.discount(price), 0)
Всяка отстъпка е ламбда:
checkout = Checkout(lambda price: price)
print(f"Без отстъпка: {checkout.total(120):.2f} €")
checkout.discount = lambda price: price * 0.9
print(f"Студент: {checkout.total(120):.2f} €")
checkout.discount = lambda price: price - 20
print(f"Черен петък: {checkout.total(120):.2f} €")
Изход
Без отстъпка: 120.00 €
Студент: 108.00 €
Черен петък: 100.00 €
Нова промоция е нова ламбда. Checkout не се променя. Правилото може да се смени и докато програмата работи: записваш нова функция в checkout.discount.Това е Strategy: семейство от правила, всяко със същите параметри и резултат, така че да са взаимозаменяеми. key, който подаваш на sorted, е също стратегия: sorted знае как се подрежда, а ти казваш по какво.
По-точно, за любопитните

В книгите класът, който ползва стратегията, се казва „контекст“ (тук Checkout). Ако стратегията има свои полета, тя може да е клас. Checkout вика self.discount(price), затова обектът трябва да може да се вика като функция: това му дава методът __call__:

class PercentDiscount:
def __init__(self, percent):
self.percent = percent
def __call__(self, price):
return price * (1 - self.percent / 100)
checkout = Checkout(PercentDiscount(10))
print(f"Студент: {checkout.total(120):.2f} €")
Изход
Студент: 108.00 €

Клас е по-добрият избор, когато стратегията има свои полета или е дълга. Ламбда, когато е един ред.

Виж го

Observer: абонати за събитие

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

class Product:
def __init__(self, name, quantity):
self.name = name
self.quantity = quantity
self.listeners = []
def subscribe(self, listener):
self.listeners.append(listener)
def set_quantity(self, quantity):
if quantity == self.quantity:
return
self.quantity = quantity
for listener in self.listeners:
listener(self.name, quantity)
phone = Product("Телефон", 0)
phone.subscribe(lambda name, qty: print(f"Сайт: {name}, наличност {qty} бр."))
def store(name, qty):
if qty < 3:
print(f"Склад: поръчай още {name}")
phone.subscribe(store)
phone.set_quantity(10)
phone.set_quantity(2)
Изход
Сайт: Телефон, наличност 10 бр.
Сайт: Телефон, наличност 2 бр.
Склад: поръчай още Телефон
  • Който иска да знае, се абонира с subscribe и подава функция.
  • При промяна продуктът обикаля списъка и вика всяка функция с името и бройката. Кои са абонатите и какво правят, не го интересува.
  • Ако бройката не се е променила, никой не се безпокои.
  • Ламбдата в Python е само един израз, а if не е израз. Затова складът е обикновена функция с def, а subscribe(store) подава самата функция, без скоби.

phone = Product("Телефон", 0)
phone.subscribe(lambda name, qty: print(f"Сайт: {name}, наличност {qty} бр."))
def store(name, qty):
if qty < 3:
print(f"Склад: поръчай още {name}")
phone.subscribe(store)
phone.set_quantity(10)
phone.set_quantity(2)
Стъпка по стъпка, с думи
  1. Телефон с наличност 0 и празен списък listeners.
  2. Сайтът се абонира: ламбдата му влиза в списъка.
  3. Складът също, с функцията store. Продуктът не знае кои са, само че може да ги извика с име и бройка.
  4. Наличността става 10. Продуктът обикаля списъка и вика всеки абонат по реда на абонирането. Сайтът показва новата бройка.
  5. Складът проверява 10 < 3: не, и не прави нищо.
  6. Наличността става 2. Продуктът обикаля списъка и вика всеки абонат по реда на абонирането. Сайтът показва новата бройка.
  7. Складът проверява 2 < 3: да, и поръчва още.

Това е Observer (наблюдател). Продуктът е наблюдаваният обект, а слушателите са наблюдатели. Бутоните в графичните програми работят точно така: „при натискане извикай това“.

По-точно, за любопитните
  • Нужен е и unsubscribe, който маха слушателя от списъка. Махаш същата функция, която си абонирал, затова я пазиш в променлива. Абонат, когото никой не маха, живее в паметта, докато живее продуктът.
phone = Product("Телефон", 5)
site = lambda name, qty: print(f"Сайт: {qty} бр.")
phone.subscribe(site)
phone.set_quantity(4)
phone.listeners.remove(site)
phone.set_quantity(3)
print("Сайтът вече не чува")
Изход
Сайт: 4 бр.
Сайтът вече не чува
  • В старата лекция наблюдаваният клас се казва Subject, с методи attach и notifyAllObservers, а наблюдателят е абстрактен клас с метод update(). Идеята е същата, а с функции не трябва клас за всеки наблюдател.
  • Слушателите се викат един след друг, вътре в метода, който е сменил бройката. Ако един от тях е бавен, чакат всички.

Пробвай

Кой ще чуе?

Product е този от картата преди тази.

Какво ще отпечата програмата?

coffee = Product("Кафе", 5)
coffee.subscribe(lambda name, qty: print(f"А: {qty}"))
coffee.set_quantity(4)
coffee.set_quantity(4)
coffee.subscribe(lambda name, qty: print(f"Б: {qty}"))
coffee.set_quantity(0)
Отговор

Виж го

Factory Method: кой клас да се създаде

Клиентът е избрал как да го уведомяваме: имейл или SMS. Кодът, който изпраща, не трябва да пише EmailNotification() на десет места, всяко със своя if. Договорът „известие“ е абстрактен клас, както в модул „Наследяване“:
from abc import ABC, abstractmethod
class Notification(ABC):
@abstractmethod
def send(self, to, text):
pass
class EmailNotification(Notification):
def send(self, to, text):
print(f"Имейл до {to}: {text}")
class SmsNotification(Notification):
def send(self, to, text):
print(f"SMS до {to}: {text}")
Изборът е на едно място, във фабрика. Тя е обикновена функция: в Java е статичен метод в клас само защото там всичко е в клас.
def create_notification(channel):
match channel:
case "email":
return EmailNotification()
case "sms":
return SmsNotification()
case _:
raise ValueError(f"Непознат канал: {channel}")
customers = [
("email", "maria@example.com"),
("sms", "+359 88 123 4567"),
("гълъб", "ул. Шипка 5"),
]
for channel, to in customers:
try:
n = create_notification(channel)
n.send(to, "Поръчката ти е изпратена.")
except ValueError as e:
print(e)
Изход
Имейл до maria@example.com: Поръчката ти е изпратена.
SMS до +359 88 123 4567: Поръчката ти е изпратена.
Непознат канал: гълъб
Викащият казва какво иска („email“) и получава Notification. Кой точно клас стои отзад, знае само фабриката. Нов канал, например push, е нов клас и нов случай в match. Изпращането не се пипа.
По-точно, за любопитните
  • Това е най-честата форма в практиката. По-точното ѝ име е „проста фабрика“. Factory Method от книгата на GoF е друго: родителят има абстрактен метод, който създава обекта, а наследниците решават какъв:
class Shop(ABC):
@abstractmethod
def create_notification(self):
pass
def confirm_order(self, to):
self.create_notification().send(to, "Поръчката е приета.")
class OnlineShop(Shop):
def create_notification(self):
return EmailNotification()
class PhoneShop(Shop):
def create_notification(self):
return SmsNotification()
OnlineShop().confirm_order("maria@example.com")
PhoneShop().confirm_order("+359 88 123 4567")
Изход
Имейл до maria@example.com: Поръчката е приета.
SMS до +359 88 123 4567: Поръчката е приета.

confirm_order е написан веднъж, в Shop, и не знае какво уведомление праща. А Shop() не може да се създаде: ABC спира с TypeError, защото create_notification е абстрактен.

  • Ако фабриката не стигне до return, тя връща None. Тогава грешката идва чак при send, далеч от истинската причина:
def create_notification(channel):
if channel == "email":
return EmailNotification()
if channel == "sms":
return SmsNotification()
n = create_notification("гълъб")
try:
n.send("ул. Шипка 5", "Поръчката ти е изпратена.")
except AttributeError as e:
print("AttributeError:", e)
Изход
AttributeError: 'NoneType' object has no attribute 'send'

Изключение с ясно съобщение във фабриката е по-добре.

Виж го

Singleton: само един обект

Настройките на приложението, като валутата, се четат веднъж. Ако всяка част на програмата прави свой обект с настройки, те се четат много пъти и може да се разминат.

class AppConfig:
_instance = None
def __new__(cls):
if cls._instance is None:
print("Чета настройките...")
cls._instance = super().__new__(cls)
cls._instance.currency = "EUR"
return cls._instance
a = AppConfig()
b = AppConfig()
print(a is b)
print(b.currency)
Изход
Чета настройките...
True
EUR
  • private конструктор в Python няма: всеки може да напише AppConfig(). Затова пазачът е в __new__. Python вика __new__, за да създаде обекта, още преди __init__.
  • Единственият обект е в полето на класа _instance. __new__ го създава при първото извикване и после връща все него. Долната черта в името казва „за вътрешна употреба“.
  • Валутата се записва също в __new__, само първия път. Защо не в __init__, е в картата след тази.
  • „Чета настройките...“ се отпечатва веднъж, а a is b е True: обектът е един.

Това е Singleton.

По-точно, за любопитните
  • Ако две нишки извикат AppConfig() в един и същи момент, може да се създадат два обекта. За нишките е модул „Програми в истинския свят“.
  • Сигурен Singleton в Python е Enum с една стойност (модулът enum). Enum е тип с предварително изброени обекти. Python създава всеки точно веднъж, а Settings("EUR") не прави нов обект, а намира изброения:
from enum import Enum
class Settings(Enum):
INSTANCE = "EUR"
def currency(self):
return self.value
print(Settings.INSTANCE.currency())
print(Settings("EUR") is Settings.INSTANCE)
try:
Settings("USD")
except ValueError as e:
print("ValueError:", e)
Изход
EUR
True
ValueError: 'USD' is not a valid Settings
  • В Python по-често се прави друго: обектът се създава веднъж, на най-горно ниво в модула, а другите части го внасят с import.
  • Singleton е обща променлива за цялата програма с хубаво име. Всеки може да го ползва отвсякъде, затова е трудно да се тества и да се смени. Често е по-добре обектът да се създаде веднъж в началото на програмата и да се подаде на тези, които го ползват, като параметър.

Пробвай

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

Номерата на поръчките трябва да са уникални, затова броячът е Singleton. Програмата обаче даде номер 1001 два пъти:

numbers = OrderNumbers()
print(numbers.next_number())
print(numbers.next_number())
print(OrderNumbers().next_number())
Изход
1001
1002
1001

На кой ред в класа е грешката?

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

Виж го

Adapter: свързва несъвместими интерфейси

Магазинът плаща през интерфейс със сума в евро и отговор „да или не“. Банката дава стара библиотека, която иска центове и връща код на грешка. Библиотеката не е твоя и не може да я промениш.

# Стара библиотека на банката. Не можеш да я променяш.
class LegacyBankClient:
# Сумата е в центове. Връща 0 при успех и код на грешка иначе.
def charge(self, cents, reference):
print(f"Банка: {cents} цента по {reference}")
return 0 if cents <= 50000 else 51

Пишеш клас, който изглежда като това, което магазинът очаква, а вътре вика банката:

from abc import ABC, abstractmethod
# Това очаква магазинът.
class PaymentGateway(ABC):
@abstractmethod
def pay(self, order_id, amount_eur):
pass
class BankAdapter(PaymentGateway):
def __init__(self, bank):
self.bank = bank
def pay(self, order_id, amount_eur):
cents = round(amount_eur * 100)
return self.bank.charge(cents, f"ORDER-{order_id}") == 0
gateway = BankAdapter(LegacyBankClient())
print(gateway.pay("1001", 49.90))
print(gateway.pay("1002", 1299.00))
Изход
Банка: 4990 цента по ORDER-1001
True
Банка: 129900 цента по ORDER-1002
False
Адаптерът превежда в двете посоки: евро в центове и номер в текст по пътя натам, код 0 в true по пътя обратно. Магазинът не знае, че отзад е стара библиотека. Ако утре смениш банката, пишеш нов адаптер, а магазинът не се пипа.

Това е Adapter: като преходник за контакт, който не променя нито щепсела, нито стената.

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

Класическият пример в книгите, и в старата лекция, е медиен плейър. AudioPlayer може да пуска само mp3, а съществуващ клас AdvancedMediaPlayer има методи playVlc и playMp4. MediaAdapter имплементира интерфейса на плейъра и препраща към правилния метод. Структурата е същата като тук.

Упражнение

Шаблоните в магазина

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

1. Отстъпки като стратегии. Три функции, които връщат ламбда: none(), percent(p) и fixed(amount) (никога под 0 €). Клас Cart със списък цени и сменяема отстъпка в полето discount. Добави кафе на зърна (18,50 €), мед (12 €) и чай (4,20 €) и отпечатай сумата без отстъпка, с −10%, с −5 € и с −50 €.

Изход
Без отстъпка: 34.70 €
-10%: 31.23 €
-5 €: 29.70 €
-50 €: 0.00 €

2. „Уведоми ме, когато е налично“. Продуктът пази списък с клиенти, които чакат. Когато наличността стане от 0 повече от 0, той уведомява всички и изчиства списъка, защото известието е еднократно. Конзолата е с 0 бройки, Мария и Георги се абонират, после наличността става 5, 0 и 3.

Изход
Имейл до Мария: Конзола е налична (5 бр.)
SMS до Георги: Конзола е налична
Край

3. Фабрика за известия. Добави PushNotification и фабрика за трите канала, която не гледа главни и малки букви. Прочети брой редове и после редове „канал;адрес“. На всеки адрес прати „Резервацията ти е потвърдена.“, а за непознат канал отпечатай съобщението на изключението.

Вход
4
Email;maria@example.com
sms;+359 88 123 4567
push;телефонът на Елена
факс;02 123 456
Изход
Имейл до maria@example.com: Резервацията ти е потвърдена.
SMS до +359 88 123 4567: Резервацията ти е потвърдена.
Push до телефонът на Елена: Резервацията ти е потвърдена.
Непознат канал: факс
4. Стар термометър. Клас OldThermometer връща температурата във Фаренхайт с read_fahrenheit(). Новата система работи с абстрактен клас TemperatureSensor с метод celsius(). Напиши ThermometerAdapter (°C = (°F − 32) × 5 / 9). В списък със сензори сложи два стари термометъра (71,6 °F и 39,2 °F) през адаптера и един нов сензор DigitalSensor, който връща 21,5 °C.
Изход
Офис: 22.0 °C
Склад: 4.0 °C
Сървърна: 21.5 °C

Още задачи

  • Доставка като стратегия. Направи функции за цена на доставка до офис, до адрес и от магазина и сложи избраната в касата заедно с отстъпката.
  • Дневник като Singleton. Клас Logger с __new__, който пази единствения обект, и метод log(text), който номерира редовете. Провери, че номерата продължават, когато го викаш от две различни функции.
  • Наблюдател за цена. Към продукта добави абонати за промяна на цената и прати известие само когато цената падне с поне 10%.

Тест

Провери се

1. Какво е шаблон за дизайн?
2. Всяка нова промоция в магазина добавя нов elif във функцията за цена. Кой шаблон помага?
3. Продуктът казва на сайта, склада и имейл системата, когато наличността се промени, без да знае кои са те. Кой е шаблонът?
4. Singleton с __new__, който връща вече създадения обект, има и __init__, който задава self.next = 1001. Какво става при второто OrderNumbers()?
5. Имаш стар клас, който връща температурата във Фаренхайт, а програмата очаква сензор с метод celsius(). Какво правиш?

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