Чим складніший код, тим ретельніше потрібно ставитись до його архітектури. Існує думка, що якщо код програми справляється з поставленим перед ним завданням — він вже є якісним. Це не так.
Щоб у майбутньому не довелося витрачати бюджет на нескінченні правки, пошуки причин багів та адаптування коду під нові умови роботи, крім основного ТЗ, він має відповідати певним стандартам: як мінімум бути зручним для читання і мати зрозумілу архітектуру.
Всі ці стандарти об’єднані одним словом — SOLID. Що це таке? Слово SOLID англійською означає «твердий» і це дає зрозуміти, що код написаний за всіма правилами: він «твердий» і стійкий до можливих проблем.
Рекомендації SOLID сформулювали на початку двохтисячних років як наслідок появи методології об’єктно-орієнтованого програмування (ООП).
Суть ООП у тому, що будь-який програмний код — це концепція взаємодії окремих інформаційних об’єктів. Всі ці об’єкти мають впорядкованість — тобто є екземплярами класу, а ті, у свою чергу, утворюють ієрархію успадкування.
Принцип ООП швидко поширився, тому що програмістам стало набагато легше реалізовувати великі та складні проєкти, моделювати алгоритми та керувати інформацією.
Роберт Мартін, людина, яка сформувала принципи SOLID
Першою правила SOLID сформувала людина на ім’я Роберт Мартін (або «дядько Боб», як він сам себе любить називати).
У сфері розробки програмного забезпечення у Роберта Мартіна колосальний досвід — код він створював ще в далеких 70-х, коли програмування лише зароджувалося. А в 90-х роках він вже мав достатній багаж знань, щоб сформулювати у своїх публікаціях вимоги до «гарного коду».
Термін SOLID — це «винахід» Майкла Фезерса, автора книг з програмування. Він виявив, що якщо зібрати правила «дядька Боба» воєдино, їх великі літери складуть це слово.
Так як ці принципи використовуються при проєктуванні реальних додатків, найпростіше їх зрозуміти саме на прикладах. Давайте їх і розглянемо.
Зона відповідальності в одного класу має бути єдиною.
Наприклад, якщо у коді класу є два методи, один із яких готує та форматує дані, а інший записує, то такий клас порушує принцип SRP. Його потрібно розбити на два класи, кожен з яких виконує свою функцію.
Ви створюєте клас, який реалізує взаємодію як із температурним датчиком, так і з датчиком вологості. До вас приходить колега, який працює над програмним забезпеченням для термостабілізації насосів. Він просить вас йому допомогти і ви, добра душа, ділитеся з ним своїм класом. Він його редагує, відбираючи для свого коду потрібну частину класу.
Тим часом до вас зі схожою проблемою звертається інший колега і просить ваш клас для реалізації свого ПЗ, яке стежить за рівнем вологості в теплиці. Він також редагує код, забираючи потрібний фрагмент для своїх потреб.
Тут раптово ви виявляєте помилку у своєму класі. Що ж робити? Можна, звичайно, звернутися до своїх колег і повідомити, що ви переробили свій клас, а вони тоді знову перероблятимуть його під свої потреби.
Але можна було вчинити інакше — готувати код за принципом SRP. Тоді б у вас з самого спочатку був не один клас, а два.
У цьому випадку подібних проблем взагалі не виникне — першому колезі ви надасте клас із функціональністю по датчику вологості, а другому — частину коду з підтримкою термодатчика. А якщо в якомусь із класів виявиться помилка, її можна буде швидко виправити.
OCP — це принцип, за якого будь-які програмні сутності мають бути відкриті для розширення, але закриті для внесення будь-яких правок.
Це означає, що будь-який клас, будь-який метод, а також будь-який блок програмного коду має бути відкритим для додавання додаткової функціональності.
Додавати функціональність, не змінюючи існуючої — ідея, здавалося б, спірна. Але зміст цього принципу зводиться до того, щоб зменшити кількість можливих багів і серйозно знизити витрати на розробку програмного забезпечення в цілому.
Давайте подивимося на прикладі.
Припустимо, ви створили програмне забезпечення, протестували його і через деякий час поставили за мету розширити його функціональність. У цьому випадку будь-які помилки, які виникнуть у процесі розробки, потрібно буде шукати лише в тій частині коду, який ви додаєте. Зменшується обсяг роботи у тестувальників — їм не потрібно щоразу проводити регресійне тестування всього коду.
Реалізувати цей принцип можна двома способами.
Перший варіант пропонує Бертран Мейєр, який і вигадав цей принцип у 1988 році у своїй книзі Object-Oriented Software Construction, а Роберт Мартін лише запозичив його.
Ви створюєте деякий шмат коду (скажімо, клас) і закриваєте його від будь-яких змін, крім баг-фіксів. Розширити такий код можна за допомогою наслідування. Ви створюєте Code2 і успадкуєте його від Code1, додаючи до нього функціональність. При цьому інтерфейс у блоці Code2 можна змінювати.
Як пропонує реалізувати принцип OCP Бертран Мейєр
Але це дещо дивно і незручно, і є висока ймовірність появи побічних проблем (сайд-ефект). Також, якщо деяка клієнтська частина коду, яка раніше зверталася до Code1, тепер звертається до Code2 і отримує новий інтерфейс, її також потрібно оновити.
Другий варіант — поліморфний — вигадав сам «дядько Боб». Він пропонує підійти до питання з іншого боку.
Клієнтський софт повинен залежати від незміненого інтерфейсу. Нова ж реалізація старого коду має використовувати той самий інтерфейс, можливо, делегуючи якимось чином виклик старого коду. Також вона може успадковуватись від нього — у цьому випадку клієнтський код не потрібно переписувати.
Як пропонує реалізувати принцип OCP Роберт Мартін
Трактування Роберта Мартіна є більш логічним, тому в більшості випадків, коли говорять про принцип відкритості/закритості, мають на увазі саме поліморфний підхід. Крег Ларман, який зібрав шаблони GRASP, які активно використовуються сьогодні в ООП, відніс OCP до шаблону Protected Variations — по суті, це те саме.
Моделюючи роботу клієнтського коду із серверною частиною, теоретично ви можете просто звернутися до якогось класу безпосередньо. Бажання так зробити порушує OCP — розширити таку взаємодію неможливо.
Щоб вирішити цю проблему, ми повинні винести інтерфейс сервера окремим блоком і зробити так, щоб клієнтська частина залежала тільки від цього модуля, а не від сервера. Розширити можливості можна за допомогою будь-якого GoF-патерну (про те, що це таке читайте тут )
Це правило — про «правильне успадкування». Його вигадала американська вчена-інформатик Барбара Лісков.
Барбара Лісков, авторка принципу підстановки Лісков
Будь-який об’єкт має тип, тобто клас. У різних об’єктів можуть бути різні типи і можуть належати одному класу об’єктів. Самі класи вишиковуються в ієрархію класів і мають наслідувати функціональність своїх предків.
Підхід Барбари Лісков допомагає виявляти проблемні абстракції та приховані зв’язки між сутностями, робити поведінку модулів передбачуваною та вводити обмеження на спадкування.
Принцип каже, що поведінка на наступних класах не має суперечити поведінці заданим класам. Сутності, які застосовують батьківський тип, повинні так само працювати і з дочірніми класами. При цьому в логіці функціонування програми нічого не повинно ламатися.
Перефразовуючи сказане — клас, що успадковується, повинен доповнювати, а не замінювати поведінку базового класу. Сенс у тому, щоб проєктувати логіку так, щоб класи-спадкоємці могли спокійно використовуватись замість батьків. Але в більшості випадків через додаткові перевірки логіки, для обох класів найкраще використовувати спільний інтерфейс, а не успадковувати один клас від іншого.
Найпростіший наочний приклад, який може це продемонструвати, має такий вигляд.
Ми маємо два класи: (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 }
Через те, що ми використовуємо клас квадрата, замість того, щоб кожне з полів встановлювати окремо, ми одним махом змінюємо обидва параметри.
Без додаткових перевірок підклас не може замінити суперклас. У цьому випадку потрібно створити єдиний інтерфейс для пари класів і замість успадкування одного класу від іншого задіяти інтерфейс.
Цей підхід перегукується з першим принципом 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:
Ця рекомендація описує найважливіший принцип ООП: модулі верхніх рівнів не повинні залежати від нижніх модулів. Обидва типи модулів мають залежати від абстракцій.
Альтернативний варіант викладу підходу DIP: абстракції не повинні залежати від деталей; деталі мають залежати від абстракцій. Потрібно використовувати всі класи через інтерфейси.
Коли йде звернення з одного класу до іншого (скажімо, клієнт-сервер) і потрібно додати деяку функціональність (авторизацію, автентифікацію, логування, кешування та ін.) у вас є два шляхи:
Код серверного класу і так містить досить важкий функціонал, а додавання нового порушує інший принцип проєктування SRP.
Теоретично можна організувати, щоб сервер сам викликав шматок коду, що додається. Але це погана ідея. Згодом може виявитися, що для деяких клієнтських викликів він взагалі не потрібен, і тоді доведеться використовувати якісь прапори, які визначають, коли сервер буде робити виклик.
Одним словом, щоб усіх цих проблем уникнути — потрібно дотримуватися принципу DIP.
Дотримання принципів SOLID гарантує легке оновлення програми. Розширювати, змінювати та підтримувати її буде просто за рахунок того, що ваш код буде легко читати, а всі помилки в ньому будуть чудово видно.
Код, який важко перевикористовувати, розростається в дикій кількості — його просто починають копіювати і обсяг збільшується до неосяжних розмірів. SOLID-підхід до проєктування програмного забезпечення допомагає обійти цю проблему.
Цікавий момент: рекомендації SOLID не обмежуються наведеними правилами. Принципів проєктування ПЗ Роберт Мартін виклав набагато більше. Якщо вам це цікаво, інші патерни проєктування ви знайдете у його книзі.
Резиденти Дія.City сплатили до бюджету понад 8 млрд грн податків в І кварталі 2025 року.…
У Китаї закликають офісних працівників не працювати надто багато — держава сподівається, що вільний час…
Експерти звертають увагу на тривожну тенденцію: люди все частіше використовують ChatGPT, щоб визначити місцезнаходження, зображене…
Компанія JetBrains випустила нову версію мультимовного середовища розробки IntelliJ IDEA 2025.1. Оновлена IDE отримала численні…
Платформа обміну миттєвими повідомленнями Discord впроваджує функцію перевірки віку за допомогою сканування обличчя. Зараз вона…
Wikipedia намагається захистити себе від тисяч різноманітних ботів-скрейперів, які сканують дані цієї платформи для навчання…