Шаблони за дизайн
Проблем
Методът, който расте с всяка промоция
Цената в магазина зависи от промоцията:
static double finalPrice(double price, String promo) { if (promo.equals("студент")) { return price * 0.9; } else if (promo.equals("черен петък")) { return Math.max(price - 20, 0); } else if (promo.equals("лоялен клиент")) { return price * 0.95; } return price;}System.out.printf("%.2f €%n", finalPrice(120, "студент"));System.out.printf("%.2f €%n", finalPrice(120, "черен петък"));System.out.printf("%.2f €%n", finalPrice(120, "без"));108.00 €100.00 €120.00 €else if в този метод, а често и в още два: в този за фактурата и в този за имейла с потвърждение. Методът знае за всички промоции наведнъж и всяка промяна го пипа. Тази задача е толкова честа, че решението ѝ си има име.Идеята
Шаблон за дизайн
Шаблоните се делят на три групи:
- Създаващи: как се правят обекти. Например Singleton (само един обект от класа) и Factory Method (кой клас да се създаде решава друг).
- Структурни: как класове и обекти се сглобяват в по-големи. Например Adapter (свързва несъвместими интерфейси) и Composite (дърво от части, с които се работи като с едно цяло).
- Поведенчески: как обектите си разпределят работата и си говорят. Например Strategy (сменяемо правило) и Observer (абонати за събитие).
Защо си струва да ги знаеш:
- не измисляш наново решение, което другите вече са изпитали;
- кодът се променя с по-малко риск, защото променливата част е отделена;
- имената са общ език. „Тук сложи адаптер“ казва на колегата цяла структура с една дума.
По-точно, за любопитните
if върши работа, шаблон не трябва.Виж го
Strategy: сменяемо правило
apply:interface DiscountStrategy { double apply(double price);}
class Checkout { private DiscountStrategy discount;
public Checkout(DiscountStrategy discount) { this.discount = discount; }
public void setDiscount(DiscountStrategy discount) { this.discount = discount; }
public double total(double price) { return Math.max(discount.apply(price), 0); }}Checkout checkout = new Checkout(price -> price);System.out.printf("Без отстъпка: %.2f €%n", checkout.total(120));
checkout.setDiscount(price -> price * 0.9);System.out.printf("Студент: %.2f €%n", checkout.total(120));
checkout.setDiscount(price -> price - 20);System.out.printf("Черен петък: %.2f €%n", checkout.total(120));Без отстъпка: 120.00 €Студент: 108.00 €Черен петък: 100.00 €Checkout не се променя. Правилото може да се смени и докато програмата работи, със setDiscount.Това е Strategy: семейство от правила, всяко зад един и същ интерфейс, така че да са взаимозаменяеми. Comparator, който подаваш на sort, е също стратегия: sort знае как се подрежда, а ти казваш по какво.По-точно, за любопитните
Преди ламбдите всяка стратегия беше отделен клас, а класът, който я ползва, в книгите се казва „контекст“ (тук Checkout):
class StudentDiscount implements DiscountStrategy { @Override public double apply(double price) { return price * 0.9; }}Checkout checkout = new Checkout(new StudentDiscount());System.out.printf("Студент: %.2f €%n", checkout.total(120));Студент: 108.00 €Клас е по-добрият избор, когато стратегията има свои полета или е дълга. Ламбда, когато е един ред.
Виж го
Observer: абонати за събитие
Когато наличността на продукт се промени, сайтът трябва да покаже новата бройка, а складът да поръча още, ако бройките са малко. Утре може и имейл до клиентите. Продуктът не трябва да знае за никого от тях.
interface StockListener { void stockChanged(String product, int quantity);}
class Product { private String name; private int quantity; private List<StockListener> listeners = new ArrayList<>();
public Product(String name, int quantity) { this.name = name; this.quantity = quantity; }
public void subscribe(StockListener listener) { listeners.add(listener); }
public void setQuantity(int quantity) { if (quantity == this.quantity) { return; } this.quantity = quantity; for (StockListener listener : listeners) { listener.stockChanged(name, quantity); } }}Product phone = new Product("Телефон", 0);phone.subscribe((name, qty) -> System.out.println("Сайт: " + name + ", наличност " + qty + " бр."));phone.subscribe((name, qty) -> { if (qty < 3) { System.out.println("Склад: поръчай още " + name); }});
phone.setQuantity(10);phone.setQuantity(2);Сайт: Телефон, наличност 10 бр.Сайт: Телефон, наличност 2 бр.Склад: поръчай още Телефон- Който иска да знае, се абонира с
subscribe. - При промяна продуктът обикаля списъка и уведомява всеки абонат. Кои са те и какво правят, не го интересува.
- Ако бройката не се е променила, никой не се безпокои.
Product phone = new Product("Телефон", 0);phone.subscribe((name, qty) -> System.out.println("Сайт: " + name + ", наличност " + qty + " бр."));phone.subscribe((name, qty) -> { if (qty < 3) { System.out.println("Склад: поръчай още " + name); }});
phone.setQuantity(10);phone.setQuantity(2);Стъпка по стъпка, с думи
- Телефон с наличност 0 и празен списък listeners.
- Сайтът се абонира: ламбдата му влиза в списъка.
- Складът също. Продуктът не знае кои са, само че могат да чуят stockChanged.
- Наличността става 10. Продуктът обикаля списъка и вика всеки абонат по реда на абонирането. Сайтът показва новата бройка.
- Складът проверява 10 < 3: не, и не прави нищо.
- Наличността става 2. Продуктът обикаля списъка и вика всеки абонат по реда на абонирането. Сайтът показва новата бройка.
- Складът проверява 2 < 3: да, и поръчва още.
Това е Observer (наблюдател). Продуктът е наблюдаваният обект, а слушателите са наблюдатели. Бутоните в графичните програми работят точно така: „при натискане извикай това“.
По-точно, за любопитните
- Нужен е и
unsubscribe, който маха слушателя от списъка. Абонат, когото никой не маха, живее в паметта, докато живее продуктът. - В старата лекция наблюдаваният клас се казва
Subject, с методиattachиnotifyAllObservers, а наблюдателят е абстрактен клас с методupdate(). Идеята е същата, а интерфейсът позволява ламбди. - Слушателите се викат един след друг в нишката, която е сменила бройката. Ако един от тях е бавен, чакат всички.
Пробвай
Кой ще чуе?
Product е този от картата преди тази.
Какво ще отпечата програмата?
Product coffee = new Product("Кафе", 5);coffee.subscribe((name, qty) -> System.out.println("А: " + qty));coffee.setQuantity(4);coffee.setQuantity(4);coffee.subscribe((name, qty) -> System.out.println("Б: " + qty));coffee.setQuantity(0);setQuantity(4) уведомява А. Второто не е промяна и никой не чува. Б се абонира след това и не научава за минали промени. При 0 чуват и двамата, по реда на абонирането.Виж го
Factory Method: кой клас да се създаде
new EmailNotification() на десет места, всяко със своя if.interface Notification { void send(String to, String text);}
class EmailNotification implements Notification { @Override public void send(String to, String text) { System.out.println("Имейл до " + to + ": " + text); }}
class SmsNotification implements Notification { @Override public void send(String to, String text) { System.out.println("SMS до " + to + ": " + text); }}class NotificationFactory { public static Notification create(String channel) { return switch (channel) { case "email" -> new EmailNotification(); case "sms" -> new SmsNotification(); default -> throw new IllegalArgumentException("Непознат канал: " + channel); }; }}String[][] customers = { { "email", "maria@example.com" }, { "sms", "+359 88 123 4567" }, { "гълъб", "ул. Шипка 5" },};for (String[] c : customers) { try { Notification n = NotificationFactory.create(c[0]); n.send(c[1], "Поръчката ти е изпратена."); } catch (IllegalArgumentException e) { System.out.println(e.getMessage()); }}Имейл до maria@example.com: Поръчката ти е изпратена.SMS до +359 88 123 4567: Поръчката ти е изпратена.Непознат канал: гълъбNotification. Кой точно клас стои отзад, знае само фабриката. Нов канал, например push, е нов клас и нов ред в switch. Изпращането не се пипа.По-точно, за любопитните
- Това е най-честата форма в практиката. По-точното ѝ име е „проста фабрика“. Factory Method от книгата на GoF е друго: родителят има абстрактен метод, който създава обекта, а наследниците решават какъв:
abstract class Shop { protected abstract Notification createNotification();
public void confirmOrder(String to) { createNotification().send(to, "Поръчката е приета."); }}
class OnlineShop extends Shop { @Override protected Notification createNotification() { return new EmailNotification(); }}
class PhoneShop extends Shop { @Override protected Notification createNotification() { return new SmsNotification(); }}new OnlineShop().confirmOrder("maria@example.com");new PhoneShop().confirmOrder("+359 88 123 4567");Имейл до maria@example.com: Поръчката е приета.SMS до +359 88 123 4567: Поръчката е приета.confirmOrder е написан веднъж, в Shop, и не знае какво уведомление праща.
- В стари примери фабриката връща
nullза непознат вид. Тогава викащият гърми сNullPointerExceptionдалеч от истинската причина. Изключение с ясно съобщение е по-добре.
Виж го
Singleton: само един обект
Настройките на приложението, като валутата, се четат веднъж. Ако всяка част на програмата прави свой обект с настройки, те се четат много пъти и може да се разминат.
class AppConfig { private static AppConfig instance; private String currency = "EUR";
private AppConfig() { System.out.println("Чета настройките..."); }
public static AppConfig getInstance() { if (instance == null) { instance = new AppConfig(); } return instance; }
public String getCurrency() { return currency; }}AppConfig a = AppConfig.getInstance();AppConfig b = AppConfig.getInstance();System.out.println(a == b);System.out.println(b.getCurrency());Чета настройките...trueEUR- Конструкторът е
private. Никой извън класа не може да напишеnew AppConfig(). - Единственият обект е в статичното поле
instance.getInstance()го създава при първото извикване и после връща все него. - „Чета настройките...“ се отпечатва веднъж, а
a == bеtrue: обектът е един.
Това е Singleton.
По-точно, за любопитните
- Ако две нишки извикат
getInstance()в един и същи момент, може да се създадат два обекта. За нишките е модул „Програми в истинския свят“. - Най-сигурният Singleton в Java е
enumс една стойност.enumе тип с предварително изброени обекти. Java създава всеки точно веднъж и не позволяваnew:
enum Settings { INSTANCE;
private String currency = "EUR";
public String getCurrency() { return currency; }}System.out.println(Settings.INSTANCE.getCurrency());System.out.println(Settings.INSTANCE == Settings.INSTANCE);EURtrue- Singleton е обща променлива за цялата програма с хубаво име. Всеки може да го ползва отвсякъде, затова е трудно да се тества и да се смени. Често е по-добре обектът да се създаде веднъж в
mainи да се подаде на тези, които го ползват, през конструктора.
Пробвай
Намери грешката
Номерата на поръчките трябва да са уникални, затова броячът е Singleton. Програмата обаче даде номер 1001 два пъти:
System.out.println(OrderNumbers.getInstance().nextNumber());System.out.println(OrderNumbers.getInstance().nextNumber());System.out.println(new OrderNumbers().nextNumber());100110021001На кой ред в класа е грешката?
Избери реда с грешката.
public. Тогава всеки може да напише new OrderNumbers() и да получи нов брояч, който започва отново от 1001. Singleton се пази от един-единствен ред: private OrderNumbers(). С него третият ред в main изобщо не се компилира.Виж го
Adapter: свързва несъвместими интерфейси
Магазинът плаща през интерфейс със сума в евро и отговор „да или не“. Банката дава стара библиотека, която иска центове и връща код на грешка. Библиотеката не е твоя и не може да я промениш.
// Стара библиотека на банката. Не можеш да я променяш.class LegacyBankClient { // Сумата е в центове. Връща 0 при успех и код на грешка иначе. public int charge(long cents, String reference) { System.out.println("Банка: " + cents + " цента по " + reference); return cents <= 50000 ? 0 : 51; }}Пишеш клас, който изглежда като това, което магазинът очаква, а вътре вика банката:
// Това очаква магазинът.interface PaymentGateway { boolean pay(String orderId, double amountEur);}
class BankAdapter implements PaymentGateway { private LegacyBankClient bank;
public BankAdapter(LegacyBankClient bank) { this.bank = bank; }
@Override public boolean pay(String orderId, double amountEur) { long cents = Math.round(amountEur * 100); return bank.charge(cents, "ORDER-" + orderId) == 0; }}PaymentGateway gateway = new BankAdapter(new LegacyBankClient());System.out.println(gateway.pay("1001", 49.90));System.out.println(gateway.pay("1002", 1299.00));Банка: 4990 цента по ORDER-1001trueБанка: 129900 цента по ORDER-1002falsetrue по пътя обратно. Магазинът не знае, че отзад е стара библиотека. Ако утре смениш банката, пишеш нов адаптер, а магазинът не се пипа.Това е Adapter: като преходник за контакт, който не променя нито щепсела, нито стената.
По-точно, за любопитните
Класическият пример в книгите, и в старата лекция, е медиен плейър. AudioPlayer може да пуска само mp3, а съществуващ клас AdvancedMediaPlayer има методи playVlc и playMp4. MediaAdapter имплементира интерфейса на плейъра и препраща към правилния метод. Структурата е същата като тук.
Упражнение
Шаблоните в магазина
Реши задачите в редактора си.
1. Отстъпки като стратегии. Интерфейс DiscountStrategy със статични методи, които връщат ламбда (интерфейсът може да има static методи с тяло, както и default): none(), percent(double p) и fixed(double amount) (никога под 0 €). Клас Cart със списък цени и сменяема отстъпка. Добави кафе на зърна (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 връща температурата във Фаренхайт с readFahrenheit(). Новата система работи с interface TemperatureSensor { double celsius(); }. Напиши ThermometerAdapter (°C = (°F − 32) × 5 / 9). В списък със сензори сложи два стари термометъра (71,6 °F и 39,2 °F) през адаптера и един нов сензор като ламбда, който връща 21,5 °C.Офис: 22.0 °CСклад: 4.0 °CСървърна: 21.5 °CОще задачи
- Доставка като стратегия. Направи
ShippingStrategyс цена за офис, адрес и вземане от магазина и я сложи в касата заедно с отстъпката. - Дневник като Singleton. Клас
LoggerсgetInstance()и методlog(String), който номерира редовете. Провери, че номерата продължават, когато го викаш от два различни метода. - Наблюдател за цена. Към продукта добави абонати за промяна на цената и прати известие само когато цената падне с поне 10%.
Тест