Single Responsibility Principle. Не такой простой, как кажется

  • Tutorial

image Single responsibility principle, он же принцип единой ответственности,
он же принцип единой изменчивости — крайне скользкий для понимания парень и столь нервозный вопрос на собеседовании программиста.


Первое серьезное знакомство с этим принципом состоялось для меня в начале первого курса, когда молодых и зеленых нас вывезли в лес, чтобы сделать из личинок студентов — студентов настоящих.


В лесу нас разделили на группы по 8-9 человек в каждой и устроили соревнование — какая группа быстрее выпьет бутылку водки при условии, что первый человек из группы наливает водку в стакан, второй выпивает, а третий закусывает. Выполнивший свою операцию юнит встает в конец очереди группы.


Случай, когда размер очереди был кратен трем, и являлся хорошей реализацией SRP.


Определение 1. Единая ответственность.


Официальное определение принципа единой ответственности (SRP) говорит о том, что у каждого объекта есть своя ответственность и причина существования и эта ответственность у него только одна.


Рассмотрим объект "Выпивоха" (Tippler).
Для выполнения принципа SRP разделим обязанности на троих:


  • Один наливает (PourOperation)
  • Один выпивает (DrinkUpOperation)
  • Один закусывает (TakeBiteOperation)

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


Выпивоха же, в свою очередь является фасадом для данных операций:


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

image

Зачем?


Человек-программист пишет код для человека-обезьяны, а человек-обезьяна невнимателен, глуп и вечно куда-то спешит. Он может удержать и понять около 3 — 7 термов в один момент времени.
В случае выпивохи этих термов три. Однако если мы напишем код одной простыней, то в нем появятся руки, стаканы, мордобои и бесконечные споры о политике. И все это будет в теле одного метода. Уверен — вы видели такой код в своей практике. Не самое гуманное испытание для психики.


С другой стороны, человек-обезьяна заточен на моделирование объектов реального мира в своей голове. В своем воображении он может их сталкивать, собирать из них новые объекты и точно так же разбирать. Представьте себе старую модель машины. Вы можете в воображении открыть дверь, открутить обшивку двери и увидеть там механизмы стеклоподъемников, внутри которых будут шестерни. Но вы не можете увидеть все компоненты машины одновременно, в одном "листинге". По крайней мере "человек-обезьяна" не может.


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


Так вот, SRP — это принцип, объясняющий КАК декомпозировать, то есть где провести линию разделения.


Он говорит, что декомпозировать надо по принципу разделения "ответственности", то есть по задачам тех или иных объектов.


image

Вернемся к выпивохе и плюсам, которые получает человек-обезьянка при декомпозировании:


  • Код стал предельно ясен на каждом уровне
  • Код могут писать несколько программистов сразу (каждый пишет отдельный элемент)
  • Упрощается автоматическое тестирование — чем проще элемент, тем легче его тестировать
  • Из этих трех операций, в будущем, вы сможете сложить обжору ( используя только TakeBitOperation), Алкоголика (используя только DrinkUpOperation напрямую из бутылки) и удовлетворить многие другие требования бизнеса.

И, конечно же, минусы:


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

Определение 2. Единая изменчивость.


Позвольте господа! Класс выпивохи же также выполняет единую ответственность — он выпивает! И вообще, слово "ответственность" — понятие крайне размытое. Кто-то ответственен за судьбу человечества, а кто-то ответственен за поднимание опрокинутых на полюсе пингвинов.


Рассмотрим две реализации выпивохи. Первая, указанная выше, содержит в себе три класса — налить, выпить и закусить.


Вторая, написана через методологию "Вперед и только вперед" и содержит всю логику в методе Act:


//Не тратьте время  на изучение этого класса. Лучше съешьте печеньку
с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 — Принцип единой изменчивости.


Это определение гласит, что "У модуля есть один и только один повод для изменения". То есть "Ответственность — это повод для изменения".


Теперь все встает на свои места. Отдельно можно изменять процедуры наливания, выпивания и закусывания, а в самом выпивохе мы можем поменять только последовательность и состав операций, например, переместив закуску перед выпиванием или добавив чтение тоста.


В подходе "Вперед и только вперед", все что можно поменять — меняется только в методе Act. Это может быть читабельно и эффективно в случае, когда логики немного и она редко меняется, но зачастую это кончается ужасными методами по 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 и OnError также могут меняться по отдельности, и по аналогии с предыдущими действиями создаст три класса: PourLoggerBefore, PourLoggerAfter и PourErrorLogger.


А вспомнив, что операций для выпивохи три — получаем девять классов логирования. В итоге весь выпивоха состоит из 14 (!!!) классов.


Гипербола? Едва ли! Человек-обезьянка с декомпозиционной гранатой раздробит “наливателя” на графин, стакан, операторы наливания, сервис подачи воды, физическую модель столкновения молекул и следующий квартал будет пытаться распутать зависимости без глобальных переменных. И поверьте — он не остановится.


Именно на этом моменте многие приходят к выводу, что SRP — это сказки из розовых королевств, и уходят вить лапшу...


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


