Шаблони за дизайн
Накратко: Шаблонът за дизайн (design pattern) е проверено решение на задача, която се повтаря в много програми, например Strategy, Observer, Factory Method, Singleton и Adapter.
Проблем
Методът, който расте с всяка промоция
Цената в магазина зависи от промоцията:
static decimal FinalPrice(decimal price, string promo){ if (promo == "студент") { return price * 0.9m; } else if (promo == "черен петък") { return Math.Max(price - 20, 0); } else if (promo == "лоялен клиент") { return price * 0.95m; } return price;}Console.WriteLine($"{FinalPrice(120, "студент"):F2} €");Console.WriteLine($"{FinalPrice(120, "черен петък"):F2} €");Console.WriteLine($"{FinalPrice(120, "без"):F2} €");108.00 €100.00 €120.00 €else if в този метод, а често и в още два: в този за фактурата и в този за имейла с потвърждение. Методът знае за всички промоции наведнъж и всяка промяна го пипа. Тази задача е толкова честа, че решението ѝ си има име.Идеята
Шаблон за дизайн
Шаблоните се делят на три групи:
- Създаващи: как се правят обекти. Например Singleton (само един обект от класа) и Factory Method (кой клас да се създаде решава друг).
- Структурни: как класове и обекти се сглобяват в по-големи. Например Adapter (свързва несъвместими интерфейси) и Composite (дърво от части, с които се работи като с едно цяло).
- Поведенчески: как обектите си разпределят работата и си говорят. Например Strategy (сменяемо правило) и Observer (абонати за събитие).
Защо си струва да ги знаеш:
- не измисляш наново решение, което другите вече са изпитали;
- кодът се променя с по-малко риск, защото променливата част е отделена;
- имената са общ език. „Тук сложи адаптер“ казва на колегата цяла структура с една дума.
По-точно, за любопитните
Func, Action) вършат работата на Strategy, а събитията (event) на Observer. Шаблонът не е цел: ако един if върши работа, шаблон не трябва.Виж го
Strategy: сменяемо правило
Func<decimal, decimal>: функция, която приема цена и връща цена. Такъв тип за функция в C# се казва делегат. Касата не знае коя е промоцията, а само вика Discount(price):class Checkout{ public Func<decimal, decimal> Discount { get; set; }
public Checkout(Func<decimal, decimal> discount) { Discount = discount; }
public decimal Total(decimal price) { return Math.Max(Discount(price), 0); }}var checkout = new Checkout(price => price);Console.WriteLine($"Без отстъпка: {checkout.Total(120):F2} €");
checkout.Discount = price => price * 0.9m;Console.WriteLine($"Студент: {checkout.Total(120):F2} €");
checkout.Discount = price => price - 20;Console.WriteLine($"Черен петък: {checkout.Total(120):F2} €");Без отстъпка: 120.00 €Студент: 108.00 €Черен петък: 100.00 €Checkout не се променя. Правилото може да се смени и докато програмата работи: записваш нова ламбда в checkout.Discount.Това е Strategy: семейство от правила, всяко от един и същ тип (тук Func<decimal, decimal>), така че да са взаимозаменяеми. Ламбдата за сравнение, която подаваш на Sort на списъка, е също стратегия: Sort знае как се подрежда, а ти казваш по какво.По-точно, за любопитните
В книгите класът, който ползва стратегията, се казва „контекст“ (тук Checkout). В Java стратегията е интерфейс. В C# интерфейс също става, но тогава ламбда не може да се подаде: ламбдата се превръща в делегат, а интерфейсът не е делегат.
interface IDiscountStrategy{ decimal Apply(decimal price);}
IDiscountStrategy student = price => price * 0.9m;error CS1660: Cannot convert lambda expression to type 'IDiscountStrategy' because it is not a delegate typeЗатова сменяемо правило от една функция в C# е делегат. Ако стратегията има свои полета, тя може да е клас, а касата получава метода му. new PercentDiscount(10).Apply, без скоби, е делегат към метода Apply на този обект:
class PercentDiscount{ private decimal percent;
public PercentDiscount(decimal percent) { this.percent = percent; }
public decimal Apply(decimal price) { return price * (1 - percent / 100); }}var checkout = new Checkout(new PercentDiscount(10).Apply);Console.WriteLine($"Студент: {checkout.Total(120):F2} €");Студент: 108.00 €Клас е по-добрият избор, когато стратегията има свои полета или е дълга. Ламбда, когато е един ред.
Виж го
Observer: абонати за събитие
Когато наличността на продукт се промени, сайтът трябва да покаже новата бройка, а складът да поръча още, ако бройките са малко. Утре може и имейл до клиентите. Продуктът не трябва да знае за никого от тях.
class Product{ private string name; private int quantity; private List<Action<string, int>> listeners = new List<Action<string, int>>();
public Product(string name, int quantity) { this.name = name; this.quantity = quantity; }
public void Subscribe(Action<string, int> listener) { listeners.Add(listener); }
public int Quantity { get { return quantity; } set { if (value == quantity) { return; } quantity = value; foreach (var listener in listeners) { listener(name, quantity); } } }}var phone = new Product("Телефон", 0);phone.Subscribe((name, qty) => Console.WriteLine($"Сайт: {name}, наличност {qty} бр."));phone.Subscribe((name, qty) =>{ if (qty < 3) { Console.WriteLine($"Склад: поръчай още {name}"); }});
phone.Quantity = 10;phone.Quantity = 2;Сайт: Телефон, наличност 10 бр.Сайт: Телефон, наличност 2 бр.Склад: поръчай още Телефон- Който иска да знае, се абонира със
Subscribeи подава ламбда. Типът ѝ еAction<string, int>: делегат, който приема име и бройка и не връща нищо. - Наличността е свойство
Quantity. Приphone.Quantity = 10се изпълнява неговиятset: продуктът обикаля списъка и вика всеки абонат. Кои са те и какво правят, не го интересува. - Ако бройката не се е променила, никой не се безпокои.
var phone = new Product("Телефон", 0);phone.Subscribe((name, qty) => Console.WriteLine($"Сайт: {name}, наличност {qty} бр."));phone.Subscribe((name, qty) =>{ if (qty < 3) { Console.WriteLine($"Склад: поръчай още {name}"); }});
phone.Quantity = 10;phone.Quantity = 2;Стъпка по стъпка, с думи
- Телефон с наличност 0 и празен списък listeners.
- Сайтът се абонира: ламбдата му влиза в списъка.
- Складът също. Продуктът не знае кои са, само че може да ги извика с име и бройка.
- Наличността става 10. Продуктът обикаля списъка и вика всеки абонат по реда на абонирането. Сайтът показва новата бройка.
- Складът проверява 10 < 3: не, и не прави нищо.
- Наличността става 2. Продуктът обикаля списъка и вика всеки абонат по реда на абонирането. Сайтът показва новата бройка.
- Складът проверява 2 < 3: да, и поръчва още.
Това е Observer (наблюдател). Продуктът е наблюдаваният обект, а слушателите са наблюдатели. Бутоните в графичните програми работят точно така: „при натискане извикай това“.
По-точно, за любопитните
- В C# списъкът с абонати е вграден в езика: събитие (
event).+=абонира,-=отписва, аStockChanged?.Invoke(...)вика всички абонати. Без абонати събитието еnull, затова е?.: тогава не се вика нищо.
class Product{ private string name; private int quantity;
public event Action<string, int> StockChanged;
public Product(string name, int quantity) { this.name = name; this.quantity = quantity; }
public int Quantity { get { return quantity; } set { if (value == quantity) { return; } quantity = value; StockChanged?.Invoke(name, quantity); } }}- Отписваш същата ламбда, която си абонирал, затова я пазиш в променлива. Абонат, когото никой не отписва, живее в паметта, докато живее продуктът.
var phone = new Product("Телефон", 5);Action<string, int> site = (name, qty) => Console.WriteLine($"Сайт: {qty} бр.");phone.StockChanged += site;phone.Quantity = 4;phone.StockChanged -= site;phone.Quantity = 3;Console.WriteLine("Сайтът вече не чува");Сайт: 4 бр.Сайтът вече не чува- Отвън събитието може само да се абонира и отпише. Да го извика може само самият клас:
phone.StockChanged("Телефон", 0);error CS0070: The event 'Product.StockChanged' can only appear on the left hand side of += or -= (except when used from within the type 'Product')- В старата лекция наблюдаваният клас се казва
Subject, с методиattachиnotifyAllObservers, а наблюдателят е абстрактен клас с методupdate(). Идеята е същата, а с делегати не трябва клас за всеки наблюдател. - Слушателите се викат един след друг в нишката, която е сменила бройката. Ако един от тях е бавен, чакат всички.
Пробвай
Кой ще чуе?
Product е този от картата преди тази.
Какво ще отпечата програмата?
var coffee = new Product("Кафе", 5);coffee.Subscribe((name, qty) => Console.WriteLine($"А: {qty}"));coffee.Quantity = 4;coffee.Quantity = 4;coffee.Subscribe((name, qty) => Console.WriteLine($"Б: {qty}"));coffee.Quantity = 0;Quantity = 4 уведомява А. Второто не е промяна и никой не чува. Б се абонира след това и не научава за минали промени. При 0 чуват и двамата, по реда на абонирането.Виж го
Factory Method: кой клас да се създаде
new EmailNotification() на десет места, всяко със своя if.interface INotification{ void Send(string to, string text);}
class EmailNotification : INotification{ public void Send(string to, string text) { Console.WriteLine($"Имейл до {to}: {text}"); }}
class SmsNotification : INotification{ public void Send(string to, string text) { Console.WriteLine($"SMS до {to}: {text}"); }}static class NotificationFactory{ public static INotification Create(string channel) { return channel switch { "email" => new EmailNotification(), "sms" => new SmsNotification(), _ => throw new ArgumentException($"Непознат канал: {channel}"), }; }}string[,] customers ={ { "email", "maria@example.com" }, { "sms", "+359 88 123 4567" }, { "гълъб", "ул. Шипка 5" }};for (int i = 0; i < customers.GetLength(0); i++){ try { var n = NotificationFactory.Create(customers[i, 0]); n.Send(customers[i, 1], "Поръчката ти е изпратена."); } catch (ArgumentException e) { Console.WriteLine(e.Message); }}Имейл до maria@example.com: Поръчката ти е изпратена.SMS до +359 88 123 4567: Поръчката ти е изпратена.Непознат канал: гълъбINotification. Кой точно клас стои отзад, знае само фабриката. Нов канал, например push, е нов клас и нов ред в switch. Изпращането не се пипа.По-точно, за любопитните
- Това е най-честата форма в практиката. По-точното ѝ име е „проста фабрика“. Factory Method от книгата на GoF е друго: родителят има абстрактен метод, който създава обекта, а наследниците решават какъв:
abstract class Shop{ protected abstract INotification CreateNotification();
public void ConfirmOrder(string to) { CreateNotification().Send(to, "Поръчката е приета."); }}
class OnlineShop : Shop{ protected override INotification CreateNotification() { return new EmailNotification(); }}
class PhoneShop : Shop{ protected override INotification 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за непознат вид. Тогава викащият гърми сNullReferenceExceptionдалеч от истинската причина:
static class NotificationFactory{ public static INotification Create(string channel) { if (channel == "email") { return new EmailNotification(); } if (channel == "sms") { return new SmsNotification(); } return null; }}
var n = NotificationFactory.Create("гълъб");try{ n.Send("ул. Шипка 5", "Поръчката ти е изпратена.");}catch (NullReferenceException e){ Console.WriteLine($"NullReferenceException: {e.Message}");}NullReferenceException: Object reference not set to an instance of an object.Изключение с ясно съобщение във фабриката е по-добре.
Виж го
Singleton: само един обект
Настройките на приложението, като валутата, се четат веднъж. Ако всяка част на програмата прави свой обект с настройки, те се четат много пъти и може да се разминат.
class AppConfig{ private static AppConfig instance;
public string Currency { get; } = "EUR";
private AppConfig() { Console.WriteLine("Чета настройките..."); }
public static AppConfig Instance { get { if (instance == null) { instance = new AppConfig(); } return instance; } }}var a = AppConfig.Instance;var b = AppConfig.Instance;Console.WriteLine(a == b);Console.WriteLine(b.Currency);Чета настройките...TrueEUR- Конструкторът е
private. Никой извън класа не може да напишеnew AppConfig(). - Единственият обект е в статичното поле
instance. СвойствотоInstanceго създава при първото четене и после връща все него. - „Чета настройките...“ се отпечатва веднъж, а
a == bеTrue: обектът е един.
Това е Singleton.
По-точно, за любопитните
- Ако две нишки (части от програмата, които вървят едновременно) прочетат
Instanceв един и същи момент, и двете може да видятnullи да създадат два обекта. - Най-сигурният Singleton в C# е
static readonlyполе. Средата инициализира статичните полета на класа точно веднъж, преди класът да се ползва, и когато много нишки го искат наведнъж:
class Settings{ public static readonly Settings Instance = new Settings();
public string Currency { get; } = "EUR";
private Settings() { Console.WriteLine("Създавам настройките..."); }}Console.WriteLine(Settings.Instance.Currency);Console.WriteLine(Settings.Instance == Settings.Instance);Създавам настройките...EURTrue- Ако създаването е скъпо и трябва да стане чак когато потрябва, има
Lazy<T>. Обектът се създава при първото.Value, също веднъж и при много нишки:
class AppConfig{ private static readonly Lazy<AppConfig> instance = new Lazy<AppConfig>(() => new AppConfig());
public string Currency { get; } = "EUR";
private AppConfig() { Console.WriteLine("Чета настройките..."); }
public static AppConfig Instance { get { return instance.Value; } }}Console.WriteLine("Програмата тръгва");var a = AppConfig.Instance;var b = AppConfig.Instance;Console.WriteLine(a == b);Програмата тръгваЧета настройките...True- Singleton е обща променлива за цялата програма с хубаво име. Всеки може да го ползва отвсякъде, затова е трудно да се тества и да се смени. Често е по-добре обектът да се създаде веднъж в
Mainи да се подаде на тези, които го ползват, през конструктора.
Пробвай
Намери грешката
Номерата на поръчките трябва да са уникални, затова броячът е Singleton. Програмата обаче даде номер 1001 два пъти:
Console.WriteLine(OrderNumbers.Instance.NextNumber());Console.WriteLine(OrderNumbers.Instance.NextNumber());Console.WriteLine(new OrderNumbers().NextNumber());100110021001На кой ред в класа е грешката?
Избери реда с грешката.
Конструкторът е public. Тогава всеки може да напише new OrderNumbers() и да получи нов брояч, който започва отново от 1001. Singleton се пази от един-единствен ред: private OrderNumbers(). С него третият ред в Main изобщо не се компилира:
error CS0122: 'OrderNumbers.OrderNumbers()' is inaccessible due to its protection levelВиж го
Adapter: свързва несъвместими интерфейси
Магазинът плаща през интерфейс със сума в евро и отговор „да или не“. Банката дава стара библиотека, която иска центове и връща код на грешка. Библиотеката не е твоя и не може да я промениш.
// Стара библиотека на банката. Не можеш да я променяш.class LegacyBankClient{ // Сумата е в центове. Връща 0 при успех и код на грешка иначе. public int Charge(long cents, string reference) { Console.WriteLine($"Банка: {cents} цента по {reference}"); return cents <= 50000 ? 0 : 51; }}Пишеш клас, който изглежда като това, което магазинът очаква, а вътре вика банката:
// Това очаква магазинът.interface IPaymentGateway{ bool Pay(string orderId, decimal amountEur);}
class BankAdapter : IPaymentGateway{ private LegacyBankClient bank;
public BankAdapter(LegacyBankClient bank) { this.bank = bank; }
public bool Pay(string orderId, decimal amountEur) { long cents = (long)Math.Round(amountEur * 100); return bank.Charge(cents, "ORDER-" + orderId) == 0; }}IPaymentGateway gateway = new BankAdapter(new LegacyBankClient());Console.WriteLine(gateway.Pay("1001", 49.90m));Console.WriteLine(gateway.Pay("1002", 1299.00m));Банка: 4990 цента по ORDER-1001TrueБанка: 129900 цента по ORDER-1002Falsetrue по пътя обратно. Магазинът не знае, че отзад е стара библиотека. Ако утре смениш банката, пишеш нов адаптер, а магазинът не се пипа.Това е Adapter: като преходник за контакт, който не променя нито щепсела, нито стената.
По-точно, за любопитните
Класическият пример в книгите, и в старата лекция, е медиен плейър. AudioPlayer може да пуска само mp3, а съществуващ клас AdvancedMediaPlayer има методи playVlc и playMp4. MediaAdapter имплементира интерфейса на плейъра и препраща към правилния метод. Структурата е същата като тук.
Упражнение
Шаблоните в магазина
Реши задачите в редактора си.
1. Отстъпки като стратегии. Статичен клас Discounts с методи, които връщат ламбда от тип Func<decimal, decimal>: None(), Percent(decimal p) и Fixed(decimal 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 връща температурата във Фаренхайт с ReadFahrenheit(). Новата система работи с interface ITemperatureSensor { double 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Още задачи
- Доставка като стратегия. Направи
Func<decimal, decimal>за цена на доставка до офис, до адрес и от магазина и сложи избраната в касата заедно с отстъпката. - Дневник като Singleton. Клас
Loggerсъс свойствоInstanceи методLog(string text), който номерира редовете. Провери, че номерата продължават, когато го викаш от два различни метода. - Наблюдател за цена. Към продукта добави събитие за промяна на цената и прати известие само когато цената падне с поне 10%.
Тест
Провери се
Идеята
Нататък: шаблоните отблизо
Тук видя пет шаблона накратко, с ламбди. В курса „Софтуерно инженерство“ има цял модул за шаблоните: всеки тръгва от код, който боли, има анимация кой обект кого вика и казва кога не ти трябва. Там са и Builder, Decorator, Composite, Command, State, Template Method, Iterator и Repository.
Модулът е тук: Шаблони за дизайн.Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Списъци · Вложени класове · Ламбди и делегати · record, switch и шаблони
- Продължава в: Защо шаблони · Strategy · Observer · Factory Method и Simple Factory
- Задачи по темата: Модерен C#