SOLID простими словами

SOLID простими словами

  • 23 січня
  • читати 10 хв
Олександра Донцова
Олександра Донцова Front-end Developer у Sigma Software, Викладач Комп'ютерної школи Hillel.

SOLID — це абревіатура, яка описує п'ять основних принципів об'єктно-орієнтованого програмування та проєктування. Вони були сформульовані Робертом Мартіном і спрямовані на підвищення гнучкості, читабельності та підтримуваності коду.

Ось короткий огляд кожного принципу:

  1. Single Responsibility Principle (Принцип єдиної відповідальності): Кожен клас повинен мати тільки одну причину для зміни, що означає, що клас має виконувати лише одну задачу.
  2. Open/Closed Principle (Принцип відкритості/закритості): Програмні сутності (класи, модулі, функції тощо) повинні бути відкритими для розширення, але закритими для змін. Це означає, що ви можете додавати нові функції без зміни існуючого коду.
  3. Liskov Substitution Principle (Принцип підстановки Лісков): Об'єкти повинні бути замінювані їхніми підтипами без зміни правильності програми. Тобто, підкласи повинні бути замінними на їхні батьківські класи.
  4. Interface Segregation Principle (Принцип сегрегації інтерфейсу): Клієнти не повинні бути змушені залежати від інтерфейсів, які вони не використовують. Це стимулює створення вузьких інтерфейсів, які більш конкретно відповідають потребам клієнтів.
    Dependency Inversion Principle (Принцип інверсії залежностей): Модулі високого рівня не повинні залежати від модулів низького рівня. Обидва типи модулів повинні залежати від абстракцій.

Рекомендуємо публікацію по темі

Важливість SOLID для Front-end Розробників

  • Підтримуваність та розширюваність: Front-end проєкти часто швидко зростають та еволюціонують, особливо у складних додатках. Дотримуючись SOLID, розробники можуть легше адаптувати та розширювати свій код без значних переписувань.
  • Зменшення складності: Сучасні вебдодатки можуть бути дуже складними. SOLID допомагає управляти цією складністю, розділяючи код на менші, більш керовані частини.
  • Сприяння командній роботі: Коли проєкт дотримується чітко визначених принципів, нові члени команди можуть швидше розібратися в кодовій базі, а спільна робота стає ефективнішою.
  • Підвищення якості коду: SOLID сприяє написанню чистішого, більш організованого та тестованого коду, що важливо для стабільності та надійності веб-додатків.
  • Адаптивність до змін: В умовах постійної зміни технологій та вимог користувачів, дотримання SOLID дозволяє легше адаптуватися до нових вимог без повного перепроєктування системи.

Принцип єдиної відповідальності

Принцип єдиної відповідальності — один із ключових принципів у SOLID, який стверджує, що кожен клас або модуль у програмному коді повинен мати лише одну причину для зміни. Іншими словами, це означає, що кожен клас має відповідати тільки за одну область функціональності і мати тільки одну задачу або відповідальність. Цей принцип допомагає уникнути «божественних об'єктів» — класів, які намагаються робити занадто багато речей одночасно, що веде до складності та ускладнює підтримку коду.

Приклади:

  • Компоненти UI: Уявіть, що ви створюєте компонент для вебдодатку, наприклад, `UserProfile`. Якщо цей компонент відповідає і за відображення профілю користувача, і за отримання даних з сервера, і за валідацію даних, він порушує SRP. Замість цього ви повинні розділити ці завдання: один компонент (`UserProfile`) для відображення інтерфейсу, інший модуль (наприклад, `UserAPI`) для управління запитами на сервер та третій (наприклад, `UserValidation`) для валідації даних.
  • Функції утиліт: Розглянемо функцію, яка обчислює та форматує дату. Якщо ця функція і обчислює дату (наприклад, додає дні до поточної дати), і форматує її для відображення (наприклад, перетворює у формат «дд-мм-рррр»), то вона порушує SRP. Краще мати одну функцію для обчислення дати та іншу для її форматування.
  • Реакція на події (Event Handling): Припустимо, що ви маєте кнопку, яка виконує дві різні функції: збереження даних форми та її закриття. Це порушення SRP, оскільки обробник подій кнопки має дві причини для зміни (збереження даних і закриття форми). Краще розділити ці функції на два окремі обробники подій.
function handleSubmit(formData) {
  if (validateFormData(formData)) {
    saveFormData(formData);
    displaySuccessMessage();
  } else {
    displayErrorMessage();
  }
}


function validateFormData(formData) {
  // Код для валідації даних форми
}


function saveFormData(formData) {
  // Код для збереження даних форми
}


function displaySuccessMessage() {
  alert('Дані успішно збережено!');
}


function displayErrorMessage() {
  alert('Помилка у введених даних!');
}

Принцип відкритості/закритості (OCP)

Принцип відкритості/закритості заснований на ідеї, що класи повинні бути відкритими для розширення, але закритими для модифікації. Це означає, що поведінка класу може бути змінена без зміни його вихідного коду, шляхом додавання нового коду, а не модифікації існуючого.

Уявімо, що ми розробляємо вебдодаток, де потрібно відображати різні типи повідомлень користувачам. Ми можемо створити базовий клас для повідомлення та розширити його для конкретних типів повідомлень.

Перед зміною (без застосування OCP):

class Alert {
  constructor(message) {
      this.message = message;
  }


  display() {
      // Логіка відображення звичайного повідомлення
      console.log(`Alert: ${this.message}`);
  }
}


// Використання
const alert = new Alert("Помилка!");
alert.display();

У цьому випадку, якщо нам потрібно додати інші типи сповіщень (наприклад, успіх, попередження), нам доведеться змінювати клас `Alert`.

Після зміни (з застосуванням OCP):

class Alert {
  constructor(message) {
      this.message = message;
  }


  display() {
      // Базова логіка відображення повідомлення
      console.log(this.message);
  }
}


class SuccessAlert extends Alert {
  display() {
      // Логіка відображення повідомлення про успіх
      console.log(`Success: ${this.message}`);
  }
}


class ErrorAlert extends Alert {
  display() {
      // Логіка відображення повідомлення про помилку
      console.log(`Error: ${this.message}`);
  }
}


// Використання
const successAlert = new SuccessAlert("Операція успішна!");
const errorAlert = new ErrorAlert("Помилка при операції!");


successAlert.display();
errorAlert.display();

У цьому прикладі базовий клас `Alert` залишається незмінним, а функціональність для різних типів повідомлень реалізована у підкласах. Таким чином, якщо нам потрібно додати новий тип повідомлення, ми можемо просто створити новий підклас без зміни існуючого коду.

Принцип підстановки Лісков (LSP)

Принцип підстановки Лісков стверджує, що якщо клас S є підтипом класу T, тоді об'єкти типу T можна замінити об'єктами типу S без зміни бажаної правильності виконання програми. Це означає, що підкласи повинні бути замінними на їхні батьківські класи.

Цей принцип особливо важливий в поліморфізмі та спадкуванні. У Front-end розробці, де ми часто працюємо з компонентними ієрархіями (наприклад, у React або Vue), це означає, що компоненти-нащадки повинні мати можливість замінити батьківські компоненти без порушення функціональності.

Уявімо, що ми розробляємо додаток, де маємо базовий клас `Button`, та різні спеціалізовані кнопки, такі як `IconButton` або `SubmitButton`.

Без застосування LSP:

class Button {
  click() {
      console.log('Клік по кнопці');
  }
}


class IconButton extends Button {
  click() {
      throw new Error("Ця кнопка не підтримує клік");
  }


  // специфічна логіка для IconButton
  iconClick() {
      console.log('Клік по іконці');
  }
}


function performClick(button) {
  button.click();
}


const regularButton = new Button();
const iconButton = new IconButton();


performClick(regularButton); // працює правильно
performClick(iconButton); // викине виняток

У цьому прикладі, коли ми намагаємося використати `IconButton` замість `Button`, ми отримуємо помилку, що є порушенням LSP.

З застосуванням LSP:

class Button {
  click() {
      console.log('Клік по кнопці');
  }
}


class IconButton extends Button {
  // IconButton розширює функціональність, але не змінює базову поведінку
  iconClick() {
      console.log('Клік по іконці');
  }
}


function performClick(button) {
  button.click();
}


const regularButton = new Button();
const iconButton = new IconButton();


performClick(regularButton); // працює правильно
performClick(iconButton); // також працює правильно

У цьому прикладі `IconButton` розширює функціональність `Button`, але не змінює його базової поведінки, тому `IconButton` може безпечно замінювати `Button` без виникнення помилок.

Використання LSP дозволяє створювати більш гнучкі та масштабовані ієрархії класів, які легко підтримувати та розширювати.

Принцип сегрегації інтерфейсу (ISP)

Принцип сегрегації інтерфейсу заснований на ідеї, що клієнти (у цьому контексті, об'єкти, які використовують інші об'єкти) не повинні бути змушені залежати від інтерфейсів, які вони не використовують. Це означає, що великі інтерфейси слід розбивати на менші та більш специфічні, щоб класи, які використовують ці інтерфейси, не мали непотрібних залежностей.

ISP допомагає створити модульну архітектуру в програмах, де кожен модуль або компонент має вузько визначену відповідальність. Це сприяє легшому управлінню залежностями, спрощує тестування та підтримку, а також підвищує перевикористання коду.

Перед застосуванням ISP:

// Більший інтерфейс, який визначає методи для різних типів дій
interface UserActions {
  createPost(content: string): void;
  sendMessage(user: User, message: string): void;
  createSchedule(date: Date, task: string): void;
}


class User implements UserActions {
  createPost(content: string) {
      // Логіка створення посту
  }


  sendMessage(user: User, message: string) {
      // Логіка надсилання повідомлення
  }


  createSchedule(date: Date, task: string) {
      // Логіка створення розкладу
  }
}


// У цьому випадку, клас User змушений реалізовувати всі методи, хоча деякі з них можуть бути йому не потрібні

Після застосування ISP:

// Розділені інтерфейси для кожної конкретної дії
interface PostCreator {
  createPost(content: string): void;
}


interface MessageSender {
  sendMessage(user: User, message: string): void;
}


interface ScheduleCreator {
  createSchedule(date: Date, task: string): void;
}


class User implements PostCreator, MessageSender {
  createPost(content: string) {
      // Логіка створення посту
  }


  sendMessage(user: User, message: string) {
      // Логіка надсилання повідомлення
  }


  // Метод createSchedule видалений, оскільки він не потрібен для класу User
}

У цьому прикладі, після розділення великого інтерфейсу `UserActions` на декілька менших (`PostCreator`, `MessageSender`, `ScheduleCreator`), клас `User` тепер може вибірково реалізовувати тільки ті інтерфейси, які йому потрібні. Це зменшує непотрібні залежності і робить код більш модульним та гнучким.

Принцип інверсії залежностей (DIP)

Принцип інверсії залежностей вказує, що модулі високого рівня (ті, що виконують ключові бізнес-операції) не повинні залежати від модулів низького рівня (ті, що виконують базові операції, як-от доступ до даних). Натомість, обидва типи модулів повинні залежати від абстракцій (інтерфейсів або абстрактних класів).

DIP дозволяє створити більш гнучкі, легкі для тестування та підтримки Front-end системи, особливо важливі при роботі з великими додатками та фреймворками, такими як React, Angular або Vue.

Уявімо, що ми розробляємо вебдодаток, де маємо компонент `UserProfile`, який відображає інформацію про користувача. Дані про користувача завантажуються з сервера.

Без застосування DIP:

// Клас для отримання даних користувача з сервера
class UserApi {
  getUser(userId) {
      // Логіка отримання даних користувача
  }
}


// Компонент високого рівня, який залежить від конкретної реалізації UserApi
class UserProfile {
  constructor(userId, api) {
      this.userId = userId;
      this.api = api;
  }


  showProfile() {
      const userData = this.api.getUser(this.userId);
      // Відображення даних користувача
  }
}


const api = new UserApi();
const userProfile = new UserProfile(1, api);
userProfile.showProfile();

У цьому випадку `UserProfile` прямо залежить від конкретної реалізації `UserApi`, що ускладнює тестування та рефакторинг.

З застосуванням DIP:

// Абстракція, від якої залежить і компонент, і модуль доступу до даних
interface UserDataSource {
  getUser(userId): User;
}


// Реалізація інтерфейсу для отримання даних користувача з сервера
class UserApi implements UserDataSource {
  getUser(userId) {
      // Логіка отримання даних користувача
  }
}


// Компонент, який залежить від абстракції
class UserProfile {
  constructor(userId, dataSource) {
      this.userId = userId;
      this.dataSource = dataSource;
  }


  showProfile() {
      const userData = this.dataSource.getUser(this.userId);
      // Відображення даних користувача
  }
}


const api = new UserApi();
const userProfile = new UserProfile(1, api);
userProfile.showProfile();

Тут `UserProfile` та `UserApi` залежать від абстракції `UserDataSource`. Це дозволяє легко замінити реалізацію отримання даних без зміни `UserProfile`, спрощуючи тестування та розширення функціональності.

SOLID принципи формують фундамент для створення міцної архітектури програмного забезпечення, особливо у сфері Front-end розробки. Ці принципи спрямовані на підвищення гнучкості, підтримуваності та масштабованості коду, що є критично важливим у швидкозмінному світі сучасних вебтехнологій.

Ці принципи, якщо їх правильно застосовувати, можуть значно поліпшити якість коду, спрощувати управління проєктами, підвищувати продуктивність розробників та полегшувати масштабування продуктів.

У світі, де технології та вимоги постійно змінюються, важливо, щоб Front-end розробники приймали та використовували SOLID принципи у своїй роботі. Це дозволить створювати більш адаптивні, стабільні вебдодатки, які легко підтримувати. Використання цих принципів в робочих процесах не тільки підвищить ефективність та якість коду, але й зробить розробку більш приємною..

Тому, як Front-end розробники, ми повинні прагнути до включення цих принципів у наші щоденні практики кодування, адже вони є фундаментом для створення високоякісних, ефективних та тривалих вебдодатків.

Рекомендуємо курс по темі