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

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

Накратко: Шаблонът за дизайн (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 в този метод, а често и в още два: в този за фактурата и в този за имейла с потвърждение. Методът знае за всички промоции наведнъж и всяка промяна го пипа. Тази задача е толкова честа, че решението ѝ си има име.

Идеята

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

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

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

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

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

  • не измисляш наново решение, което другите вече са изпитали;
  • кодът се променя с по-малко риск, защото променливата част е отделена;
  • имената са общ език. „Тук сложи адаптер“ казва на колегата цяла структура с една дума.
По-точно, за любопитните
Класическият каталог е книгата „Design Patterns“ от 1994 г. на четирима автори, наричани „бандата на четиримата“ (Gang of Four, GoF). В нея има 23 шаблона. Тук са петте, които ще срещаш най-често в C# код. Някои C# има вградени в езика: делегатите (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;
Стъпка по стъпка, с думи
  1. Телефон с наличност 0 и празен списък listeners.
  2. Сайтът се абонира: ламбдата му влиза в списъка.
  3. Складът също. Продуктът не знае кои са, само че може да ги извика с име и бройка.
  4. Наличността става 10. Продуктът обикаля списъка и вика всеки абонат по реда на абонирането. Сайтът показва новата бройка.
  5. Складът проверява 10 < 3: не, и не прави нищо.
  6. Наличността става 2. Продуктът обикаля списъка и вика всеки абонат по реда на абонирането. Сайтът показва новата бройка.
  7. Складът проверява 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;
Отговор

Виж го

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

Клиентът е избрал как да го уведомяваме: имейл или SMS. Кодът, който изпраща, не трябва да пише 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: Поръчката ти е изпратена.
Непознат канал: гълъб
Викащият казва какво иска („email“) и получава 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);
Изход
Чета настройките...
True
EUR
  • Конструкторът е 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);
Изход
Създавам настройките...
EUR
True
  • Ако създаването е скъпо и трябва да стане чак когато потрябва, има 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());
Изход
1001
1002
1001

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

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

Виж го

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-1001
True
Банка: 129900 цента по ORDER-1002
False
Адаптерът превежда в двете посоки: евро в центове и номер в текст по пътя натам, код 0 в true по пътя обратно. Магазинът не знае, че отзад е стара библиотека. Ако утре смениш банката, пишеш нов адаптер, а магазинът не се пипа.

Това е 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 и фабрика за трите канала, която не гледа главни и малки букви. Прочети брой редове и после редове „канал;адрес“. На всеки адрес прати „Резервацията ти е потвърдена.“, а за непознат канал отпечатай съобщението на изключението.

Вход
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 връща температурата във Фаренхайт с 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%.

Тест

Провери се

1. Какво е шаблон за дизайн?
2. Всяка нова промоция в магазина добавя нов else if в метода за цена. Кой шаблон помага?
3. Продуктът казва на сайта, склада и имейл системата, когато наличността се промени, без да знае кои са те. Кой е шаблонът?
4. Какво пази Singleton да не стане?
5. Имаш стар клас, който връща температурата във Фаренхайт, а програмата очаква интерфейс ITemperatureSensor с метод Celsius(). Какво правиш?

Идеята

Нататък: шаблоните отблизо

Тук видя пет шаблона накратко, с ламбди. В курса „Софтуерно инженерство“ има цял модул за шаблоните: всеки тръгва от код, който боли, има анимация кой обект кого вика и казва кога не ти трябва. Там са и Builder, Decorator, Composite, Command, State, Template Method, Iterator и Repository.

Модулът е тук: Шаблони за дизайн.

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