top of page

Принципи SOLID — що це? Кейси та поради, як їх застосовувати


princypy-solid-sho-ce-ta-yak-yih-zastosovuvaty

Принципи SOLID та як їх застосувати — те, що питають розробників на більшості співбесід. Це набір правил, який в теорії дозволяє створити відмовостійкий масштабований та легкий у підтримці продукт, код якого буде репрезентативним. Водночас є низка сценаріїв, коли ці принципи не застосовуються та суперечать деяким шаблонам проєктування. Єгор Ілющенко, Back-end Engineer у Legit з екосистеми Genesis, поділився кейсами та прикладами, а також розповів про поширені помилки в інтерпретації цих правил.





Yehor-Ilushchenko-back-end-engineer-legit

Що таке SOLID


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

  • S — принцип єдиної відповідальності (Single Responsibility Principle, SRP). Введений Робертом Мартіном у книзі «Agile Software Development: Principles, Patterns, and Practices» 2002 року. Мартін підкреслив важливість створення класів, які відповідають лише за один напрям дій.

  • O — принцип відкритості/закритості (Open/Closed Principle, OCP). Вперше описаний Бертраном Меєром у книзі «Object-Oriented Software Construction» 1988 року. Він визначив, що класи повинні бути відкритими для розширення, але закритими для модифікації, щоби сприяти гнучкості та легкості розширення коду.

  • L — принцип заміщення Лісков (Liskov Substitution Principle, LSP). Сформульований Барбарою Лісков 1988 року. Її робота «A Behavioral Notion of Subtyping» визначила умови, за якими один об'єкт може замінити інший без змін поведінки програми.

  • I — принцип розділення інтерфейсу (Interface Segregation Principle, ISP). Введений Робертом Мартіном, він розширив ідеї використання малих та специфічних інтерфейсів для зменшення залежностей.

  • D — принцип інверсії залежності (Dependency Inversion Principle, DIP). Сформульований Робертом Мартіном, визначає важливість того, щоби модулі низького рівня не залежали від верхніх модулів, а обидва типи залежали від абстракцій.

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



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


SOLID розширює список базових принципів об'єктно-орієнтованого програмування. «Якщо ООП розповідає нам, що таке класи, і що вони можуть, то SOLID пояснює, яким чином краще їх поєднувати між собою, будувати та компонувати систему, — пояснює Єгор Ілющенко, Back-end Engineer Legit. — Фактично цей набір принципів допомагає інтегрувати принципи ООП, підштовхує розробників до використання абстракцій та правильної побудови залежностей в системі. Набагато простіше працювати з кодом, що підготовлений до розширення і перевикористання загалом, — таким чином нам достатньо фокусуватися на власному коді замість того, щоби розпиляти увагу на вивчення коду, що існує».





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


«Як саме мають бути реалізовані ці принципи, вирішує техлід або СТО проєкту, тому підходи до їхнього застосування різняться. Водночас незалежно від архітектури та інтерпретації, модуль, написаний по SOLID в одній системі, буде не сильно відрізнятися від модуля, написаного за цими принципами в іншій системі, адже обидва будуть наближені до однакової структури якості», — каже Єгор.


Розглянемо кожен принцип детальніше та розберемо приклади.


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



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


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


«Вивчаючи структуру великої системи, ми маємо обмежений контекст сприйняття і бачимо інформацію лише в певному радіусі. Якщо вся логіка буде описана в одному файлі, код займатиме десятки тисяч рядків, і ми нічого не зрозуміємо. Щоби ми могли сприймати систему, ми розбиваємо її на складові — пояснює Єгор. — Про це нам каже SRP. Цей принцип полягає у тому, щоби розділяти елементи системи за зоною їх призначення, щоби один модуль не мав більш ніж однієї зони відповідальності. У такий спосіб їх простіше вивчати, а також, чим менше відповідальностей має модуль, тим менше причин його змінювати, і тим менше потенційних причин його зламати.


Принцип SRP допомагає подолати поширений антипатерн GodObject, коли один об'єкт у системі виконує велику кількість функцій або має занадто велику кількість властивостей. Іноді його називають «Singleton на стероїдах» або «Монолітний обʼєкт». На прикладі нижче — клас, що відповідає одразу за все. Відповідно, зміна в одній з частин класу поставить під загрозу всі інші.


class ResponsibleForEverything {
    public function buildSelf() {
        ///
    }

    public function readDb() {
        ///
    }

    public function writeDb() {
        ///
    }

    public function mapObjects() {
        ///
    }
}

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


