Все, що ви хотіли знати про принципи SOLID. Частина п’ята: DIP
Привіт! Мене звати Сергій Немчинський, і я — програміст з понад
Нагадаю, що я не претендую на істину в останній інстанції, тому дуже радію, коли ви зі мною не погоджуєтесь та пропонуєте альтернативні підходи та точки зору. Дякую вам за це, колеги. І продовжую.
Короткий огляд попередніх принципів SOLID
Перш ніж розглянути п’ятий, нагадаю чотири попередні принципи SOLID, які ми обговорювали у попередніх статтях.
SRP — Принцип єдиної відповідальності (Single Responsibility Principle)
Кожен клас повинен мати одну і тільки одну причину для змін. Клас має виконувати одну конкретну задачу.
OCP — Принцип відкритості-закритості (Open/Closed Principle)
Програмні сутності повинні бути відкриті для розширення, але закриті для модифікації. Ми можемо додавати нову функціональність, не змінюючи існуючий код.
LSP — Принцип підстановки Лісков (Liskov Substitution Principle)
Об’єкти в програмі повинні бути замінними на екземпляри їх підтипів без порушення коректності виконання програми.
ISP — Принцип розділення інтерфейсу (Interface Segregation Principle)
Багато спеціалізованих інтерфейсів краще, ніж один інтерфейс загального призначення. Клієнти не повинні залежати від методів, які вони не використовують.
Принцип інверсії залежностей (Dependency Inversion Principle)
Отже, ми підійшли до п’ятої, останньої літери в акронімі SOLID — D. З неї починається Dependency Inversion Principle, або принцип інверсії залежностей — це найважливіший принцип об’єктноорієнтованого програмування, що використовується для зменшення зв’язаності в комп’ютерних програмах.
Формулювання та просте пояснення
Простими словами, принцип інверсії залежностей складається з двох частин:
- Ваш основний код не повинен залежати від конкретних реалізацій. Замість цього і основний код, і конкретні реалізації повинні залежати від інтерфейсів.
- Інтерфейси не повинні підлаштовуватися під конкретні класи. Навпаки, конкретні класи повинні підлаштовуватися під інтерфейси.
Простими словами я можу це пояснити на прикладі керування командою. Ви повинні залежати не від конкретної людини (Петро, Марія), а від ролі (програміст, дизайнер). Якщо Петро звільниться, ви просто знайдете іншого програміста, не змінюючи свій підхід до управління.
Зв’язок з іншими принципами
На відміну від інших принципів SOLID, Роберт Мартін не розглядає DIP як повністю окремий принцип. У статті в журналі C++ Report за травень 1996 року він зазначив, що DIP є наслідком суворого дотримання принципу відкритості-закритості (OCP) та принципу підстановки Лісков (LSP). Але навіть якщо цей принцип не самостійний, а похідний від попередніх, дотримуватись його все одно варто. І до того ж — не псувати ж красивий акронім SOLID?
Проблеми, які вирішує DIP
Принцип інверсії залежностей допомагає уникнути трьох основних проблем поганого коду:
- Жорсткість — змінити один клас неможливо без зміни купи інших класів.
- Крихкість — виправили баг в одному місці, а поламалося щось зовсім інше.
- Нерухомість — неможливо витягти клас і використати в іншому проєкті, бо він намертво зв’язаний з рештою коду.
Наприклад, якщо ваш клас прямо звертається до MySQL бази даних, то при переході на PostgreSQL через жорсткість доведеться міняти весь код. А якщо він, згідно з принципом DIP, працює через інтерфейс типу Database, то вистачить просто підмінити реалізацію.
Як використовувати принцип DIP
Головна рекомендація для дотримання цього принципу проста: використовуйте всі класи через інтерфейси. Про важливість інтерфейсів ми вже говорили раніше. Але принцип DIP похідний від попередніх, тому до цього ми повертаємось знов.
Знов за рибу гроші: чому важливо використовувати інтерфейси
Розглянемо типову ситуацію. У вас є клас Client, який звертається до класу Server напряму. Все працює, але потім виникає потреба додати додаткову функціональність до сервера: авторизацію, логування, кешування, повторні виклики, you name it. Все стандартне, типове, що не має утворювати проблеми на рівному місці. Але ж утворює, якщо ви не користуєтесь принципами SOLID.
При прямому зверненні до класу у вас є лише два варіанти для додавання нового функціоналу. Або розривати залежність — але тоді доведеться шукати по всьому коду місця, де відбувається пряме звернення до класу. Це складне і трудомістке завдання. Або вбудовувати нову функціональність прямо в код сервера. Теж таке собі рішення. Такий підхід порушує принцип єдиної відповідальності та робить код важким і заплутаним.
Але можна уникнути безпосереднього звертання до класу, використавши інтерфейси.

