Single Responsibility Principle. Не такий простий, як здається

Single Responsibility Principle. Не такий простий, як здається Single responsibility principle, він принцип єдиної відповідальності,
він же принцип єдиної мінливості — вкрай слизький для розуміння хлопець і таке нервозне питання співбесіди програміста.

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

У лісі нас розділили на групи по 8-9 осіб у кожній та влаштували змагання — яка група швидше вип'є пляшку горілки за умови, що перша людина з групи наливає горілку у склянку, друга випиває, а третю закушує. Юніт, що виконав свою операцію, встає в кінець черги групи.

Випадок, коли розмір черги був кратний трьом, і був гарною реалізацією SRP.

Визначення 1. Єдина відповідальність.

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

Розглянемо об'єкт «Випивоха» (Самоскид).
Для виконання принципу SRP розділимо обов'язки на трьох:

  • Один наливає (PourOperation)
  • Один випиває (DrinkUpOperation)
  • Один закушує (TakeBiteOperation)

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

Випивоха ж, у свою чергу, є фасадом для даних операцій:

сlass Tippler {
    //...
    void Act(){
        _pourOperation.Do() // налить
        _drinkUpOperation.Do() // выпить
        _takeBiteOperation.Do() // закусить
    }
}

Single Responsibility Principle. Не такий простий, як здається

Навіщо?

Людина-програміст пише код для людини-мавпи, а людина-мавпа неуважна, дурна і вічно кудись поспішає. Він може утримати і зрозуміти близько 3 - 7 термів одночасно.
У разі випивохи цих термів три. Однак якщо ми напишемо код одним простирадлом, то в ньому з'являться руки, склянки, мордобої та нескінченні суперечки про політику. І все це буде у тілі одного методу. Впевнений, ви бачили такий код у своїй практиці. Чи не найгуманніше випробування для психіки.

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

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

Так ось, SRP – це принцип, який пояснює ЯК декомпозувати, тобто, де провести лінію поділу.

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

Single Responsibility Principle. Не такий простий, як здається

Повернімося до випивки та плюсів, які отримує людина-мавпочка при декомпозуванні:

  • Код став гранично зрозумілим на кожному рівні
  • Код можуть писати кілька програмістів відразу (кожен пише окремий елемент)
  • Спрощується автоматичне тестування – чим простіше елемент, тим легше його тестувати
  • З'являється композиційність коду – можна замінити DrinkUpOperation на операцію, в якій випивака виливає рідину під стіл. Або замінити операцію наливання на операцію, в якій ви заважаєте вино та воду чи горілку та пиво. Залежно від вимог бізнесу ви можете все, при цьому не чіпаючи код методу Tippler.Act.
  • З цих операцій ви можете скласти ненажеру ( використовуючи тільки TakeBitOperation), Алкоголіка (використовуючи тільки DrinkUpOperation безпосередньо з пляшки) і задовольнити багато інших вимог бізнесу.

(О, здається це вже OCP принцип, та я порушив відповідальність цієї посади)

І, звичайно ж, мінуси:

  • Потрібно створити більше типів.
  • Випивоха вперше вип'є на пару годин пізніше, ніж міг би

Визначення 2. Єдина мінливість.

Дозвольте панове! Клас пиятики також виконує єдину відповідальність — він випиває! І взагалі слово «відповідальність» — поняття вкрай розмите. Хтось відповідальний за долю людства, а хтось відповідальний за піднімання перекинутих на полюсі пінгвінів.

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

Друга написана через методологію «Вперед і тільки вперед» і містить всю логіку в методі Діяти:

//Не тратьте время  на изучение этого класса. Лучше съешьте печеньку
сlass BrutTippler {
   //...
   void Act(){
        // наливаем
    if(!_hand.TryDischarge(from:_bottle, to:_glass, size:_glass.Capacity))
        throw new OverdrunkException();

    // выпиваем
    if(!_hand.TryDrink(from: _glass,  size: _glass.Capacity))
        throw new OverdrunkException();

    //Закусываем
    for(int i = 0; i< 3; i++){
        var food = _foodStore.TakeOrDefault();
        if(food==null)
            throw new FoodIsOverException();

        _hand.TryEat(food);
    }
   }
}

Обидва ці класи, з погляду стороннього спостерігача, виглядають абсолютно однаково і виконують єдину відповідальність «випити».

Конфуз!

Тоді ми ліземо в інтернет і дізнаємося про інше визначення SRP - Принцип єдиної мінливості (Single Changeability Principle).

SCP говорить, що «Модуль має один і лише один привід для зміни“. Тобто, «Відповідальність — це привід для зміни».

(Схоже, хлопці, які придумали початкове визначення, були впевнені в телепатичних здібностях людини-мавпи)

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

У підході «Вперед і тільки вперед», що можна поміняти — змінюється лише методі Діяти. Це може бути читабельно і ефективно у випадку, коли логіки небагато і вона рідко змінюється, але найчастіше це закінчується жахливими методами по 500 рядків у кожному, з кількістю if-ів більшим, ніж потрібно для вступу Росії в НАТО.

Визначення 3. Локалізація змін.

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

Почнемо логірування з процесу наливання:

class PourOperation: IOperation{
    PourOperation(ILogger log /*....*/){/*...*/}
    //...
    void Do(){
        _log.Log($"Before pour with {_hand} and {_bottle}");
        //Pour business logic ...
        _log.Log($"After pour with {_hand} and {_bottle}");
    }
}

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

interface IPourLogger{
    void LogBefore(IHand, IBottle){}
    void LogAfter(IHand, IBottle){}
    void OnError(IHand, IBottle, Exception){}
}

class PourOperation: IOperation{
    PourOperation(IPourLogger log /*....*/){/*...*/}
    //...
    void Do(){
        _log.LogBefore(_hand, _bottle);
        try{
             //... business logic
             _log.LogAfter(_hand, _bottle");
        }
        catch(exception e){
            _log.OnError(_hand, _bottle, e)
        }
    }
}

Прискіпливий читач помітить, що LogAfter, LogBefore и Одна помилка також можуть змінюватись окремо, і за аналогією з попередніми діями створить три класи: PourLoggerBefore, PourLoggerAfter и PourErrorLogger.

А згадавши, що операцій для пиятики три — отримуємо дев'ять класів логування. У результаті весь пияка складається з 14 (!!!) класів.

Гіперболу? Ледве! Людина-мавпа з декомпозиційною гранатою роздробить "наливателя" на графін, склянку, оператори наливання, сервіс подачі води, фізичну модель зіткнення молекул і наступний квартал намагатиметься розплутати залежності без глобальних змінних. І повірте, він не зупиниться.

Саме на цьому моменті багато хто приходить до висновку, що SRP - це казки з рожевих королівств, і йдуть вити локшину.

… так і не дізнавшись про існування третього визначення Srp:

«Принцип єдиної відповідальності говорить, що схожі зміни речі повинні зберігатися у одному місці“. або “Те, що змінюється разом, має зберігатися в одному місці"

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

Це дуже важливий момент — оскільки всі пояснення SRP, які були вищими, говорили про те, що треба дробити типи, доки вони дробляться, тобто накладало «обмеження зверху» на розмір об'єкта, а тепер ми вже говоримо про «обмеження знизу». . Іншими словами, SRP не тільки вимагає «дробити поки що дробиться», але й не перестаратися — «не роздробити зчеплені речі». Це велика битва бритви Оккама з людиною-мавпою!

Single Responsibility Principle. Не такий простий, як здається

Тепер випивоху має стати легше. Крім того, що не треба дробити логувальник IPourLogger на три класи, ми також можемо об'єднати всі логувальники в один тип:

class OperationLogger{
    public OperationLogger(string operationName){/*..*/}
    public void LogBefore(object[] args){/*...*/}       
    public void LogAfter(object[] args){/*..*/}
    public void LogError(object[] args, exception e){/*..*/}
}

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

У результаті у нас 5 класів для вирішення задачі випивання:

  • Операція наливання
  • Операція випивання
  • Операція заїдання
  • Логувальник
  • Фасад пивохи

Кожен з них відповідає за одну функціональність, має одну причину для зміни. Усі схожі зміни правила лежать поруч.

Приклад з реального життя

Якось ми писали сервіс автоматичної реєстрації b2b клієнта. І з'явився GOD-метод на 200 рядків такого вмісту:

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

І було в цьому списку ще близько 10-ти бізнес операцій із моторошною пов'язаністю. Об'єкт рахунку потрібний був майже всім. Ідентифікатор точки та ім'я клієнта були потрібні в половині викликів.

Після годинного рефакторингу, ми змогли відокремити інфраструктурний код та деякі нюанси роботи з обліковим записом в окремі методи/класи. God метод полегшав, але залишилося 100 рядків коду, які розплутуватись ніяк не хотіли.

Лише за кілька днів прийшло розуміння, що суть цього методу, що «полегшував», — і є бізнес алгоритм. І що початковий опис ТЗ був досить складним. І саме спроба розбити на шматки цей метод буде порушенням SRP, а чи не навпаки.

Формалізм.

Настав час дати спокій нашому випивоху. Витріть сльози - ми обов'язково повернемося до нього якось. А зараз формалізуємо знання із цієї статті.

Формалізм 1. Визначення SRP

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

Формалізм 2. Необхідні критерії самоперевірки.

Мені не зустрічалися достатні критерії виконання SRP. Але є необхідні умови:

1) Задайте собі питання - що робить цей клас/метод/модуль/сервіс. ви повинні відповісти на нього простим визначенням. ( дякую Brightori )

пояснення

Втім, іноді підібрати просте визначення дуже складно

2) Фікс деякого бага або додавання нової фічі торкається мінімальної кількості файлів/класів. В ідеалі – один.

пояснення

Так як відповідальність (за фічу або баг) інкапсульована в одному файлі/класі, то ви точно знаєте, де шукати і що правити. Наприклад: фічу зміни виведення логування операцій вимагатиме змінити лише логувальник. Бігати по решті коду не потрібно.

Інший приклад - додавання нового UI-контролю, схожого на попередні. Якщо це змушує вас додати 10 різних сутностей і 15 різних конвертерів — здається, ви передробили.

3) Якщо кілька розробників працюють над різними фічами вашого проекту, то ймовірність мердж-конфлікту, тобто ймовірність того, що той самий файл/клас буде змінений у декількох розробників одночасно - мінімальна.

пояснення

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

4) При уточнюючому питанні про бізнес логіку (від розробника або менеджера) ви лізете строго в один клас/файл і отримуєте інформацію лише від туди.

пояснення

Фічі, правила чи алгоритми компактно написані кожна одному місці, а чи не розкидані прапорами у всьому просторі коду.

5) Неймінг зрозумілий.

пояснення

Наш клас чи метод відповідальний щось одне, і відповідальність відбито у його назві

AllManagersManagerService - швидше за все, God-клас
LocalPayment - ймовірно, ні

Формалізм 3. Методика розробки Оккама-first.

На початку проектування, людина-мавпочка не знає і не відчуває всіх тонкощів завдання, що розв'язується, і може дати маху. Помилятися можна по-різному:

  • Зробити надто великі об'єкти, склеївши різні відповідальності
  • Передробити, розділивши єдину відповідальність на багато різних типів
  • Неправильно визначити межі відповідальності

Важливо запам'ятати правило: "помилятися краще у велику сторону", або "не впевнені - не дробіть". Якщо, наприклад, ваш клас збирає в собі дві відповідальності — то він, як і раніше, зрозумілий і його можна розпиляти на два з мінімальною зміною клієнтського коду. Збирати ж з осколків скла склянку, як правило, складніше через розмазаний по кількох файлах контекст і відсутність необхідних залежностей у клієнтському коді.

Час закруглюватися

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

Single Responsibility Principle. Не такий простий, як здається

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

Джерело: habr.com

Додати коментар або відгук