Шаблони за дизайн
Проблем
Функцията, която расте с всяка промоция
Цената в магазина зависи от промоцията:
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 priceprint(f"{final_price(120, 'студент'):.2f} €")print(f"{final_price(120, 'черен петък'):.2f} €")print(f"{final_price(120, 'без'):.2f} €")108.00 €100.00 €120.00 €elif в тази функция, а често и в още две: в тази за фактурата и в тази за имейла с потвърждение. Функцията знае за всички промоции наведнъж и всяка промяна я пипа. Тази задача е толкова честа, че решението ѝ си има име.Идеята
Шаблон за дизайн
Шаблоните се делят на три групи:
- Създаващи: как се правят обекти. Например Singleton (само един обект от класа) и Factory Method (кой клас да се създаде решава друг).
- Структурни: как класове и обекти се сглобяват в по-големи. Например Adapter (свързва несъвместими интерфейси) и Composite (дърво от части, с които се работи като с едно цяло).
- Поведенчески: как обектите си разпределят работата и си говорят. Например Strategy (сменяемо правило) и Observer (абонати за събитие).
Защо си струва да ги знаеш:
- не измисляш наново решение, което другите вече са изпитали;
- кодът се променя с по-малко риск, защото променливата част е отделена;
- имената са общ език. „Тук сложи адаптер“ казва на колегата цяла структура с една дума.
По-точно, за любопитните
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.9print(f"Студент: {checkout.total(120):.2f} €")
checkout.discount = lambda price: price - 20print(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)Стъпка по стъпка, с думи
- Телефон с наличност 0 и празен списък listeners.
- Сайтът се абонира: ламбдата му влиза в списъка.
- Складът също, с функцията store. Продуктът не знае кои са, само че може да ги извика с име и бройка.
- Наличността става 10. Продуктът обикаля списъка и вика всеки абонат по реда на абонирането. Сайтът показва новата бройка.
- Складът проверява 10 < 3: не, и не прави нищо.
- Наличността става 2. Продуктът обикаля списъка и вика всеки абонат по реда на абонирането. Сайтът показва новата бройка.
- Складът проверява 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)set_quantity(4) уведомява А. Второто не е промяна и никой не чува. Б се абонира след това и не научава за минали промени. При 0 чуват и двамата, по реда на абонирането.Виж го
Factory Method: кой клас да се създаде
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}")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: Поръчката ти е изпратена.Непознат канал: гълъб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._instancea = AppConfig()b = AppConfig()print(a is b)print(b.currency)Чета настройките...TrueEURprivateконструктор в 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.valueprint(Settings.INSTANCE.currency())print(Settings("EUR") is Settings.INSTANCE)try: Settings("USD")except ValueError as e: print("ValueError:", e)EURTrueValueError: '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())100110021001На кой ред в класа е грешката?
Избери реда с грешката.
__new__ връща все него. Но след всяко OrderNumbers() Python вика и __init__, а той връща брояча на 1001. Третото извикване не прави нов брояч, а нулира единствения. Поправката е началната стойност да е в __new__, до super().__new__(cls), където се изпълнява само веднъж, а __init__ да го няма.Виж го
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}") == 0gateway = BankAdapter(LegacyBankClient())print(gateway.pay("1001", 49.90))print(gateway.pay("1002", 1299.00))Банка: 4990 цента по ORDER-1001TrueБанка: 129900 цента по ORDER-1002Falsetrue по пътя обратно. Магазинът не знае, че отзад е стара библиотека. Ако утре смениш банката, пишеш нов адаптер, а магазинът не се пипа.Това е 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 и фабрика за трите канала, която не гледа главни и малки букви. Прочети брой редове и после редове „канал;адрес“. На всеки адрес прати „Резервацията ти е потвърдена.“, а за непознат канал отпечатай съобщението на изключението.
4Email;maria@example.comsms;+359 88 123 4567push;телефонът на Еленафакс;02 123 456Имейл до maria@example.com: Резервацията ти е потвърдена.SMS до +359 88 123 4567: Резервацията ти е потвърдена.Push до телефонът на Елена: Резервацията ти е потвърдена.Непознат канал: факс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%.
Тест