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 — это принцип, объясняющий КАК декомпозировать, то есть где провести линию разделения.
Он говорит, что декомпозировать надо по принципу разделения "ответственности", то есть по задачам тех или иных объектов.
Вернемся к выпивохе и плюсам, которые получает человек-обезьянка при декомпозировании:
- Код стал предельно ясен на каждом уровне
- Код могут писать несколько программистов сразу (каждый пишет отдельный элемент)
- Упрощается автоматическое тестирование — чем проще элемент, тем легче его тестировать
- Из этих трех операций, в будущем, вы сможете сложить обжору ( используя только 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 не только требует "дробить пока дробится", но и не перестараться — "не раздробить сцепленные вещи". Не усложнять без надобности. Это великая битва бритвы Оккама с человеком-обезьяной!
Теперь выпивохе должно стать полегче. Помимо того, что не надо дробить логировщик 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
- Разделяйте элементы так, чтобы каждый из них был ответственен за что-то одно.
- Ответственность расшифровывается как "повод для изменения". То есть каждый элемент имеет только один повод для изменения, в терминах бизнес логики.
- Потенциальные изменения бизнес логики. должны быть локализованы. Изменяемые вместе элементы должны быть рядом.
Формализм 2. Необходимые критерии самопроверки.
Мне не встречались достаточные критерии выполнения SRP. Но есть необходимые условия:
1) Задайте себе вопрос — что делает этот класс/метод/модуль/сервис. вы должны ответить на него простым определением. ( благодарю Brightori )
Впрочем иногда подобрать простое определение очень сложно
2) Фикс некоторого бага или добавление новой фичи затрагивает минимальное количество файлов/классов. В идеале — один.
Так как ответственность (за фичу или баг) инкапсулированна в одном файле/классе, то вы точно знаете где искать и что править. Например: фича изменения вывода логировки операций потребует изменить только логировщик. Бегать по всему остальному коду не требуется.
Другой пример — добавление нового UI-контрола, схожего с предыдущими. Если это заставляет вас добавить 10 разных сущностей и 15 разных конвертеров — кажется, вы “передробили”.
3)Если несколько разработчиков работают над разными фичами вашего проекта, то вероятность мердж -конфликта, то есть вероятность того, что один и тот же файл/класс будет изменен у нескольких разработчиков одновременно — минимальна.
Если при добавлении новой операции "Вылить водку под стол" вам нужно затронуть логировщик, операцию выпивания и выливания — то похоже, что ответственности разделены криво. Безусловно, это не всегда возможно, но нужно стараться снизить этот показатель.
4) При уточняющем вопросе про бизнес логику (от разработчика или менеджера) вы лезете строго в один класс/файл и получаете информацию только от туда.
Фичи, правила или алгоритмы компактно написаны каждая в одном месте, а не разбросаны флагами по всему пространству кода.
5) Нейминг понятен.
Наш класс или метод ответственен за что-то одно, и ответственность отражена в его названии
AllManagersManagerService — скорее всего, God-класс
LocalPayment — вероятно, нет
Формализм 3. Методика разработки "Оккама-first".
В начале проектирования, человек-обезьянка не знает и не чувствует всех тонкостей решаемой задачи и может дать маху. Ошибаться можно по разному:
- Сделать слишком большие объекты, склеив разные ответственности
- Передробить, разделив единую ответственность на много разных типов
- Неверно определить границы ответственности
Важно запомнить правило: "ошибаться лучше в большую сторону", или "не уверены — не дробите". Если, например, ваш класс собирает в себе две ответственности — то он по прежнему понятен и его можно распилить на два с минимальным изменением клиентского кода. Собирать же из осколков стекла стакан, как правило, сложнее из-за размазанного по нескольким файлам контекста и отсутствия необходимых зависимостей в клиентском коде.
Пора закругляться
Сфера применения SRP не ограничивается ООП и SOLID. Он применим к методам, функциям, классам, модулям, микросервисам и сервисам. Он применим как к “фигакс-фигакс-и-в-прод”, так и к “рокет-сайнс” разработке, везде делая мир чуточку лучше. Если задуматься, то это едва ли не фундаментальный принцип всей инженерии. Машиностроение, системы управления, да и вообще все сложные системы — строятся из компонентов, и “недодробление” лишает конструкторов гибкости, “передробление” — эффективности, а неверные границы — разума и душевного спокойствия.
SRP не выдуман природой и не является частью точной науки. Он вылезает из наших с вами биологических и психологических ограничений.Это всего лишь способ контролировать и развивать сложные системы при помощи мозга человека-обезьяны. Он рассказывает нам, как декомпозировать систему. Изначальная формулировка требовала изрядного навыка телепатии, но надеюсь, эта статья слегка развеяла дымовую завесу.
Комментарии (14)
Brightori
31.05.2019 14:19+1Мне понравилось выступление на конференции Юнити, и там чел сказал что проверяет SRP очень просто, он задаёт вопрос — что делает этот класс? Если ответ содержит в себе список функционала, то вывод очевиден ).
Но на мой взгляд главное без глупого фанатизма.tmteam Автор
31.05.2019 14:46Дополнил статью вашим замечанием.
Я написал эту статью в т.ч. из опыта общения с начинающими разработчиками и в процессе общения открыл для себя много нового.
Для тех кто уже знаком с Srp — да, вопрос «что делает этот класс» — хороший критерий для самопроверки.
Но если вас еще не успели познакомить — то возникает много вопросов:
1) При наличие фасадов ответ может быть расплывчатым
«Выпивоха ответственнен за все что касается выпивания». А стакан — касается выпивания или за хранения жидкости? Он часть выпивохи или нет? Для новичка (да и не только) это может быть большим вопросом.
2) Формулировка «за что отвечает» является больше мотивацией дробить чем объединять, то есть накладывает ограничение сверху по размеру модуля, по крайней мере в понимание начинающих программистов (практика опять таки показывает, что не только)
DrPass
31.05.2019 15:00и там чел сказал что проверяет SRP очень просто, он задаёт вопрос — что делает этот класс? Если ответ содержит в себе список функционала, то вывод очевиден ).
Я, кстати, к этой проблеме захожу «от противного». Задаю вопрос: «сколько мест в программе нужно изменить, чтобы изменить поведение такого-то функционала». Если количество равно 1, то SRP соблюдён, по крайней мере, на необходимом и достаточном уровне.v2kxyz
01.06.2019 20:03А если у вас есть класс, который например что-то считает и пишет результат в файл, что, ИМХО, нарушение SRP. И, допустим, Вам нужно поменять алгоритм формирования имени файла(это уже может быть третьим ответ на вопрос «что?). Вы вроде как поменяете одно место, но „лишний“ функционал в классе останется…
Но с вашим замечанием про необходимый и достаточный уровень согласен.DrPass
01.06.2019 23:57Смотрите, я вот что имел в виду:
1. SRP, как и все остальные принципы, не есть самоцель. Это инструмент для упрощения сопровождения кода, и именно с этой позиции его надо применять.
2. Нет четкого определения, где надо дробить функционал на составляющие. Эта «черта сложности» как раз и есть регулятор, определяющий степень применения инструмента. Мы должны стараться её провести так, чтобы получить наилучшее, кхм, упрощение.
Соответственно, в данном примере, если при прочих равных условиях разделение этого класса на два приведет к усложнению сопровождения кода, просто проведите черту SRP выше него. Ваш класс теперь согласно условиям решает одну задачу расчета и вывода данных.
taliban
01.06.2019 01:58+1А вам не кажется что налить, выпить и закусить, для человека, это три части одного действия? И с точки зрения организма человека лишение хоть одного из них чревато ошибкой, а значит они явно связаны жестко между собой. Правильно ли бить на 3 разные сущности один жестко связанный процесс?
tmteam Автор
01.06.2019 03:22Зависит от контекста задачи. В случае с оригинальной студенческой игрой эти ответственности определенно разделены ;)
Если же весь процесс действительно жестко сцеплен, то есть изменение одной его части повлечет изменение другой — то это разделение не только не нужно, но и вредно.
В терминах SRP — такое разделение противоречит идее «локализации изменений».
Исключением может быть объемный процес, допустим на 500 строк кода (цифра «зависит»). В таком случае нужно стараться изыскивать возможности разделения ответственности, из-за ограничений нашего с вами мозга. Разбить сложный процесс на взаимодействие нескольких более простых.taliban
01.06.2019 09:02Кстати, в контексте оригинальной студенческой задачи число игроков кратное 3 самый худший выбор, ибо нагрузка ложится на людей не равномерно, и если взять число игроков 4, то роли становятся задачами одной сущности, тоесть каждый должен уметь и наливать и пить и закусывать
tmteam Автор
01.06.2019 16:55К программированию эта нагрузка имеет мало отношения.
Если же волею ТЗ требуется взять именно 4 игрока, то разбиение выпивохи на сабличности тем более пригодится.
DrPass
ИМХО, это самая непонятная и запутанная попытка объяснить SRP, которые мне попадались :)
Zoolander
а что делать, если сам дядюшка Боб (Роберт Мартин) пишет, что понимание SRP запутано из-за его неудачного названия, из-за чего большинство думают, что каждый модуль должен отвечать за что-то одно. Этот принцип тоже есть и он тоже более чем оправдан, но под первым принципом SOLID подразумевается такая формулировка
«Модуль должен иметь одну и только одну причину для изменения. Пользователи и заинтересованные лица и есть та самая причина для изменений». (с) «Чистая архитектура», глава 7, страница 79
tmteam Автор
Эта фраза и была основной мотивацией написать эту статью, так как вторая ее часть еще более загадочна чем изначальное определение. На сайте у дядюшки Боба еще целую страницу он объясняет что значит «Пользователи и заинтересованные лица и есть та самая причина для изменений».
tmteam Автор
Переименовал статью в «Не такой простой, как кажется» ;)