Рішення через інтерфейси
Якщо ви використовуєте інтерфейси, то можете легко додати будь-яку додаткову функціональність за допомогою шаблону Proxy, в три кроки.
- Створюєте новий клас, який реалізує той самий інтерфейс.
- Додаєте в нього потрібну функціональність (авторизацію, логування, кешування).
- Делегуєте виконання основної логіки серверному класу.
Якщо потрібно кілька видів додаткової функціональності, використовуйте шаблон Decorator. Це дозволяє гнучко комбінувати різні види функціональності для різних клієнтів.
Практичне застосування DIP
Пам’ятаєте, в попередніх частинах у нас були уявні друзі-розробники Василь і Петро? Вони працювали над Embedded-системою з датчиками (вони ж давачі, вони ж сенсори). Спочатку датчики передавали лише температуру, потім додалися вимірювання вологості та тиску. Кожен раз коли з’являвся новий тип датчика, доводилося змінювати клас SensorMonitor, додавати залежності, ускладнювати логіку. Отже, високорівневий клас SensorMonitor ставав залежним від кожної конкретної реалізації датчика. Це порушує принцип інверсії залежностей, бо модуль верхнього рівня SensorMonitor напряму залежить від конкретних класів датчиків (модулі нижнього рівня).
Як розв’язати цю проблему, використовучи принцип DIP? Ви переробляєте систему, створивши інтерфейс Sensor. Клас SensorMonitor тепер не знає, з яким саме датчиком він працює, а просто отримує будь-який об’єкт, що реалізує інтерфейс Sensor, і викликає його методи.
Тепер, коли Василь приходить з новим датчиком вологості, ви просто створюєте HumiditySensor, який реалізує інтерфейс Sensor і передає його в SensorMonitor. Сам SensorMonitor змінювати не потрібно.
Коли пізніше приходить Петро з датчиком тиску, відбувається те саме — створюється PressureSensor, який теж реалізує Sensor.
SensorMonitor тепер залежить від абстракції (інтерфейс Sensor), а не від конкретних реалізацій датчиків. Це ідеальний приклад принципу Dependency Inversion: високорівневий модуль не залежить від низькорівневих деталей, обидва залежать від абстракції.

Переваги принципу DIP для тестування
При написанні юніт-тестів вам потрібно буде «замокати» серверний клас. Хоча сучасні фреймворки вміють через рефлексію створювати моки для будь-якого класу, набагато зручніше використовувати Stub замість Mock. Принаймні я обожнюю це робити.
Різниця між Mock і Stub в тому, що Mock — клас з функціональністю, який імітує роботу іншого класу, а Stub — простий клас без функціональності, який повертає статичні дані (константи). Stub-класи можна реалізувати простим написанням implementation інтерфейсу з поверненням констант.
Коли ваш код використовує інтерфейси замість прямих залежностей, тестування стає набагато простішим.
- Легше ізолювати код — підставляєте тестовий EmailSender замість реального.
- Тести працюють швидше — заглушки миттєво повертають дані замість реальних запитів до бази.
- Тести стабільніші — ваш Stub завжди працює, навіть якщо мережа або база недоступна.
- Простіше тестувати помилки — створіть ErrorStub, який завжди кидає виключення.
Отак завдяки DIP ваші тести перетворюються з повільних інтеграційних у швидкі юніт-тести.
А чи не призведе використання інтерфейсів до роздування коду?
Мої студенти регулярно розповідають, що часто керівництво проти використання інтерфейсів. Мовляв, це призведе до створення зайвої кількості класів. Я вважаю, що це помилкове твердження, і ось чому:
- Інтерфейси стабільні — вони не змінюються кожні дві хвилини, на відміну від бізнес-коду.
- Інтерфейси можна організувати — розмістити в окремих пакетах, файлах, namespace.
- Інтерфейси можна генерувати — сучасні IDE легко створюють інтерфейси автоматично.
- Довгострокова економія — час, витрачений на створення інтерфейсів, окупається при першій же потребі розширення функціональності.
Тобто, якщо кожен раз використовувати новий клас, це призведе до розростання коду. А інтерфейси ви написали один раз і вони лежать собі спокійно, допоки вам не знадобляться.
Золота середина між hard та soft кодом
А ще використання DIP допомагає знайти золоту середину між двома крайнощами в програмуванні. Це два полюси, між якими кидає кожного розробника:
- Hard-code — все «прибити цвяхами», написати жорстко в коді.
- Soft-code — зробити все гнучко налаштовуваним, що призводить до жахливого роздування коду і складної конфігурації.
Чесно кажучи, обидва варіанти — лайно. Але це справедливо для будь-яких крайнощів, не лише в програмуванні.
Так от, принцип Dependency Inversion показує нам золоту середину. Використовуючи класи через інтерфейси, ми не потребуємо додаткових конфігурацій, нам не треба все гнучко налаштовувати, можемо в будь-який момент розширити функціональність будь-якої точки системи. І звісно, ми не порушуємо інші принципи SOLID.
Коли можна не використовувати принцип DIP
Ваша улюблена рубрика «Коли можна не робити так, як каже Немчинський». Отже, навіть такий прекрасний принцип як DIP може бути зайвим чи навіть шкідливим у деяких випадках.
Стандартні бібліотеки мови (робота з рядками, колекціями, математичні функції) настільки стабільні, що створювати для них інтерфейси зайво. Класи для валідації, форматування, математичних обчислень зазвичай не змінюються роками. Тому в таких випадках можна не писати інтерфейси.
Коли ви швидко перевіряєте ідею або робите одноразовий скрипт, краще спочатку зробити «щоб працювало», а потім рефакторити. Скрипт на 50 рядків для парсингу одного файлу не потребує архітектурних надлишків. Теж можна обійтись без інтерфейсів.
Ви знаєте, що я — як людина досить лінива — проти зайвої роботи. Отже, якщо у вас є клас логування, який використовується 5 років без змін, можливо, він достатньо стабільний. Створювати для нього інтерфейси «щоб було» — зайвий клопіт.
І не варто заморочуватись інтерфейсами, якщо немає часу. В умовах жорстких дедлайнів іноді краще зробити працююче рішення, ніж ідеальну архітектуру.
Золоті правила використання DIP
Використовуйте DIP, коли є ймовірність зміни реалізації, потрібно тестувати в ізоляції, працюєте в команді або довгостроковому проєкті, залежність може розширюватися.
Не використовуйте, коли код одноразовий або експериментальний, залежність абсолютно стабільна, ускладнення перевищує користь, проєкт дуже простий.
Головне — здоровий глузд і розуміння контексту вашого проєкту.
Висновок
Принцип інверсії залежностей — це потужний інструмент для створення гнучкої та підтримуваної архітектури. Простий підхід «використовуйте всі класи через інтерфейси» дозволяє легко додавати нову функціональність, покращити тестованість коду, зменшити зв’язаність між модулями та знайти оптимальний баланс між жорсткістю та гнучкістю.
Застосовуючи DIP послідовно, разом з іншими принципами SOLID, ви отримуєте код, який легко розширювати, тестувати та підтримувати, що є основою якісної архітектури програмного забезпечення.
Дякую за увагу до цього циклу статей, завжди радий вашим коментарям, і якщо вам щось цікаво по темі принципів SOLID — пишіть.
Сподобалась стаття автора? Підписуйтесь на його акаунт вгорі сторінки, щоб отримувати сповіщення про нові публікації на пошту.

7 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів