Рубріки: Теорія

Принципи SOLID в об’єктно-орієнтованому програмуванні

Сергій Бондаренко

Що таке SOLID?

Сьогодні поговоримо про об’єктно-орієнтоване програмування (ООП), а саме — про основні принципи написання коду.

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

Щоб у майбутньому не довелося витрачати бюджет на нескінченні правки, пошуки причин багів та адаптування коду під нові умови роботи, крім основного ТЗ, він має відповідати певним стандартам: як мінімум бути зручним для читання і мати зрозумілу архітектуру.

Всі ці стандарти об’єднані одним словом — SOLID. Що це таке? Слово SOLID англійською означає «твердий» і це дає зрозуміти, що код написаний за всіма правилами: він «твердий» і стійкий до можливих проблем.

Навіщо потрібні принципи SOLID

Рекомендації SOLID сформулювали на початку двохтисячних років як наслідок появи методології об’єктно-орієнтованого програмування (ООП).

Суть ООП у тому, що будь-який програмний код — це концепція взаємодії окремих інформаційних об’єктів. Всі ці об’єкти мають впорядкованість — тобто є екземплярами класу, а ті, у свою чергу, утворюють ієрархію успадкування.

Принцип ООП швидко поширився, тому що програмістам стало набагато легше реалізовувати великі та складні проєкти, моделювати алгоритми та керувати інформацією.

Роберт Мартін, людина, яка сформувала принципи SOLID

Першою правила SOLID сформувала людина на ім’я Роберт Мартін (або «дядько Боб», як він сам себе любить називати).

У сфері розробки програмного забезпечення у Роберта Мартіна колосальний досвід — код він створював ще в далеких 70-х, коли програмування лише зароджувалося. А в 90-х роках він вже мав достатній багаж знань, щоб сформулювати у своїх публікаціях вимоги до «гарного коду».

Термін SOLID — це «винахід» Майкла Фезерса, автора книг з програмування. Він виявив, що якщо зібрати правила «дядька Боба» воєдино, їх великі літери складуть це слово. Підходи до розробки SOLID – це абстрактні сутності, які не прив’язані до конкретної мови програмування. Вони містять такі рекомендації для розробників:

  • S (SRP) Single-responsibility principle (єдина відповідальність). Будь-який клас повинен мати одну зону відповідальності.
  • O Open-closed principle (відкритість і закритість). Класи можна розширювати, але бажано їх не модифікувати. Іншими словами, код, який вже створено, не повинен піддаватися правкам. Розробник має право лише додати щось або виправити виявлені помилки.
  • L (LSP) Liskov substitution principle (правило підстановки Барбари Лісков). Цей принцип найважчий для розуміння та трохи абстрактний. Йдеться про логічність наслідування; про те, що клас-предок можна поміняти на дочірній, не ламаючи логіку роботи програми.
  • I (ISP) Interface segregation principle (розподіл інтерфейсу). Суть цього принципу у перевазі інтерфейсу, спеціально призначеного клієнтам проти єдиного інтерфейса загального призначення всім одразу.
  • D (DIP) Dependency inversion principle (правило інверсії залежностей). Більш високорівневі модулі не повинні залежати від низькорівневих, а в ідеалі вони повинні залежати від деяких абстракцій. Деталі не повинні впливати на абстракції, а радше абстракції мають впливати на деталі.

Так як ці принципи використовуються при проєктуванні реальних додатків, найпростіше їх зрозуміти саме на прикладах. Давайте їх і розглянемо.

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

Зона відповідальності в одного класу має бути єдиною.

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

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

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

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

Але можна було вчинити інакше — готувати код за принципом SRP. Тоді б у вас з самого спочатку був не один клас, а два.

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

O — принцип відкритості/закритості

OCP — це принцип, за якого будь-які програмні сутності мають бути відкриті для розширення, але закриті для внесення будь-яких правок.

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

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

Давайте подивимося на прикладі.

Припустимо, ви створили програмне забезпечення, протестували його і через деякий час поставили за мету розширити його функціональність. У цьому випадку будь-які помилки, які виникнуть у процесі розробки, потрібно буде шукати лише в тій частині коду, який ви додаєте. Зменшується обсяг роботи у тестувальників їм не потрібно щоразу проводити регресійне тестування всього коду.

Реалізувати цей принцип можна двома способами.

Перший варіант пропонує Бертран Мейєр, який і вигадав цей принцип у 1988 році у своїй книзі Object-Oriented Software Construction, а Роберт Мартін лише запозичив його.

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

Як пропонує реалізувати принцип OCP Бертран Мейєр

Але це дещо дивно і незручно, і є висока ймовірність появи побічних проблем (сайд-ефект). Також, якщо деяка клієнтська частина коду, яка раніше зверталася до Code1, тепер звертається до Code2 і отримує новий інтерфейс, її також потрібно оновити.

Другий варіант — поліморфний — вигадав сам «дядько Боб». Він пропонує підійти до питання з іншого боку.

Клієнтський софт повинен залежати від незміненого інтерфейсу. Нова ж реалізація старого коду має використовувати той самий інтерфейс, можливо, делегуючи якимось чином виклик старого коду. Також вона може успадковуватись від нього — у цьому випадку клієнтський код не потрібно переписувати.

Як пропонує реалізувати принцип OCP Роберт Мартін

Трактування Роберта Мартіна є більш логічним, тому в більшості випадків, коли говорять про принцип відкритості/закритості, мають на увазі саме поліморфний підхід. Крег Ларман, який зібрав шаблони GRASP, які активно використовуються сьогодні в ООП, відніс OCP до шаблону Protected Variations — по суті, це те саме. 

Моделюючи роботу клієнтського коду із серверною частиною, теоретично ви можете просто звернутися до якогось класу безпосередньо. Бажання так зробити порушує OCP — розширити таку взаємодію неможливо.

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

L — принцип підстановки Лісков

Це правило — про «правильне успадкування». Його вигадала американська вчена-інформатик Барбара Лісков.

Барбара Лісков, авторка принципу підстановки Лісков

Будь-який об’єкт має тип, тобто клас. У різних об’єктів можуть бути різні типи і можуть належати одному класу об’єктів. Самі класи вишиковуються в ієрархію класів і мають наслідувати функціональність своїх предків.

Підхід Барбари Лісков допомагає виявляти проблемні абстракції та приховані зв’язки між сутностями, робити поведінку модулів передбачуваною та вводити обмеження на спадкування.

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

Перефразовуючи сказане — клас, що успадковується, повинен доповнювати, а не замінювати поведінку базового класу. Сенс у тому, щоб проєктувати логіку так, щоб класи-спадкоємці могли спокійно використовуватись замість батьків. Але в більшості випадків через додаткові перевірки логіки, для обох класів найкраще використовувати спільний інтерфейс, а не успадковувати один клас від іншого.

Найпростіший наочний приклад, який може це продемонструвати, має такий вигляд.

Ми маємо два класи: (1) прямокутник і (2) квадрат. Клас прямокутник приймає два числа — висоту та ширину. Крім того, він містить три методи:

  • завдання висоти;
  • встановлення ширини;
  • та розрахунок площі геометричної фігури.
class Rectangle {
    constructor(public width: number, public height: number) {}
    setWidth(width:number){
        this.width = width;
    }
    setHeight(height:number){
        this.height = height;
    }
    totalareaOf(): number {
        return this.width * this.height
    }
}

class Square extends Rectangle {
    width: number = 0;
    height: number = 0;
    constructor(size: number) {
        super(size, size);
    }
    setWidth(width:number) {
        this.width = width;
        this.height = width;
    }
    setHeight(height:number){
        this.height = height;
        this.width = width;
    }
}

Оскільки ця фігура — ​​окремий випадок фігури Rectangle, він успадковується від відповідного класу та визначає методи.

Перевизначення методів потрібне обов’язково. Якщо цього не зробити, у нас будуть невірно розраховуватися сторони, тому що клас-нащадок братиме методи батьківського класу. В такому разі принцип підстановки Лисков порушується.

Звичайно, ми можемо визначити інстанс (примірник класу) квадрата:

const mySquare = new Square(20); //ширина та висота - 20
mySquare.setWidth(30); //ширина та висота - 30
mySquare.setWidth(40); //ширина та висота - 40

Але коли ми, працюючи з класом Square, будемо використовувати як інтерфейс Rectangle, виникнуть проблеми:

//Як повинно працювати
const changeShapeSize = (figure:Rectangle): void => {
    figure.setWidth(10); //ширина 10, висота - 0
    figure.setHeight(20); //ширина 10, висота - 20
}

//Як це працює
const changeShapeSize = (figure:Rectangle): void => {
    figure.setWidth(10); //ширина 10, висота - 10
    figure.setHeight(20); //ширина 20, висота - 20
}

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

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

I — принцип поділу інтерфейсу

Цей підхід перегукується з першим принципом SOLID – SRP.

При наслідуванні клас-нащадок може отримати купу непотрібної функціональності, яка в ньому не використовується. Щоб уникнути такої проблеми, інтерфейси прийнято декомпозувати.

Як це виглядає практично?

Припустимо, ми маємо справу з інтерфейсом AutoSet, який містить три прості методи для різних автомобілів:

interface AutoSet {
    getMercedesSet(): any;
    getRenaultSet(): any;
    getZaporozhetsSet(): any;
}

Якщо зараз написати клас, наслідуючи інтерфейс, в ньому будуть всі методи, включаючи два непотрібних:

class MercedesImplements AutoSet {
    getMercedesSet(): any { };
    getRenaultSet(): any { };
    getZaporozhetsSet(): any { };
}

class ZaporozhetsImplements AutoSet {
    getMercedesSet(): any { };
    getRenaultSet(): any { };
    getZaporozhetsSet(): any { };
}

class RenaultImplements AutoSet {
    getMercedesSet(): any { };
    getRenaultSet(): any { };
    getZaporozhetsSet(): any { };
}

Додаючи метод, ми повинні будемо вносити зміни до всіх класів-нащадків. Тому буде логічно розділити інтерфейси:

interface MercedesSet {
    getMercedesSet(): any; 
}

interface RenaultSet {
    getRenaultSet(): any;  
}
interface ZaporozhetsSet {
    getZaporozhetsSet(): any;
}

class Mercedes implements MercedesSet {
    getMercedesSet(): any { };
}

class Renault implements RenaultSet {
    getRenaultSet(): any { };
}

class Zaporozhets implements ZaporozhetsSet {
    getZaporozhetsSet(): any { };
}

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

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

Переваги рекомендації ISP:

  • знижує залежність між модулями;
  • при наслідуванні відсутній непотрібний функціонал, який необхідно реалізовувати;
  • у процесі внесення правок зачіпаються лише необхідні частини, а не всі залежні модулі.

D — принцип інверсії залежностей

Ця рекомендація описує найважливіший принцип ООП: модулі верхніх рівнів не повинні залежати від нижніх модулів. Обидва типи модулів мають залежати від абстракцій.

Альтернативний варіант викладу підходу DIP: абстракції не повинні залежати від деталей; деталі мають залежати від абстракцій. Потрібно використовувати всі класи через інтерфейси. 

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

  1. ви можете розірвати цю залежність (розшукуючи у коді, де саме було звернення);
  2. або вбудувати зміни прямо в код сервера.

Код серверного класу і так містить досить важкий функціонал, а додавання нового порушує інший принцип проєктування SRP.

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

Одним словом, щоб усіх цих проблем уникнути — потрібно дотримуватися принципу DIP. 

Висновок

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

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

Цікавий момент: рекомендації SOLID не обмежуються наведеними правилами. Принципів проєктування ПЗ Роберт Мартін виклав набагато більше. Якщо вам це цікаво, інші патерни проєктування ви знайдете у його книзі.

Останні статті

Brave1 збільшив гранти для оборонних розробок: можна отримати до 2 млн гривень

Кластер Brave1 збільшує гранти для оборонних розробок — тепер можна отримати від 500 тис до…

10.05.2024

Softserve, Luxoft та Infopulse. З’явився рейтинг найбільших платників податків серед IT-компаній

За 2023 рік IT-компанії сплатили сплатили в державний бюджет 20,8 мільярда гривень податків. Це 7,4%…

10.05.2024

«За заслуги перед компанією»: Microsoft розморозить підвищення зарплат співробітникам

Корпорація Microsoft планує відновити підвищення зарплат для найбільш ефективних співробітників. Про це повідомив Insider. Вірогідне…

10.05.2024

Мінекономіки запустило пільгові гранти для виробників дронів

Міністерство економіки запропонувало виробникам дронів пільгові гранти від держави за програмою «Переробка». Про це йдеться…

09.05.2024

Дочекалися. В квітні попит на айтівців без досвіду був вищий, ніж на досвідчених фахівців

В квітні попит на недосвідчених айтівців був вищий, аніж на тих, хто має 3-4 роки…

09.05.2024

Dell буде відстежувати переміщення та присвоювати рейтинг «прогульникам» офісу

Американська компанія Dell після зміни політики щодо ремоуту посилює контроль за працівниками. Зокрема, відстежує фізичне…

09.05.2024