SOLID принципи складаються з 5 основних принципів, запропонованих Робертом C. Мартіном для покращення модульності, читабельності, підтримуваності та розширюваності дизайну програмного забезпечення в парадигмі ООП. SOLID має на меті створення кодової бази, яка є високоякісною, стійкою до помилок і гнучкою в процесах розробки програмного забезпечення.
Дотримання цих принципів є необхідним для розробників програмного забезпечення. Адже завдяки ним можна уникнути багатьох проблем, що виникають у програмних проектах. Принципи SOLID полегшують обслуговування програмного забезпечення, одночасно надаючи структуру, яка є розширюваною та повторно використовуваною. Ось деякі з переваг, які надають ці принципи:
1. Забезпечує більш модульну структуру
Принципи SOLID сприяють поділу програмного забезпечення на маленькі, незалежні одиниці. Таким чином:
- Код стає більш читабельним.
- Кожен модуль можна тестувати та розвивати окремо.
- Обробка коду різними членами команди стає легшою.
2. Забезпечує гнучкість до змін
Зміни в програмному забезпеченні неминучі. Завдяки принципам SOLID:
- Ви можете створювати розширювані структури замість того, щоб змінювати наявний код при додаванні нових функцій (OCP — Open/Closed Principle).
- Мінімізується ймовірність виникнення небажаних ефектів від змін в інших частинах коду.
3. Створює повторно використовуваний код
Код, написаний відповідно до принципів SOLID:
- Може бути повторно використаний у різних частинах проекту.
- Легко адаптується для нових проектів.
Це заощаджує час і знижує витрати на розробку програмного забезпечення.
4. Покращує тестованість
Код, написаний за принципами SOLID, спрощує написання юніт-тестів:
- Класи з одним завданням і незалежними відносинами можна тестувати окремо (SRP — Single Responsibility Principle).
- Структури, що працюють з інтерфейсами та абстракціями, дозволяють використовувати фальшиві об'єкти (mock) замість реальних під час тестування (DIP — Dependency Inversion Principle).
5. Забезпечує стабільність програмного забезпечення
Принципи SOLID гарантують, що програмне забезпечення працюватиме стабільно, навіть якщо воно росте і змінюється з часом.
- Ви можете бути впевнені, що підкласи не порушать поведінку батьківських класів (LSP — Liskov Substitution Principle).
- Позбувшись зайвих залежностей, ви зможете обмежити вплив змін на систему (ISP — Interface Segregation Principle).
6. Полегшує командну роботу
- Код, що відповідає принципам SOLID, є більш зрозумілим і організованим, тому іншим розробникам у команді буде легше пристосуватися до нього.
- Код-рев'ю стають більш ефективними.
Усі ці переваги пояснюють, чому принципи SOLID такі важливі для розробників.
Принципи SOLID складаються з першої літери кожного принципу:
Single Responsibility Principle (SRP) — Принцип єдиного обов'язку
Цей принцип стверджує, що кожен об'єкт повинен бути створений для виконання тільки однієї конкретної функції. Тобто, код, який відповідає SRP, містить одну функцію на кожен клас.
Мета SRP — зробити код більш модульним і мінімізувати проблеми між залежностями. Розбиття коду на модулі і функціональні одиниці збільшує повторне використання і зменшує потребу в переписуванні вже виконаної роботи. Прийняття SRP приносить користь при оновленнях коду, оскільки, коли потрібно оновити певну функцію, є менше місць, де може виникнути проблема.
Критику щодо SRP висловлюють за те, що цей підхід часто призводить до великої кількості мікросервісів та численних фрагментів коду. Однак Мартін зазначав, що немає потреби суворо розділяти функції, а в цьому контексті він сказав:
"Об'єднуйте те, що змінюється з однієї причини. Розділяйте те, що змінюється з різних причин."
C# Код Приклад;
1.
Код, що не відповідає SRP:
public class Order
{
public int OrderId { get; set; }
public string CustomerName { get; set; }
public decimal Amount { get; set; }
// Обробка замовлення та платіжний процес в одному класі
public void ProcessOrder()
{
// Обробка замовлення
Console.WriteLine($"Processing order for {CustomerName}");
// Платіжний процес
Console.WriteLine("Processing payment...");
}
}
У наведеному коді клас Order зберігає як дані замовлення, так і виконує платіжний процес. Це не відповідає принципу SRP, оскільки клас має дві різні відповідальності: управління замовленнями та обробка платежів.
Код, що відповідає SRP:
// Клас для обробки замовлення
public class Order
{
public int OrderId { get; set; }
public string CustomerName { get; set; }
public decimal Amount { get; set; }
public void ProcessOrder()
{
// Обробка замовлення
Console.WriteLine($"Processing order for {CustomerName}");
}
}
// Клас для обробки платежів
public class PaymentProcessor
{
public void ProcessPayment(decimal amount)
{
// Платіжний процес
Console.WriteLine($"Processing payment of {amount}...");
}
}
У цьому коді кожен клас має лише одну відповідальність:
- Клас Order обробляє лише замовлення.
- Клас PaymentProcessor відповідає за платіжний процес.
Це дозволяє полегшити підтримку коду. Наприклад, якщо потрібно змінити платіжний процес, достатньо змінити лише клас PaymentProcessor, не торкаючись класу Order.
2. Open/Closed Principle (OCP) — Принцип відкритості/закритості
Цей принцип стверджує, що клас має бути закритий для змін, але відкритий для розширень. Це дозволяє збільшити розширюваність програмного забезпечення, зберігаючи при цьому стабільність і надійність існуючого коду.
Код, що не відповідає OCP:
public class DiscountCalculator
{
public decimal CalculateDiscount(Order order)
{
// Додавання знижки до замовлення
if (order.Amount > 1000)
{
return order.Amount * 0.1m; // Знижка 10%
}
return 0;
}
// Додавання функції для обробки платежів
public void ProcessPayment(Order order)
{
// Обробка платежу
Console.WriteLine($"Processing payment of {order.Amount}...");
}
}
У цьому прикладі клас DiscountCalculator відповідає як за розрахунок знижки, так і за обробку платежу. Це не відповідає принципу OCP, тому що, якщо потрібно додати нову функціональність для обробки платежу, потрібно буде змінити існуючий клас DiscountCalculator, що може порушити його поточну роботу.
Код, що відповідає OCP:
// Клас для розрахунку знижки
public class DiscountCalculator
{
public decimal CalculateDiscount(Order order)
{
if (order.Amount > 1000)
{
return order.Amount * 0.1m; // Знижка 10%
}
return 0;
}
}
// Клас для обробки платежів
public class PaymentProcessor
{
public void ProcessPayment(Order order)
{
// Обробка платежу
Console.WriteLine($"Processing payment of {order.Amount}...");
}
}
// Коли додається новий метод обробки платежів, ми не змінюємо існуючий код, а просто додаємо новий клас.
public class CreditCardPaymentProcessor : PaymentProcessor
{
public override void ProcessPayment(Order order)
{
Console.WriteLine($"Processing credit card payment of {order.Amount}...");
}
}
public class BankCardPaymentProcessor : PaymentProcessor
{
public override void ProcessPayment(Order order)
{
Console.WriteLine($"Processing PayPal payment of {order.Amount}...");
}
}
У цьому прикладі клас DiscountCalculator відповідає лише за обчислення знижки, а за обробку платежів відповідають окремі класи. Коли з'являється новий метод оплати, ми додаємо новий клас, не змінюючи наявні, що відповідає принципу OCP.
Клас PaymentProcessor займається загальною обробкою платежів і містить метод ProcessPayment. Для додавання нових методів обробки платежів ми не змінюємо клас PaymentProcessor. Замість цього створюємо нові класи, які успадковують PaymentProcessor (CreditCardPaymentProcessor, BankCardPaymentProcessor), і в кожному класі реалізуємо обробку конкретного типу платежу.
Таким чином:
- При додаванні нових типів платежів, нам не потрібно змінювати існуючий код. Достатньо додати нові класи.
- Застосовується принцип Open/Closed, оскільки нові можливості додаються без зміни існуючих класів.
3. Liskov Substitution Principle (LSP) — Принцип заміщення Ліскова
Цей принцип стверджує, що ми повинні мати можливість використовувати підкласи (derived class) замість їх базових класів (base class) без змін у поведінці програми. Іншими словами, функціональність, успадкована від base class, повинна працювати бездоганно і в derived class. Якщо підклас порушує або змінює функціональність батьківського класу, це є порушенням принципу LSP.
Підклас (derived class) успадковує всі властивості та поведінку батьківського класу (base class); однак він повинен продовжувати їх або розширювати без порушення роботи. Як і в OCP (Open/Closed Principle), принцип LSP сприяє тому, щоб код був розширюваним без зміни існуючого функціоналу.
Цей принцип:
- Збільшує повторне використання коду і гнучкість.
- Підтримує коректну роботу Polymorphism.
- Запобігає несподіваній поведінці програми під час її виконання.
Порушення LSP часто призводить до того, що поведінка коду стає непередбачуваною і важко співпрацювати з підкласами базового класу. Тому дотримання LSP є важливим для створення розширюваного і підтримуваного програмного забезпечення.
Код, що не відповідає LSP:
public class Kus
{
public virtual void Uc()
{
Console.WriteLine("Качки можуть літати.");
}
}
public class Guvercin : Kus
{
public override void Uc()
{
Console.WriteLine("Голуби літають.");
}
}
public class Penguen : Kus
{
// Пінгвіни не можуть літати, тому метод Uc не перевизначено
public override void Uc()
{
throw new NotImplementedException("Пінгвіни не можуть літати!");
}
}
// Використання
public class KusTest
{
public void TestUc(Bird bird)
{
kus.Fly(); // Припускаємо, що всі птахи можуть літати
}
}
// Тестування
var kusTest = new KusTest();
kusTest.TestUc(new Guvercin()); // Працює
kusTest.TestUc(new Penguen()); // Не працює, оскільки пінгвін не може літати!
У наведеному прикладі клас Penguen успадковує клас Kus і перевизначає метод Uc, але оскільки пінгвіни не можуть літати, метод не виконується. Таким чином, підклас не виконує очікувану поведінку, що порушує принцип LSP.
Код, що відповідає LSP:
public abstract class Kus
{
public abstract void HareketEt(); // Описуємо загальний рух для всіх птахів
}
public class UcanKus : Kus
{
public virtual void Uc()
{
Console.WriteLine("Цей птах може літати.");
}
public override void HareketEt()
{
Uc();
}
}
public class UcamayanKus : Kus
{
public override void HareketEt()
{
Console.WriteLine("Цей птах не може літати, він може ходити.");
}
}
// Підкласи
public class Guvercin : UcanKus
{
public override void Uc()
{
Console.WriteLine("Голуби можуть літати.");
}
}
public class Penguen : UcamayanKus
{
public override void HareketEt()
{
Console.WriteLine("Пінгвін (повільно) ходить.");
}
}
// Використання
var kuslar = new List
{
new Guvercin(),
new Penguen()
};
foreach (var kus in kuslar)
{
kus.HareketEt(); // Тепер кожен птах показує свою поведінку
}
У цьому прикладі, як показано вище, кожен підклас продовжує функціональність свого базового класу без порушення принципу LSP.
Принцип сегрегації інтерфейсів (ISP) — Interface Segregation Principle
Цей принцип стверджує, що інтерфейс має містити лише необхідні функціональні можливості. Тобто, клас не повинен бути змушений реалізовувати методи, які він не використовує. Цей принцип сприяє тому, щоб великі й загальні інтерфейси розділялись на менші, специфічніші інтерфейси.
Якщо клас реалізує інтерфейс, він повинен мати змогу використовувати всі методи цього інтерфейсу. Якщо інтерфейс містить надмірну кількість методів, і деякі з них не використовуються, це призводить до зайвих залежностей і порушує принцип ISP. Щоб уникнути таких проблем, великі інтерфейси мають бути розділені на менші, більш фокусовані інтерфейси. Таким чином, програма стає більш модульною, підтримуваною і легко тестованою.
Кожен інтерфейс повинен бути спроектований для виконання конкретної задачі та містити лише ті методи, що відповідають цій задачі, щоб код відповідав принципу ISP.
Код, що не відповідає ISP:
public interface IPrinter
{
void Print();
void Scan();
void Fax();
}
// Старий принтер реалізує цей інтерфейс:
public class OldPrinter : IPrinter
{
public void Print()
{
Console.WriteLine("Printing...");
}
public void Scan()
{
throw new NotImplementedException("Цей принтер не підтримує сканування.");
}
public void Fax()
{
throw new NotImplementedException("Цей принтер не підтримує факсування.");
}
}
У наведеному прикладі клас OldPrinter змушений реалізувати інтерфейс IPrinter, але оскільки він не підтримує методи Scan та Fax, ці методи залишаються непрацюючими. Це створює зайві залежності й ускладнює підтримку коду. Таке проектування вводить в оману і робить код складнішим для обслуговування.
Код, що відповідає ISP:
public interface IPrinter
{
void Print();
}
public interface IScanner
{
void Scan();
}
public interface IFax
{
void Fax();
}
// Старий принтер підтримує тільки друк, тому реалізує лише IPrinter
public class OldPrinter : IPrinter
{
public void Print()
{
Console.WriteLine("Printing...");
}
}
// Більш сучасний принтер може підтримувати кілька функцій
public class MultiFunctionPrinter : IPrinter, IScanner, IFax
{
public void Print()
{
Console.WriteLine("Printing...");
}
public void Scan()
{
Console.WriteLine("Scanning...");
}
public void Fax()
{
Console.WriteLine("Faxing...");
}
}
У цьому прикладі ми розділяємо інтерфейс на менші, більш спеціалізовані частини, і кожен клас імплементує лише ті методи, які йому потрібні, що забезпечує відповідність принципу ISP.
5. Принцип інверсії залежностей (DIP) — Dependency Inversion Principle
Принцип інверсії залежностей (DIP) стверджує, що високорівневі модулі не повинні залежати від низькорівневих модулів. Натомість обидва модулі повинні залежати від абстракцій. Тобто, клас або модуль не повинні залежати від реалізації іншого класу чи модуля, а лише від його абстракції (інтерфейсу або абстрактного класу).
Мета цього принципу — зробити програму більш гнучкою, модульною та легкою для підтримки. Якщо високорівневі модулі залежать від конкретних реалізацій низькорівневих модулів, це зменшує гнучкість програми. Наприклад, якщо високорівнева служба доступу до бази даних залежить від конкретної реалізації бази даних, то при зміні бази даних, увесь додаток може бути порушений. Але якщо служба залежить від абстракції бази даних, зміна конкретного типу бази не вплине на службу.
Принцип інверсії залежностей часто реалізується за допомогою інструментів, таких як Dependency Injection (DI) або Service Locator.
Принцип інверсії залежностей (DIP) — Dependency Inversion Principle
Цей принцип стверджує, що високорівневі модулі не повинні залежати від низькорівневих модулів. Натомість обидва модулі повинні залежати від абстракцій. Тобто, клас або модуль не повинен залежати від реалізації іншого класу чи модуля, а лише від його абстракції (інтерфейсу або абстрактного класу).
Використовуючи принцип Dependency Inversion, програмне забезпечення отримує кілька переваг:
- Зменшення залежностей: Це дозволяє зробити програму більш гнучкою і обмежує область впливу змін.
- Підвищена повторна використовуваність: Завдяки абстракціям зменшуються залежності, тому компоненти можуть використовуватись незалежно один від одного.
- Підвищена тестованість: Замість реальних залежностей можна використовувати підроблені (mocked) об'єкти, що полегшує написання юніт-тестів.
- Модульність та підтримуваність коду: Код стає більш гнучким завдяки абстракціям і компонентам, що полегшує обслуговування програми.
Для реалізації цього принципу зазвичай використовуються абстракції (інтерфейси або абстрактні класи). Завдяки цьому високорівневі модулі залежать лише від абстракцій, а не від деталей реалізації.
Приклад коду, що не відповідає DIP:
public class SqlDatabase
{
public void SaveData(string data)
{
Console.WriteLine("Дані збережено в SQL базі даних");
}
}
public class DataService
{
private SqlDatabase _database;
public DataService()
{
_database = new SqlDatabase(); // Пряма залежність
}
public void SaveData(string data)
{
_database.SaveData(data);
}
}
public class Program
{
public static void Main()
{
DataService dataService = new DataService(); // Пряма залежність
dataService.SaveData("Привіт, код, що не відповідає DIP!");
}
}
У наведеному прикладі клас DataService має пряму залежність від класу SqlDatabase. Це означає, що високорівневий модуль (DataService) залежить від низькорівневого модуля (база даних), що порушує принцип DIP, адже обидва модулі повинні залежати від абстракцій.
Приклад коду, що відповідає DIP:
public interface IDatabase
{
void SaveData(string data);
}
public class SqlDatabase : IDatabase
{
public void SaveData(string data)
{
Console.WriteLine("Дані збережено в SQL базі даних");
}
}
public class MongoDatabase : IDatabase
{
public void SaveData(string data)
{
Console.WriteLine("Дані збережено в MongoDB");
}
}
public class DataService
{
private readonly IDatabase _database;
// Залежність від абстракції через інжекцію залежностей
public DataService(IDatabase database)
{
_database = database;
}
public void SaveData(string data)
{
_database.SaveData(data);
}
}
public class Program
{
public static void Main()
{
IDatabase database = new SqlDatabase(); // Залежність від абстракції
DataService dataService = new DataService(database);
dataService.SaveData("Привіт, код, що відповідає DIP!");
// Якщо потрібно використовувати інший тип бази даних
IDatabase mongoDatabase = new MongoDatabase();
dataService = new DataService(mongoDatabase);
dataService.SaveData("Дані збережено в MongoDB.");
}
}
У цьому прикладі клас DataService залежить від абстракції IDatabase, а не від конкретного класу SqlDatabase. Це дозволяє замінювати одну реалізацію бази даних на іншу без змін у класі DataService. Така реалізація відповідає принципу DIP, оскільки високорівневий модуль залежить лише від абстракції, а не від конкретних реалізацій.
Перекладено з: SOLID Prensipleri Nelerdir?