Принципи SOLID
SOLID — це набір з п'яти принципів об'єктно-орієнтованого дизайну, які допомагають писати код, що легко підтримується, тестується та розширюється. Ці принципи були сформульовані Робертом С. Мартіном, також відомим як "Дядя Боб".
Принцип єдиного обов'язку (SRP)
Принцип єдиного обов'язку
Кожен клас або модуль повинен мати єдиний обов'язок і, відповідно, єдину причину для змін.
Чому?
- Спрощує розуміння коду, оскільки клас/модуль відповідає за одне завдання.
- Якщо вимоги змінюються, виправлення вносяться в строго визначене місце.
Приклад (JS)
// Погано: клас виконує занадто багато дій — зберігає дані та веде журнал
class UserService {
saveUser(user) {
// ...код для збереження в базі даних
Logger.log(`User ${user.name} saved`);
}
}
// Краще: виділити логіку ведення журналу в окремий клас/сервіс
class UserService {
constructor(logger) {
this.logger = logger;
}
saveUser(user) {
// ...код для збереження в базі даних
this.logger.log(`User ${user.name} saved`);
}
}
class Logger {
log(message) {
console.log(`[LOG]: ${message}`);
}
}Принцип відкритості/закритості (OCP)
Принцип відкритості/закритості
Класи та модулі повинні бути відкриті для розширення, але закриті для модифікації.
Простими словами — модулі повинні бути спроектовані так, щоб їх потрібно було змінювати якомога рідше, а функціональність можна було розширити, створюючи нові сутності та комбінуючи їх зі старими.
Чому?
- Дозволяє додавати нову функціональність без зміни вже існуючого коду.
- Зменшує ризик внесення помилок у стабільні частини системи.
// Погано: щоразу, коли ми додаємо нову умову в код
function getDiscount(user) {
if (user.type === 'regular') {
return 5;
}
if (user.type === 'vip') {
return 10;
}
// ...
}
// Краще
class RegularUser {
getDiscount() {
return 5;
}
}
class VipUser {
getDiscount() {
return 10;
}
}
function showDiscount(user) {
// Не змінюємо логіку, просто викликаємо метод
return user.getDiscount();
}Тут ми можемо легко додати нового користувача, реалізуючи їх клас з власним методом getDiscount. Водночас функцію showDiscount не потрібно змінювати.
Принцип підстановки Ліскова (LSP)
Принцип підстановки Ліскова
Дочірні класи повинні бути повністю замінними на свої батьківські класи. Якщо десь очікується об'єкт батьківського типу, ми повинні мати можливість замінити об'єкт дочірнього типу без порушення програми.
Простими словами — дочірні класи не повинні суперечити базовому класу. Наприклад, вони не можуть надавати інтерфейс, який є вужчим, ніж базовий. Поведінка дочірнього класу повинна бути очікуваною для функцій, які використовують базовий клас.
Чому?
- Забезпечує правильну та передбачувану поведінку при використанні наслідування.
- Допомагає уникнути помилок, пов'язаних з несподіваною логікою в дочірніх класах.
// Погано: Квадрат порушує поведінку Прямокутника
class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
setWidth(width) {
this.width = width;
}
setHeight(height) {
this.height = height;
}
getArea() {
return this.width * this.height;
}
}
class Square extends Rectangle {
// Порушує LSP: при зміні ширини або висоти
// інший вимір також повинен змінюватися, але це порушує логіку батька
setWidth(width) {
this.width = width;
this.height = width;
}
setHeight(height) {
this.width = height;
this.height = height;
}
}У цьому випадку, якщо ми очікуємо Прямокутник у коді та викликаємо методи setWidth і setHeight, для Квадрата ми отримаємо поведінку, яка не відповідає тому, що передбачає базовий клас (квадрат завжди має ширину, рівну висоті).
Краще — не наслідувати Square від Rectangle, а або використовувати спільний абстрактний клас (наприклад, Shape), або застосувати композицію. Таким чином, ми уникаємо порушення базової логіки з допомогою наслідування.
Принцип сегрегації інтерфейсу (ISP)
Принцип сегрегації інтерфейсу
Клієнти (класи, модулі) не повинні залежати від інтерфейсів (або контрактів), які вони не використовують. Якщо інтерфейс "товстий" і містить занадто багато методів, краще розділити його на кілька спеціалізованих інтерфейсів.
Чому?
- Уникає "захаращення" контрактів методами, які не потрібні в кожному місці.
- Робить код більш гнучким і зрозумілим: кожна частина програми залежить лише від того, що дійсно важливо для неї.
// Погано: один клас, що містить всі методи
// (наприклад, Тварина з eat, walk, swim, fly)
class Animal {
eat() {}
walk() {}
swim() {}
fly() {}
}
// Клас Собака може не плавати, а клас Птиця може не ходити по землі
// тощо - ми отримуємо порожні або невикористовувані методи.
// Краще: розділити обов'язки
class Eatable {
eat() {}
}
class Walkable {
walk() {}
}
class Flyable {
fly() {}
}
// Тепер класи, яким потрібно літати, просто розширюють Flyable,
// а ті, яким потрібно ходити — Walkable, і так далі
class Dog extends Eatable {
// Додатково, якщо хочемо, можемо успадкувати Собаку від Walkable
// але не додавати методи Flyable та Swimable, які їй не потрібні
}Принцип інверсії залежностей (DIP)
Принцип інверсії залежностей
Залежності повинні будуватися на рівні абстракції, а не конкретних реалізацій. Модулі високого рівня не повинні залежати від модулів низького рівня безпосередньо — обидва типи модулів залежать від абстракцій.
Чому?
- Спрощує заміну реалізацій (наприклад, робота з різними базами даних).
- Поліпшує тестованість: можна легко замінити залежності (наприклад, на моки).
// Погано: клас безпосередньо залежить від конкретної реалізації
class UserService {
constructor() {
this.db = new MySQLDatabase();
}
getUsers() {
return this.db.query('SELECT * FROM users');
}
}
// Краще: через абстракцію (інтерфейс/контракт)
class UserService {
constructor(database) {
// database — це абстрактний контракт, який повинен мати можливість .query()
this.database = database;
}
getUsers() {
return this.database.query('SELECT * FROM users');
}
}
// Тепер ми можемо замінити конкретну реалізацію
class MySQLDatabase {
query(sql) {
// Запит до MySQL
}
}
class MongoDatabase {
query(sql) {
// Запит до Mongo
}
}
// У різних середовищах/додатках
// ми можемо передати потрібну реалізацію в конструктор UserService
const userService = new UserService(new MySQLDatabase());Тут UserService не знає деталей конкретної реалізації бази даних, він працює через абстракцію, надану класами MySQLDatabase або MongoDatabase.
Висновок
- SRP – Один модуль, один обов'язок.
- OCP – Відкритий для розширення, закритий для модифікації.
- LSP – Діти повинні бути замінними на батьківські типи без проблем.
- ISP – Розділіть "товсті" інтерфейси, щоб залежності були мінімальними.
- DIP – Залежіть від абстракцій, а не від конкретних реалізацій.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.