Розуміння принципів SOLID за допомогою практичних прикладів коду
Цей посібник детально розглядає всі п’ять принципів SOLID за допомогою конкретних прикладів коду, показуючи, як вони застосовуються у реальних проектах та додатках на React.
Коли ви розробляєте програмне забезпечення, правильна робота коду — це лише половина завдання.
Коли додаток починає розвиватися, його кодова база часто стає складнішою для читання, зміни та підтримки правильної роботи. Невелика зміна в одній частині системи може тихо пошкодити щось не пов’язане.
Саме такі проблеми мали на меті вирішити принципи SOLID.
SOLID — це група з п’яти принципів проектування в об’єктно-орієнтованому програмуванні, які спрямовані на створення коду, що є:
- Простішим у підтримці
- Простішим у тестуванні
- Простішим у розширенні
- Більш відокремленим за структурою
- Простішим для розуміння командою
Абревіатура розкладається наступним чином:
S — Принцип єдиної відповідальності
O — Принцип відкритості/закритості
L — Принцип заміщуваності Ліскова
I — Принцип сегрегації інтерфейсу
D — Принцип інверсії залежностей
Розглянемо кожен з них за допомогою простих прикладів.
1. S — Принцип єдиної відповідальності
«Клас має мати лише одну причину для змін».
Простими словами, кожен клас чи модуль має бути створений навколо однієї функції.
Розглянемо клас User, який відповідає за:
- Зберігання даних користувача
- Взаємодію з базою даних
- Надсилання електронних листів
- Створення звітів
Це занадто багато функцій для одного класу.
class User {
createUser() {
// create user
}
saveToDatabase() {
// save user
}
sendEmail() {
// send email
}
generateReport() {
// generate report
}
}
Якщо потрібно змінити логіку надсилання електронних листів, доводиться редагувати клас User.
Якщо змінюється робота з базою даних, знову потрібно працювати з цим самим класом.
Більш чистий підхід — розділити ці функції.
class User {
createUser() {
// create user
}
}
class UserRepository {
saveToDatabase() {
// database logic
}
}
class EmailService {
sendEmail() {
// email logic
}
}
class ReportService {
generateReport() {
// report logic
}
}
Завдяки такому розділенню кожен клас тепер відповідає лише за одну функцію.
Чому це корисно?
Як тільки змінюється якась вимога, ви одразу знаєте, яку частину коду потрібно змінити.
Одна функція на клас означає одну причину для його зміни.
2. O — Принцип відкритості/закритості
„Програмні елементи мають бути відкритими для розширення, але закритими для змін.“
Цей формулювання здається абстрактним, але концепція за ним — ні.
Мета полягає у додаванні нових функцій без постійного переписування вже працюючого коду.
Візьмемо приклад обробки платежів:
function processPayment(type, amount) {
if (type === "card") {
// card payment
} else if (type === "upi") {
// UPI payment
} else if (type === "paypal") {
// PayPal payment
}
}
Тепер уявімо, що вам потрібно підтримувати:
- Stripe
- Razorpay
- Apple Pay
- Google Pay
Кожен новий варіант робить цю функцію більшою та хаотичною.
Кращою стратегією є надання кожному способу оплати власного класу.
class CardPayment {
pay(amount) {
console.log(`Card payment: ${amount}`);
}
}
class UpiPayment {
pay(amount) {
console.log(`UPI payment: ${amount}`);
}
}
class PaypalPayment {
pay(amount) {
console.log(`PayPal payment: ${amount}`);
}
}
З такою структурою додавання нового способу оплати не вимагає змінювати класи, які ви вже написали.
class StripePayment {
pay(amount) {
console.log(`Stripe payment: ${amount}`);
}
}
Оригінальні реалізації залишаються абсолютно такими, якими були.
Ідея
Розширюйте систему, додаючи новий код, а не постійно редагуючи вже стабільний код.
3. L — Принцип заміщення Ліскова
«Підтипи мають бути замінними на свої базові типи».
У суті цей принцип стверджує:
Якщо
Bє підтипомA, то має бути можливість замінитиBтам, де використовуєтьсяA, і програма має продовжувати працювати коректно.
Класичним прикладом є птахи.
Припустимо, ви визначаєте:
class Bird {
fly() {
console.log("Flying");
}
}
Потім ви його розширюєте:
class Sparrow extends Bird {
fly() {
console.log("Sparrow is flying");
}
}
Поки що все гаразд.
Але що станеться з пінгвіном?
class Penguin extends Bird {
fly() {
throw new Error("Penguins cannot fly");
}
}
Це виявляє недолік у проектуванні.
Якщо інші частини коду припускають, що кожен об’єкт типу Bird може літати, передача об’єкта типу Penguin порушить це припущення.
Більш розумне проектування виводить поведінку польоту у окремий модуль.
class Bird {
eat() {
console.log("Eating");
}
}
class FlyingBird extends Bird {
fly() {
console.log("Flying");
}
}
class Sparrow extends FlyingBird {}
class Penguin extends Bird {}
Таким чином пінгвіни більше не змушені підтримувати поведінку, яка до них не стосується.
Урок
Уникайте створення ієрархій успадкування, які не мають логічного сенсу.
Підклас має правильно працювати в будь-якому місці, де очікується працездатність його батьківського класу.
4. I — Принцип сегрегації інтерфейсів
«Клієнтів не слід змушувати покладатися на методи, які вони не використовують».
Уявіть собі інтерфейс, побудований ось так:
print()
scan()
fax()
copy()
Уявіть собі простий принтер, який може лише друкувати.
Чому цей принтер має обов’язково реалізовувати також функції scan(), fax() та copy()?
Для цього немає жодних вагомих причин.
Кращим підходом є розділення інтерфейсу відповідно до того, що насправді робить кожна з можливостей.
Наприклад:
class Printer {
print() {
console.log("Printing...");
}
}
class Scanner {
scan() {
console.log("Scanning...");
}
}
class FaxMachine {
fax() {
console.log("Faxing...");
}
}
Простий принтер має реалізовувати лише ті функції, які він дійсно підтримує.
У сучасному JavaScript
JavaScript не має формальних інтерфейсів, як Java чи C#, але основна ідея залишається актуальною.
Її можна застосувати через:
- Маленькі модулі
- Короткі API
- Композицію
- Окремі сервіси
- Фокусовані компоненти React
Замість того, щоб об’єднувати все в один величезний сервіс:
userService.getUser();
userService.createUser();
userService.deleteUser();
userService.sendEmail();
userService.generateReport();
Розділіть обов’язки:
userService.getUser();
userService.createUser();
emailService.sendEmail();
reportService.generateReport();
Урок
Ніколи не змушуйте компонент, клас чи модуль покладатися на функціонал, який йому не потрібен.
5. D — Принцип інверсії залежностей
„Модулі високого рівня не повинні безпосередньо залежати від модулів низького рівня. Обидва повинні залежати від абстракцій.“
Цей принцип існує для того, щоб зменшити тісну взаємозалежність.
Розгляньмо цей приклад:
class MongoDB {
save(data) {
console.log("Saving to MongoDB");
}
}
class UserService {
constructor() {
this.database = new MongoDB();
}
saveUser(user) {
this.database.save(user);
}
}
Проблема тут у тому, що UserService безпосередньо підключений до MongoDB.
Якщо згодом перейти на PostgreSQL, доведеться повернутися та змінити сам UserService.
Кращим підходом є введення залежності шляхом ін’єкції.
class UserService {
constructor(database) {
this.database = database;
}
saveUser(user) {
this.database.save(user);
}
}
Тепер можна вільно передавати різні реалізації баз даних.
const mongoDB = new MongoDB();
const userService = new UserService(mongoDB);
А згодом її заміна є дуже простою:
const postgresDB = new PostgreSQL();
const userService = new UserService(postgresDB);
UserService ніколи не потребує знати, яка база даних знаходиться за ним.
Чому це корисно?
Це дозволяє отримати код, який:
- Легше тестувати
- Легше замінювати
- Має меншу залежність
- Легше підтримувати з часом
SOLID у реальному проекті
Дотримання принципів SOLID не означає створення окремого класу для кожної деталі.
Це розрізнення має велике значення.
SOLID — це прийняття обдуманих рішень щодо проектування, а не накопичення абстракцій заради них самих.
У React-додатку, наприклад, ці ідеї природно проявляються, коли ви розділяєте:
Components
↓
Hooks
↓
Services
↓
API Layer
↓
Database
Основне завдання компонента — обробка користувацького інтерфейсу.
Власний хук може містити логіку стану, яку можна використовувати знову.
Сервіс API може керувати комунікацією через HTTP.
Бекенд відповідає за бізнес-логіку.
Шар бази даних відповідає за збереження інформації.
Такий підхід до розділення функцій дозволяє підтримувати прикладне програмування у керованому стані під час його розвитку.
SOLID та React
Хоча принципи SOLID виникли з об’єктно-орієнтованого проектування, багато з їхніх ідей добре підходять для роботи з React.
Єдина відповідальність
Замість створення одного величезного компонента:
Dashboard.jsx
який намагається робити все, розділіть його на:
Dashboard
UserProfile
Statistics
RecentOrders
Notifications
Кожна частина тоді має значно чіткішу функцію.
Відкрито/Закрито
Створюйте компоненти, які можна використовувати знову та знову, і які отримують нову поведінку через props, замість того щоб їхню внутрішню структуру переписувати щоразу, коли потрібна нова версія.
<Button variant="primary">
Save
</Button>
<Button variant="danger">
Delete
</Button>
Інверсія залежностей
Замість прямого пов’язування компонента з певним способом отримання даних, тримайте логіку API всередині сервісу чи хука.
const users = await userService.getUsers();
Сам компонент не повинен знати, як саме виконується цей запит у глибині системи.
Чому важливий принцип SOLID
Фактична користь від принципу SOLID мало пов’язана з тим, щоб код виглядав елегантно.
Йдеться про те, щоб майбутні зміни були менш болісними.
10 розробників → 100 функцій → тисячі файлів → постійні зміни
Без ретельного проектування одна невелика вимога може спричинити ланцюгові проблеми, не пов’язані між собою.
З чітким розділенням обов’язків та слабкою взаємопов’язаністю зміни стають набагато прогнозованішими.
Принцип SOLID може допомогти вам:
- Зменшити дублювання коду
- Знизити рівень тісної взаємопов’язаності
- Покращити можливості тестування
- Упростити розширення функцій
- Спростити відлов помилок
- Покращити співпрацю в команді
- Зберегти великі додатки у стані, придатному для підтримки
SOLID не означає надмірної інженерії
Це, мабуть, найважливіший висновок.
Не застосовуйте принципи SOLID як суворий чек-лист.
Візьмемо просту функцію, наприклад:
function add(a, b) {
return a + b;
}
Їй не потрібні п’ять класів, три інтерфейси та контейнер для впровадження залежностей.
Мета ніколи не полягала у тому, щоб робити код більш складним.
Мета — зробити справді складний код більш зручним для роботи.
Звертайтеся до принципів SOLID лише тоді, коли складність системи справді вимагає додаткової структури.
Короткий огляд
S — Одна відповідальність: клас чи модуль повинен виконувати одну основну функцію.
O — Відкрито/Закрито: розширюйте функціонал, замість того щоб постійно редагувати вже працюючий код.
L — Принцип заміщення Ліскова: підкласи повинні правильно функціонувати там, де очікується батьківський клас.
I — Принцип сегрегації інтерфейсів: не змушуйте клієнтів покладатися на функціонал, який їм не потрібен.
D — Принцип інверсії залежностей: покладайтеся на абстракції, а не на конкретні реалізації.
Заключні міркування
SOLID ніколи не були призначені для запам’ятовування п’яти визначень перед співбесідою.
Це спосіб мислення щодо проектування програмного забезпечення.
Під час написання коду корисно зупинитися та запитати себе:
Чи намагається цей модуль робити занадто багато речей одночасно?
Чи змусить додавання нової функції переписувати існуючий код?
Чи вводяться тут непотрібні залежності?
Чи можна протестувати цей код без труднощів?
Чи примушують щось підтримувати поведінку, яка йому насправді не потрібна?
Розмірковування над цими запитаннями зазвичай мають більше значення, ніж здатність перерахувати, що означає кожна літера принципу SOLID.
Хороший програмний продукт — це не просто той, що зараз працює.
Хороший програмний продукт — це той, який постійно граційно змінюється, а не перетворюється на справжній кошмар.
Пов’язана література
- Коли ШІ пише ваш React-додаток, але ігнорує принципи чистого коду — Дізнайтеся про сім звичок чистого коду — DRY, принцип єдиної відповідальності, захисні клози та інші — які часто порушуються у коді React, створеному ШІ, та як їх виправити.