"Схожие для изменения вещи должны храниться в одном месте". или “То, что изменяется вместе, должно храниться в одном месте


То есть, если мы меняем логировку операции, то мы должны это менять в одном месте.


Это очень важный момент — так как все объяснения SRP, которые были выше, говорили о том, что надо дробить типы, пока они дробятся, то есть накладывало "ограничение сверху" на размер объекта, а теперь мы говорим уже и об "ограничении снизу". Иными словами, SRP не только требует "дробить пока дробится", но и не перестараться — "не раздробить сцепленные вещи". Не усложнять без надобности. Это великая битва бритвы Оккама с человеком-обезьяной!


image

Теперь выпивохе должно стать полегче. Помимо того, что не надо дробить логировщик 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 классов для решения задачи выпивания:


  • Операция наливания
  • Операция выпивания
  • Операция заедания
  • Логировщик
  • Фасад выпивохи

Каждый из них отвечает строго за одну функциональность, имеет одну причину для изменения. Все схожие для изменения правила лежат рядом.


Примеры из реальной жизни


Сериализация и десериализация

В рамках разработки протокола передачи данных нужно сделать сериализацию и десериализацию некоторого типа "User" в строку.


User{
    String Name;
    Int Age;
}

Можно подумать, что сериализацию и десериализацию нужно делать в отдельных классах:


UserDeserializer{
    String deserialize(User){...}
}
UserSerializer{
    User serialize(String){...}
}

Так как у каждого из них есть своя ответственность и один повод для изменения.


Но повод для изменения у них общий — "изменение формата сериализации данных".
И при изменение этого формата всегда будут меняться и сериализация и десериализация вместе.


Согласно принципу локализации изменений мы должны объединить их в один класс:


UserSerializer{
    String deserialize(User){...}
    User serialize(String){...}
}

Это избавиляет нас от излишней сложности, и необходимости помнить, что при каждом изменении сериализатора, нужно помнить и о десериализаторе.


Посчитай и сохрани

Необходимо посчитать годовую выручку компании и сохранить ее в файл C:\results.txt.


Быстро решаем это при помощи одного метода:


void SaveGain(Company company){
    //Код по подсчету выручки компании 
        //и сохранению результатов
}

Уже из определения задачи видно, что есть две подзадачи -"Посчитать выручку" и "Сохранить выручку". Каждая из них имеет по одному поводу для изменений — "изменене методики подсчета" и "изменение формата сохранения". Эти изменения никак не пересекаются. Так же, мы не можем односложно ответить на вопрос — "что делает метод SaveGain?". Этот метод И считает выручку И сохраняет результаты.


Потому нужно разделить этот метод на два:


Gain CalcGain(Company company){..}
void SaveGain(Gain gain){..}

Плюсы:


  • можно отдельно протестировать CalcGain
  • проще локализовать баги и вносить изменения
  • повысилась читабельность кода
  • уменьшился риск ошибки в каждом из методов из-за их упрощения

Сложная бизнес логика

Однажды мы писали сервис автоматической регистрации 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. Он применим к методам, функциям, классам, модулям, микросервисам и сервисам. Он применим как к “фигакс-фигакс-и-в-прод”, так и к “рокет-сайнс” разработке, везде делая мир чуточку лучше. Если задуматься, то это едва ли не фундаментальный принцип всей инженерии. Машиностроение, системы управления, да и вообще все сложные системы — строятся из компонентов, и “недодробление” лишает конструкторов гибкости, “передробление” — эффективности, а неверные границы — разума и душевного спокойствия.


image

SRP не выдуман природой и не является частью точной науки. Он вылезает из наших с вами биологических и психологических ограничений.Это всего лишь способ контролировать и развивать сложные системы при помощи мозга человека-обезьяны. Он рассказывает нам, как декомпозировать систему. Изначальная формулировка требовала изрядного навыка телепатии, но надеюсь, эта статья слегка развеяла дымовую завесу.

Поделиться публикацией

Комментарии 16

    +4
    ИМХО, это самая непонятная и запутанная попытка объяснить SRP, которые мне попадались :)
      +2
      а что делать, если сам дядюшка Боб (Роберт Мартин) пишет, что понимание SRP запутано из-за его неудачного названия, из-за чего большинство думают, что каждый модуль должен отвечать за что-то одно. Этот принцип тоже есть и он тоже более чем оправдан, но под первым принципом SOLID подразумевается такая формулировка

      «Модуль должен иметь одну и только одну причину для изменения. Пользователи и заинтересованные лица и есть та самая причина для изменений». (с) «Чистая архитектура», глава 7, страница 79
        +2
        Эта фраза и была основной мотивацией написать эту статью, так как вторая ее часть еще более загадочна чем изначальное определение. На сайте у дядюшки Боба еще целую страницу он объясняет что значит «Пользователи и заинтересованные лица и есть та самая причина для изменений».
        0
        Переименовал статью в «Не такой простой, как кажется» ;)
        +4
        Мне понравилось выступление на конференции Юнити, и там чел сказал что проверяет SRP очень просто, он задаёт вопрос — что делает этот класс? Если ответ содержит в себе список функционала, то вывод очевиден ).

        Но на мой взгляд главное без глупого фанатизма.
          0
          Дополнил статью вашим замечанием.

          Я написал эту статью в т.ч. из опыта общения с начинающими разработчиками и в процессе общения открыл для себя много нового.

          Для тех кто уже знаком с Srp — да, вопрос «что делает этот класс» — хороший критерий для самопроверки.
          Но если вас еще не успели познакомить — то возникает много вопросов:
          1) При наличие фасадов ответ может быть расплывчатым
          «Выпивоха ответственнен за все что касается выпивания». А стакан — касается выпивания или за хранения жидкости? Он часть выпивохи или нет? Для новичка (да и не только) это может быть большим вопросом.
          2) Формулировка «за что отвечает» является больше мотивацией дробить чем объединять, то есть накладывает ограничение сверху по размеру модуля, по крайней мере в понимание начинающих программистов (практика опять таки показывает, что не только)

            +1
            и там чел сказал что проверяет SRP очень просто, он задаёт вопрос — что делает этот класс? Если ответ содержит в себе список функционала, то вывод очевиден ).

            Я, кстати, к этой проблеме захожу «от противного». Задаю вопрос: «сколько мест в программе нужно изменить, чтобы изменить поведение такого-то функционала». Если количество равно 1, то SRP соблюдён, по крайней мере, на необходимом и достаточном уровне.
              0
              А если у вас есть класс, который например что-то считает и пишет результат в файл, что, ИМХО, нарушение SRP. И, допустим, Вам нужно поменять алгоритм формирования имени файла(это уже может быть третьим ответ на вопрос «что?). Вы вроде как поменяете одно место, но „лишний“ функционал в классе останется…
              Но с вашим замечанием про необходимый и достаточный уровень согласен.
                0
                Добавил пример в статью.
                  +1
                  Смотрите, я вот что имел в виду:
                  1. SRP, как и все остальные принципы, не есть самоцель. Это инструмент для упрощения сопровождения кода, и именно с этой позиции его надо применять.
                  2. Нет четкого определения, где надо дробить функционал на составляющие. Эта «черта сложности» как раз и есть регулятор, определяющий степень применения инструмента. Мы должны стараться её провести так, чтобы получить наилучшее, кхм, упрощение.
                  Соответственно, в данном примере, если при прочих равных условиях разделение этого класса на два приведет к усложнению сопровождения кода, просто проведите черту SRP выше него. Ваш класс теперь согласно условиям решает одну задачу расчета и вывода данных.
              +2
              А вам не кажется что налить, выпить и закусить, для человека, это три части одного действия? И с точки зрения организма человека лишение хоть одного из них чревато ошибкой, а значит они явно связаны жестко между собой. Правильно ли бить на 3 разные сущности один жестко связанный процесс?
                0
                Зависит от контекста задачи. В случае с оригинальной студенческой игрой эти ответственности определенно разделены ;)

                Если же весь процесс действительно жестко сцеплен, то есть изменение одной его части повлечет изменение другой — то это разделение не только не нужно, но и вредно.
                В терминах SRP — такое разделение противоречит идее «локализации изменений».

                Исключением может быть объемный процес, допустим на 500 строк кода (цифра «зависит»). В таком случае нужно стараться изыскивать возможности разделения ответственности, из-за ограничений нашего с вами мозга. Разбить сложный процесс на взаимодействие нескольких более простых.
                  0
                  Кстати, в контексте оригинальной студенческой задачи число игроков кратное 3 самый худший выбор, ибо нагрузка ложится на людей не равномерно, и если взять число игроков 4, то роли становятся задачами одной сущности, тоесть каждый должен уметь и наливать и пить и закусывать
                    0
                    К программированию эта нагрузка имеет мало отношения.
                    Если же волею ТЗ требуется взять именно 4 игрока, то разбиение выпивохи на сабличности тем более пригодится.
                +1
                Мне статья показалась очень полезной. Автору спасибо! Интересно было бы еще почитать за остальные принципы SOLID. Единственное, у меня остался такой вопрос: если условно научить выпивоху по разному реагировать на напиток который ему необходимо выпить (стопку водки, или кружку пива), то есть действие «выпить залпом» / «выпить в течении времени». Вот это условие определяющее дальнейшее поведение должно находиться в декораторе или же правильнее вынести это разделение в непосредственно класс который овечат за потребление напитка? На мой взгяд этим должен заниматься декоратор, что вы думаете на этот счет?
                  0
                  Ответ: Это зависит. Если выпивание всегда одинаково, но есть много реализаций этого выпивания, то это либо наследование, либо декоратор, либо адаптер. Ифчики тоже никто не отменял. Семантически эти подходы почти не различаются, а какой из сахаров выбрать — зависит от контекста: языка, бизнеса и, пожалуй, фазы луны.

                  В любом случае — это не вопрос SRP. Это вопрос Open-Close принципа (OCP) и он требует отдельной проработки. Постараюсь найти время заняться и этим парнем ;)

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое