Все, що ви хотіли знати про принципи SOLID. Частина друга: OCP

Вітаю, друзі! З вами знову Сергій Немчинський — програміст з понад 20-річним досвідом розробки та засновник навчального центру FoxmindEd.

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

Так, я чудово розумію, що і концепція «чистого коду», і принципи SOLID часто стають об’єктами суворої критики. Однак я переконаний: перш ніж критикувати будь-яку методологію, варто її ґрунтовно вивчити. Лише так можна сформувати об’єктивне професійне судження.

Тож запрошую вас до другої статті циклу, присвяченої принципу відкритості-закритості (Open-Closed Principle).

Контекст (що було в попередній серії)

Для тих, хто пропустив першу статтю, нагадаю: принципи SOLID були зібрані й систематизовані Робертом Мартіном. Ми вже розглянули принцип єдиної відповідальності (Single Responsibility Principle) — літеру «S» в акронімі SOLID. Сьогодні ж зосередимося на другому принципі — «O», Open-Closed Principle, або принцип відкритості-закритості.

Принцип відкритості-закритості (Open-Closed Principle)

Принцип відкритості-закритості (OCP) був сформульований Бертраном Мейєром у 1988 році в книзі «Object-Oriented Software Construction». Власне, крім цього Бертран Мейєр мало чим відомий, тож на його персоналії ми не зупинятимемося. Пізніше Роберт Мартін інтегрував цей принцип до своєї колекції SOLID-принципів.

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

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

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

Сутність принципу OCP

Формальне визначення принципу звучить так:

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

Розшифровуємо, що це означає людською, зрозумілою мовою.

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

Закритість для модифікації означає, що вже написаний, протестований і впроваджений код не повинен змінюватися при додаванні нової функціональності. «Працює? Не чіпай!» Не варто ризикувати стабільністю системи, вносячи зміни до перевіреного коду.

На перший погляд ці дві вимоги здаються взаємовиключними. Як можна додавати нові можливості, не змінюючи існуючий код? Сутність принципу OCP полягає саме в розв’язанні цього протиріччя через правильне архітектурне рішення.

Два підходи до дотримання принципу

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

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

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

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

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

Цей підхід має два ключових аспекти:

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

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

Практичне застосування OCP

Приклад принципу Open-Closed. В минулій статті ми згадували розробку Embedded-системи, в якій є датчик, що вимірює час та температуру. Уявимо клас SensorMonitor, що відповідає за зчитування, обробку та відображення даних з датчика. У класі є метод для обробки даних, який перевіряє тип даних і відповідно обробляє.

Все працює чудово, доки до вас не приходить колега Василь і каже: «У нас з’явився новий датчик вологості, можеш додати його обробку в SensorMonitor?» Ви відкриваєте вже існуючий і протестований клас, додаєте новий блок коду для датчика вологості, змінюєте логіку і проводите повторне тестування.

Через тиждень приходить інший колега, Петро, і просить: «Нам потрібно додати підтримку датчика тиску, можеш оновити SensorMonitor?» І знову вам доводиться змінювати вже існуючий клас, додавати ще один блок коду, змінювати логіку і заново тестувати.

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

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

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

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

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

Клас SensorMonitor відкритий для розширення (можна додавати нові типи датчиків), але закритий для модифікації (не потрібно змінювати існуючий код).

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

Застосування OCP для різних архітектурних патернів

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

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

2. Декоратор. Працює так само як і проксі, але додає додаткову функціональність.

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

Пошук балансу між жорстким та гнучким програмуванням

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

З іншого боку знаходиться soft-code — класичний антипатерн, коли компоненти системи взаємодіють через надмірно абстраговані механізми: фабрики, dependency injection та інші складні інструменти. Такий підхід зрештою створює систему, яку надзвичайно складно конфігурувати, розробляти та підтримувати.

Моє бачення полягає в тому, що досвідчений програміст завжди шукає золоту середину між цими крайнощами, а середина якраз і полягає в OCP. На мій погляд, оптимальне рішення зазвичай знаходиться ближче до принципів Inversion of Control (IoC) чи Dependency Inversion — використання всіх класів через чітко визначені інтерфейси. Я переконаний, що цей підхід забезпечує необхідну гнучкість, зберігаючи при цьому простоту та зрозумілість архітектури, і ми в такому випадку можемо додати використання проксі чи декоратора, як я вже показав вище.

Іноді розробники настільки захоплюються створенням розширюваної архітектури, що система стає надмірно складною й важкою для розуміння. Якщо абстракція створює більше проблем, ніж вирішує, варто переглянути підхід і не використовувати ОСР. Як казав Альберт Ейнштейн:

Все має бути зроблено настільки просто, наскільки це можливо, але не простіше

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

👍ПодобаєтьсяСподобалось17
До обраногоВ обраному13
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Слова «датчик» (як і «передатчик») в українській мові немає. Є «давач» (як і «передавач»). А за статтю в цілому (і за попередню, і сподіваюся на продовження) щиро дякую, гарно пояснюєте!

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

А чому дивний то ? Це абревіатура яку ввів Майкл Фезерс (працював над Windows NT потім заснував консалтингову компанію), в його книзі безцелері Working Effectively with Legacy Code 2004 року, на основі принципів сформульований його колегою Робертом Мартіном також відомим як дядько Боб, також відомим консультантом та автором книг безцеллерів — Clean Code та Clean Architecture.
Усе це принципи «чистих» програмних систем — сенс яких зробити структурну якість ПЗ таким чином щоби програмну систему було би легко підтримувати. Що означає — відносно легко вносити в систему нові функції, видаляти застарілі і більше не потрібні, виправляти дефекти та переносити програмний продукт між різними програмно-аппаратнтими архітектурами (нова версія чогось там, чи з Windows на Mac, з iOS на Android і т.д.)
Усе це у свою чергу частина методології розробки Extreme Programming, що в свою чергу метод ведення IT бізнесу.
Чому це треба навчати починаючих спеціалістів ? (Тобто після засвоєння базісу, нульових нема жодного сенсу). Дуже просто — починаючи часто пишуть дуже складний (хоча і примітивний по факту просто там усе підряд — спагетті та масса операторів) програмний код, який вкрай складно підтримувати, при чому навіть їм самим з рештою.

Ок, отже створювати клас наслідник кожного разу, коли в систему потрібно внести зміни — це чистий код, правильно? Уявляю через 5 років девелопменту кількість класів які наслідують один одного... А власне саме про це OCP, а не якісь здогадки про поліморфічність які також описані в цій статті.
Писати якісний і чистий код звісно потрібно вчитись, але навряд чи це можна вивчити по акронімах типу solid/rest etc

А до чого тут класс наслідк ? Прочитайте книги вони про інше. Як один із варіантів, того же принципу підстановки типів Барбари Лісков, якщо перекласти на ОПП.
Скажімо у вас є задача — розробити мобільний застосунок, який буде працювати одразу на iOS та на Android, припустимо яку небудь Дія. Логіка роботи застосунку ідентична, та Api програмно апаратних систем різне. Що можна зробити — розділити програмний код системи на абстрактні слої, де слой щ програмним кодом який відповідає за бізнес логіку відокремлений від слою який відповідає за малювання графічного інтерфейсу на мобільному устрої. При цьому другий системою залежний код виставляє свій публічний інтерфейс — наприклад як абстрактний класс де усі функції віртуальні чи як підтримує якась конкретна мова програмування. І далі ви робите дві роздільні реалізації цього інтерфейсу під iOS і під Android, та передаєте реалізацію у слой логіки, та за рахунок підстановки типу вона працює як і працювала, не залежно від апаратної частини. В рамках ООП ви знайдете це описаним як шаблон проектування — Strategy Банди Чотирьох.
Насправді принцип не базується на OOP. Ті самі Unix та C базуються на абсолютно аналогічному принципі, тому їх полюбили ІТ спеціалісти в усьому світі. Барбара Лісков це придумала в інших контекстах, та воно працює на усіх рівнях.
В чому перевага ? А в тому що код логіки не треба міняти жодним чином, він залишається — а специфічний до апаратної частини лише додається чи змінюється. Так треба робити набагато менше роботи — тобто менше строки, і менший бюджет, а ще роботу можна розділити між різними виконавцями бо вона до певного етапу не залежить одна від одної. Відповідно це більш ефективний бізнес, та більші прибутки.
Так фреймверки типу React Native або Flutter — усе це фактично так і реалізують, і тому просто знаючи фреймверк можна не перейматись.

Дякую за детальний розбір цього важливого принципу. Сподіваюся буде більше джунів які його справді розуміють.
Я на своєму досвіді неодноразово дивувався: коли розмовляєш навіть з досвідченими девелоперами, які ніби то розуміють принципи SOLID, і кажеш на code review: дивись — цей клас був написаний і останній раз мінявся рік тому — ти не можеш його більше міняти. Або: не можна міняти юніт-тести які писалися раніше під іншу задачу (вони мають проходити як є).
І це у більшості викликає величезне здивування: як це код не можна міняти?! Більше того: я на жодному проєкті не бачив практик аби якісь сорс файли у проєкті помічалися як заблоковані для змін. Хоча б здавалося OCP — це саме про це, і навіть на рівні GIT мали б бути інструменти аби блокувати файли від подальших змін.
Єдиний приклад, де я бачив ОСР як він має бути — це версіювання API. Ось тут справді: якщо перша версія інтерфейсу вже у продакшині — то аби робити будь-які зміни треба зробити нову версію, новий окремий фолдер під цю версію, копію усіх потрібних файлів і переконатися що у фолдері з першою версією не було жодних змін.
Але ж насправді якщо робити правильне ООР — то кожен клас це фактично API. Він має реалізувати якийсь сталий інтерфейс, який використовують інші (через інжекшин). А отже якщо інтерфейс міняється — то треба справді робити нову версію інтерфейса і класа! А існуючий, вже покритий тестами, протестований QA і перевірений часом на продакшині код треба було б взагалі блокувати від будь-яких змін.
Це виглядає просто і логічно (улюблений копі-паст існуючого коду), усуває необхідність міняти існуючи юніт-тести (вони також мають бути заблоковані), майже нівелює ризик зламати існуючу функціональність, дозволяє девелоперу писати свій код і свої тести до нього, не копирсаючись у чужому коді. Я щиро не розумію чому так не роблять і чому такому підходу не вчать джунів?!

Сподіваюся буде більше джунів які його справді розуміють.

Десь рік тому були співбесіди молодих. Чисто заради інтересу задавав питання на Барбару Лісков, усе трохи по іншому з тим же розумінням типу данних взагалі. Будете робити співбесіду поцікавтесь — що народ до кінця розуміє, що таке біти та байти, RAM та типи данних в цілому. Як працює той компьютер, як робиться операція мінус і т.д. Ну тобто базове якесь розуміння звісно є, та чому скажімо із long перемінній можна призначити значення з byte сміло, а от навпаки це не зовсім добра ідея там вже треба бітові маски і т.д. це таке.
Те що класс та інтерефес — це складові типи данних аля структура, що там під низом віртуальні функції і т.д. часто народ зараз не знає. Хочя писати абсолютно робочий код не поганої якості на фреймверці може. Щоправда, якщо брати мене як студента багато років тому, я теж не знав і в голові була каша малаша.
Та щоби повторбвати трирівневу арзутектуру CRUD Web застосунку по шаблоку в цілому сам SOLID знати не треба, як і перечитувати Clean Architecture Дядька Боба. Фреймверк бере на себе базис.

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

Я вважаю це проблемою самоосвіти в ІТ. Якщо писати літери дітей вчать у школі і намагаються закласти якісь однакові хороші практики: літери одного розміру, нахил, відступи і т.і., то правильні практики розробки коду ніде не вчать з самого почату. Тому у тих, хто прийшов в ІТ сам, дуже різні підходи до процесу розробки. Якщо пощастить — вони потравлять на проєкт де є напрацьовані практики і ментор, який навчить на власному прикладі.
У мене було декілька «падаванів» — джунів, від яких я на початку чув: розробляти «правильно» тобто з архітектурою, коментарями, юніт-тестами (а може навіть TDD) — це мінімум у 2 рази довше. І мені вдавалося показати їм що оці практики насправді допомагають прискорити розробку і з ними як мінімум не довше.
Паприклад: коли джун написав код і запускає в дебаг увесь сайт, чекає поки завантажиться і доходить нарешті до свого коду — я за секунди запускаю у дебаг один юніт тест. Або один інтегрейшин тест якщо треба — але мені майже ніколи не треба запускати усю систему.
Або джун спочатку пише реалізацію, потім юніт-тест, а потім вже цю реалізацію починає викликати де треба. І виявляється що зовнішній код очікує щось інше, ніж те що зараз повертає реалізація. Отже: міняємо реалізацію, міняємо тест, знову дебажимо. У цей час я починаю з інтерфесу. Вже на цьому етапі я бачу як мій інтерфейс будуть викликати і чи усе потрібне в ньому є. Далі я роблю мок інтерфейса і новий юніт тест кода, який його викликає.
Фактично це те саме TDD — бо реалізації ще нема. Як заведено тест спочатку падає — бо мок повертає не те, що очікує зовнішній код. І це час з’ясувати що саме має повертатися і перевірити за допомогою моків. Після цього я пишу юніт тест який викликає методи мого інтерфейса і перевіряє очікувані результати. І знову спочатку проганяю тести з моками. І тільки після усього цього я починаю робити імплементацію. Бо я вже точно з’ясував що вона має робити і є тести які її перевірять. Переключаю тести на імплементацію — як тільки вони пройшли я можу бути на 90% впевнений що робота зроблена. Аби додати ще 9% я запускаю існуючи чи роблю нові інтеграційні, функціональні чи авто-тести які пройдуть увесь шлях. І тільки потім я один раз запущу увесь аплікейшин і дійду по потрібного місця аби переконатися що усе працює як треба.
Іще одна перевага: наприклад джуну треба розробити нову сторінку. Поки він не напише код репозиторія, потім контролера, потім фронта — він не побачить результат. А отже не буде значи чи правильним шляхом іде і скільки ще роботи залишилося.
Якщо я розробляю через інтерфейси і моки — я завжди маю робочій код. Це може бути робочій фронтенд, який показує данні з моків, може бути API який також повертає данні з моків. Але вони вже між собою зав’язані і фактично я маю робочий прототип, навіть якщо ще нема справжньої реалізації.

Підписатись на коментарі