Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Single responsibility principle, он же принцип единой ответственности,
он же принцип единой изменчивости — крайне скользкий для понимания парень и столь нервозный вопрос на собеседовании программиста.
Первое серьезное знакомство с этим принципом состоялось для меня в начале первого курса, когда молодых и зеленых нас вывезли в лес, чтобы сделать из личинок студентов — студентов настоящих.
В лесу нас разделили на группы по 8-9 человек в каждой и устроили соревнование — какая группа быстрее выпьет бутылку водки при условии, что первый человек из группы наливает водку в стакан, второй выпивает, а третий закусывает. Выполнивший свою операцию юнит встает в конец очереди группы.
Случай, когда размер очереди был кратен трем, и являлся хорошей реализацией SRP.
Определение 1. Единая ответственность.
Официальное определение принципа единой ответственности (SRP) говорит о том, что у каждого объекта есть своя ответственность и причина существования и эта ответственность у него только одна.
Рассмотрим объект "Выпивоха" (Tippler).
Для выполнения принципа SRP разделим обязанности на троих:
- Один наливает (PourOperation)
- Один выпивает (DrinkUpOperation)
- Один закусывает (TakeBiteOperation)
Каждый из участников процесса ответственен за одну компоненту процесса, то есть имеет одну атомарную ответственность — выпить, налить или закусить.
Выпивоха же, в свою очередь является фасадом для данных операций:
сlass Tippler {
//...
void Act(){
_pourOperation.Do() // налить
_drinkUpOperation.Do() // выпить
_takeBiteOperation.Do() // закусить
}
}
Зачем?
Человек-программист пишет код для человека-обезьяны, а человек-обезьяна невнимателен, глуп и вечно куда-то спешит. Он может удержать и понять около 3 — 7 термов в один момент времени.
В случае выпивохи этих термов три. Однако если мы напишем код одной простыней, то в нем появятся руки, стаканы, мордобои и бесконечные споры о политике. И все это будет в теле одного метода. Уверен — вы видели такой код в своей практике. Не самое гуманное испытание для психики.
С другой стороны, человек-обезьяна заточен на моделирование объектов реального мира в своей голове. В своем воображении он может их сталкивать, собирать из них новые объекты и точно так же разбирать. Представьте себе старую модель машины. Вы можете в воображении открыть дверь, открутить обшивку двери и увидеть там механизмы стеклоподъемников, внутри которых будут шестерни. Но вы не можете увидеть все компоненты машины одновременно, в одном "листинге". По крайней мере "человек-обезьяна" не может.
Поэтому человеки-программисты декомпозируют сложные механизмы на набор менее сложных и работающих элементов. Однако, декомпозировать можно по-разному: во многих старых машинах — воздуховод выходит в дверь, а в современных — сбой электроники замка не дает запуститься двигателю, что доставляет при ремонте.
Так вот, SRP — это принцип, объясняющий КАК декомпозировать, то есть где провести линию разделения.
Он говорит, что декомпозировать надо по принципу разделения "ответственности", то есть по задачам тех или иных объектов.
Вернемся к выпивохе и плюсам, которые получает человек-обезьянка при декомпозировании:
- Код стал предельно ясен на каждом уровне
- Код могут писать несколько программистов сразу (каждый пишет отдельный элемент)
- Упрощается автоматическое тестирование — чем проще элемент, тем легче его тестировать
- Появляется композиционность кода — можно заменить DrinkUpOperation на операцию, в которой выпивоха выливает жидкость под стол. Или заменить операцию наливания на операцию, в которой вы мешаете вино и воду или водку и пиво. В зависимости от требований бизнеса вы можете все, при этом не трогая код метода Tippler.Act.
- Из этих операций вы можете сложить обжору ( используя только TakeBitOperation), Алкоголика (используя только DrinkUpOperation напрямую из бутылки) и удовлетворить многие другие требования бизнеса.
(Ой, кажется это уже OCP принцип, и я нарушил ответственность этого поста)
И, конечно же, минусы:
- Придется создать больше типов.
- Выпивоха впервые выпьет на пару часов позже, чем мог бы
Определение 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 — Принцип единой изменчивости (Single Changeability Principle).
SCP гласит, что "У модуля есть один и только один повод для изменения". То есть "Ответственность — это повод для изменения".
(Похоже, ребята, придумавшие изначальное определение были уверены в телепатических способностях человека-обезьяны)
Теперь все встает на свои места. Отдельно можно изменять процедуры наливания, выпивания и закусывания, а в самом выпивохе мы можем поменять только последовательность и состав операций, например, переместив закуску перед выпиванием или добавив чтение тоста.
В подходе "Вперед и только вперед", все что можно поменять — меняется только в методе 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 не только требует "дробить пока дробится", но и не перестараться — "не раздробить сцепленные вещи". Это великая битва бритвы Оккама с человеком-обезьяной!
Теперь выпивохе должно стать полегче. Помимо того, что не надо дробить логировщик 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
- Разделяйте элементы так, чтобы каждый из них был ответственен за что-то одно.
- Ответственность расшифровывается как "повод для изменения". То есть каждый элемент имеет только один повод для изменения, в терминах бизнес логики.
- Потенциальные изменения бизнес логики. должны быть локализованы. Изменяемые синхронно элементы должны быть рядом.
Формализм 2. Необходимые критерии самопроверки.
Мне не встречались достаточные критерии выполнения SRP. Но есть необходимые условия:
1) Задайте себе вопрос — что делает этот класс/метод/модуль/сервис. вы должны ответить на него простым определением. ( благодарю Brightori )
Впрочем иногда подобрать простое определение очень сложно
2) Фикс некоторого бага или добавление новой фичи затрагивает минимальное количество файлов/классов. В идеале — один.
Так как ответственность (за фичу или баг) инкапсулированна в одном файле/классе, то вы точно знаете где искать и что править. Например: фича изменения вывода логировки операций потребует изменить только логировщик. Бегать по всему остальному коду не требуется.
Другой пример — добавление нового UI-контрола, схожего с предыдущими. Если это заставляет вас добавить 10 разных сущностей и 15 разных конвертеров — кажется, вы “передробили”.
3)Если несколько разработчиков работают над разными фичами вашего проекта, то вероятность мердж -конфликта, то есть вероятность того, что один и тот же файл/класс будет изменен у нескольких разработчиков одновременно — минимальна.
Если при добавлении новой операции "Вылить водку под стол" вам нужно затронуть логировщик, операцию выпивания и выливания — то похоже, что ответственности разделены криво. Безусловно, это не всегда возможно, но нужно стараться снизить этот показатель.
4) При уточняющем вопросе про бизнес логику (от разработчика или менеджера) вы лезете строго в один класс/файл и получаете информацию только от туда.
Фичи, правила или алгоритмы компактно написаны каждая в одном месте, а не разбросаны флагами по всему пространству кода.
5) Нейминг понятен.
Наш класс или метод ответственен за что-то одно, и ответственность отражена в его названии
AllManagersManagerService — скорее всего, God-класс
LocalPayment — вероятно, нет
Формализм 3. Методика разработки "Оккама-first".
В начале проектирования, человек-обезьянка не знает и не чувствует всех тонкостей решаемой задачи и может дать маху. Ошибаться можно по разному:
- Сделать слишком большие объекты, склеив разные ответственности
- Передробить, разделив единую ответственность на много разных типов
- Неверно определить границы ответственности
Важно запомнить правило: "ошибаться лучше в большую сторону", или "не уверены — не дробите". Если, например, ваш класс собирает в себе две ответственности — то он по прежнему понятен и его можно распилить на два с минимальным изменением клиентского кода. Собирать же из осколков стекла стакан, как правило, сложнее из-за размазанного по нескольким файлам контекста и отсутствия необходимых зависимостей в клиентском коде.
Пора закругляться
Сфера применения SRP не ограничивается ООП и SOLID. Он применим к методам, функциям, классам, модулям, микросервисам и сервисам. Он применим как к “фигакс-фигакс-и-в-прод”, так и к “рокет-сайнс” разработке, везде делая мир чуточку лучше. Если задуматься, то это едва ли не фундаментальный принцип всей инженерии. Машиностроение, системы управления, да и вообще все сложные системы — строятся из компонентов, и “недодробление” лишает конструкторов гибкости, “передробление” — эффективности, а неверные границы — разума и душевного спокойствия.
SRP не выдуман природой и не является частью точной науки. Он вылезает из наших с вами биологических и психологических ограничений.Это всего лишь способ контролировать и развивать сложные системы при помощи мозга человека-обезьяны. Он рассказывает нам, как декомпозировать систему. Изначальная формулировка требовала изрядного навыка телепатии, но надеюсь, эта статья слегка развеяла дымовую завесу.