class ResponsibleForBuild {
    public function buildSelf() {
        ///
    }
}

class RespnsibleForDb {
    public function readDb() {
        ///
    }

    public function writeDb() {
        ///
    }
}

class ResponsibleForMail {
    public function sendMail() {
        ///
    }
}

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

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


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


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


«Open/Closed — досить неоднозначний принцип, багато розробників неправильно інтерпретують його. Коли я вивчав ці принципи, не розумів, як саме закрити систему від змін і думав, що суть в тому, щоб закрити поля модифікатором private. Насправді ж його суть полягає у правильній побудові модулів, щоби розширення логіки не вимагало переписування старого коду», — каже Єгор.


Приклад порушення принципу Open/Closed:


class ClosedForExtensionChangesIsOnlyOptionToEdit {
    public function getStats($userId) {
        $stats = [];

        if ($userId) {
            $mysqlStat = getUserStatsFromDb($userId);
            $stats[] = $mysqlStat;
        }

        return $stats;
    }
}

Тепер уявімо, що нам треба додати до статистики дані з thirdParty ресурсів.


class ClosedForExtensionChangesIsOnlyOptionToEdit {
    public function getStats($userId) {
        $stats = [];

        if ($userId) {
            $mysqlStat = getUserStatsFromDb($userId);
            $stats['mysqlStat'] = $mysqlStat;
            
            $user = getUserFromDb();
            if ($user->thirdPartyId) {
                $thirdPartyStat['thirdPartyStat'] = getUserStatFromThirdParty($user->thirdPartyId); 
            }
        }

        return $stats;
    }
}

У такій реалізації щоразу, коли потрібно розширити логіку, ми маємо лізти в оригінальний клас, розбиратися в ньому і вносити зміни. Як це «лікується»:


Interface UserStatScraperStrategy {
    public function getStatKey();
    public function getStat($userId);
}

class MysqlUserStatScraperStrategy implements UserStatScraperStrategy {
    public function getStatKey()
    {
        return 'mySqlStat';
    }

    public function getStat($userId)
    {
        return getUserStatsFromDb($userId);
    }
}

class ThirdPartyUserStatScraperStrategy implements UserStatScraperStrategy {
    public function getStatKey()
    {
        return 'thirdPartyStat';
    }

    public function getStat($userId)
    {
        return getUserStatFromThirdParty($userId);
    }
}

class OpenForExtension {
    public function __construct(
        private $statScrapingStrategies,
    ) {}

    public function getStats($userId) {
        $stats = [];

        foreach ($this->statScrapingStrategies as $strategy) {
            $stats[$strategy->getStatKey()] = $strategy->getStat($userId);
        }

        return $stats;
    }
}

new OpenForExtension([
    $mysqlUserStatScraperStrategy,
    $thirdPartyUserStatScraperStrategy
])->getStat(1);

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


Interface UserStatScraperStrategy {
    public function getStatKey();
    public function getStat($userId);
}

class MysqlUserStatScraperStrategy implements UserStatScraperStrategy {

    public function getStatKey()
    {
        return 'mySqlStat';
    }

    public function getStat($userId)
    {
        return getUserStatsFromDb($userId);
    }
}

class ThirdPartyUserStatScraperStrategy implements UserStatScraperStrategy {
    public function getStatKey()
    {
        return 'thirdPartyStat';
    }

    public function getStat($userId)
    {
        return getUserStatFromThirdParty($userId);
    }
}

class MongoUserStatScraperStrategy implements UserStatScraperStrategy {
    public function getStatKey()
    {
        return 'mongoStat';
    }

    public function getStat($userId)
    {
        return getUserStatFromMongo($userId);
    }
}

class OpenForExtension {
    public function __construct(
        private $statScrapingStrategies,
    ) {}

    public function getStats($userId) {
        $stats = [];

        foreach ($this->statScrapingStrategies as $strategy) {
            $stats[$strategy->getStatKey()] = $strategy->getStat($userId);
        }

        return $stats;
    }
}

new OpenForExtension([
    $mysqlUserStatScraperStrategy,
    $thirdPartyUserStatScraperStrategy,
    $mongoUserStatScraperStrategy
])->getStat(1);

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



Принцип заміщення Лісков (LSP)


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


«Суть цього принципу не в тому, що ми можемо замінити батьківський клас дочірнім, а в тому, яким вимогам повинні відповідати дочірні класи. Якщо «батько» виконує функцію певним чином, то будь-яка «дитина» має зважати на це і не перекривати стару функціональність новою логікою. Наприклад, якщо у нас є базовий клас сповіщень. Він записує файл в системі, фіксує згадку в лог, що було відправлено повідомлення, а потім відправляє його, наприклад, на пошту. Якщо ми створюємо дочірній клас, в якому реалізується така ж функція відправки, але без логів. Виходить, що ми порушуємо принцип Лісков, адже підставляючи «дитину» на місце батька, ми втрачаємо логіку логування» — пояснює Єгор Ілющенко.


Наприклад, у нас є код:


class OldParent {
    
    public function notifySubscribers() {
        //log time
        //get subscribers
        //notify subscribers via email
    } 
    protected function getSubscriberIds() {
        //giveIds    
    }
}

Все було добре, але раптом нас попросили зробити кнопку, яка замість імейла відправить смс-повідомлення.


class NewChild extends OldParent {
    public function notifySubscribers() {
        //get subscribers from parent
        //notify subscribers via sms
    }
}

Через місяць до нас приходить продакт-менеджер і каже, що бюджет на відправку смс-повідомлень раптово витратився, і треба дізнатись, чому так сталося. Щоби визначитись, де все пішло не так, ми дивимося логи, але ми не бачимо жодної згадки про відправку смс. Здогадуєтеся, чому? Саме через порушення принципу Лісков: ми замінили стару реалізацію новою, забувши проконтролювати відповідність нової логіки до стандартів старої. Всі підтипи базового класу повинні бути взаємозамінними з «дітьми» без ризиків і необхідності поглиблення в деталі — про це і каже правило Лісков.


Правильний клас виглядав би так:


class NewChild extends OldParent {
    public function notifySubscribers() {
        //log time
        //get subscribers from parent
        //notify subscribers via sms
    }
}

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



Принцип розділення інтерфейсу (ISP)


«Залежність від багажу, який вам не потрібен, може спричинити проблеми, яких ви не очікували», — пише Роберт Мартін, описуючи цей принцип. Він вчить, що обʼєкти не повинні залежати від методів інтерфейсів, які вони не використовують.


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


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


princypy-solid-sho-ce-ta-yak-yih-zastosovuvaty-big-interfaces-are-bad


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


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


Розглянемо приклад:


class IWorkWithThirdParty {}
class MysqlSomething {

    public function __construct(public IWorkWithThirdParty $view) {}
    public function checkData() {
        //get3party data
        //readDb
        //give result
    }

}

class Command {
    public function __construct(private MysqlSomething $mysqlSomething) {}
    public function __invoke() {
        return $this->mysqlSomething->getData();
    }
}

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


interface PassCheck {
    public function passCheck();
}

interface GetIds {
    public function getIds();
}

class IWorkWithThirdParty implements GetIds {
    public function getIds() {
        //
    }
}
class MysqlSomething implements getIds {
    public function getIds() {
        //
    }
}

class ExternalDataConsistencyChecker implements PassCheck {

    public function __construct(private array $idSources) {}
    public function check() {
        $idVariations = [];

        foreach ($this->idSources as $idSource) {
            $idVariations[] = $idSource->getIds();
        }

        if (VARIATIONS_IS_NOT_EQUAL) {
            return false;
        }
        return true;
    }
}

class SyncExternalDataCommand {
    public function __construct(private PassCheck $idConsistencyCheck) {}
    public function __invoke() {
        if (!$idConsistencyCheck->passCheck()) {
            //pass check   
        }
    }
}

Тепер всі модулі залежать тільки від абстракцій. Вся логіка, що не стосується бази, винесена нагору в сервісний рівень. Ієрархія залежностей від абстракцій вибудована коректно: згори вниз.



Як засвоїти принципи SOLID


Початківці, які тільки починають вивчати принципи SOLID, можуть зіткнутися з низкою проблем.

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

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

  3. Внесення змін. Якщо значний обсяг кодової бази вже написаний без урахування SOLID, застосування цих принципів може викликати суперечності зі структурами, що існують.

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

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


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

Є низка шаблонів проєктування, з якими важко дотримуватися принципів SOLID: Singleton, Active Record, Template Method, Multiton, Builder, Facade. Деякі з них порушують принципи SRP, ISP та LSP. Проте в деяких сценаріях їхнє використання цілком обґрунтоване, а комбінація різних шаблонів може сприяти більш сучасній та підтримуваній архітектурі.

Підписуйся на нашу розсилку та отримуй корисні матеріали першим!

Надаючи вашу електронну адресу, ви погоджуєтесь з нашою Політикою приватності.

Дякуємо, що підписалися.

image-from-rawpixel-id-5996033-png.png
bottom of page