Страницы

среда, 13 августа 2014 г.

Single Responsibility Principle

Цикл статей о SOLID принципах

--------------------------------------------------

Принцип единственной обязанности (Single-Responsibility Principle, SRP): У класса должна быть только одна причина для изменения.
Роберт Мартин. Принципы, паттерны и практики гибкой разработки

Существует ряд патологических случаев нарушения принципа единственной обязанности, которые будут очевидны сегодня практически любому. Классы бизнес-логики, которые знают о пользовательском интерфейсе или о базе данных; класс windows-сервиса c кучей бизнес-логики; статические утилитные классы, изменяющие глобальное состояние и т.п.

При этом нарушение SRP бывают как на уровне классов/модулей, так и на уровне подсистем и приложений. Классическим примером из моей практики являются два вопиющих нарушения SRP на уровне приложений: использование Windows Forms приложения в качестве WCF сервиса, и необходимость взаимодействия виндового сервиса с пользователем через Message Box-ы.

Приведенные выше случаи не столько нарушают SRP, сколько противоречат здравому смыслу. А как быть с более тонкими проблемами, когда один разработчик говорит, что дизайн хорош, а для другого он серьезно «попахивает» и вам хочется найти весомые аргументы против текущего решения?

Но прежде чем ответить на этот вопрос, нужно понять, а для чего вообще существует принцип SRP? В чем проблема, когда у класса есть более одной обязанности или причины для изменения? С какими проблемами он призван бороться?

Для чего нужен SRP?

SRP предназначен для борьбы со сложностью. На самом деле, любой принцип или паттерн проектирования предназначен для этого. Когда в нашем приложении всего 200 строк, то дизайн как таковой вообще не нужен. Достаточно аккуратно написать 5-7 методов и все будет хорошо. Проблемы возникают, когда система растет и увеличивается в масштабах. Поскольку сложность растет не линейно при увеличении размера приложения приходится подходить к дизайну более осмысленно, вспоминать всякие страшные слова, типа абстракции и сокрытия информации, лучше продумывать обязанности каждого класса, чтобы у разработчика сегодня и через год была возможность сосредоточиться на главной проблеме класса/модуля и проигнорировать второстепенные детали.

ПРИМЕЧАНИЕ
Подробнее о дизайне и критериях его качества читайте в заметках: Критерии плохого дизайна и О дизайне.

SRP и борьба со сложностью

Давайте рассмотрим такой пример. R# API предоставляет абстракцию под названием Context Actions (кстати, нечто подобное появится и в API Visual Studio 2015!). Это такая штука, которая появляется при нажатии Alt + Enter и с ее помощью можно делать много всего интересного: удалять ненужные выражения, преобразовывать LINQ-запросы и т.п.

Контекстные действия представлены базовым классом ContextActionBase, который содержит следующие ключевые члены:

public abstract class ContextActionBase
{
   
public abstract void
Execute();
   
public abstract bool
IsAvailable();
   
public abstract string Text { get; }
}

(На самом деле интерфейс R# API несколько отличается от этого, но суть именно такая.)

Когда я начинал разрабатывать плагин для упрощения контрактного программирования, то я начал с реализации контекстных действий по добавлению предусловий для проверки аргументов метода на null: с класса AddRequiresContextAction.

Поскольку изначально в голову приходят лишь небольшое число граничных случаев, то вначале класс AddRequiresContextAction содержал всю логику: логику по определению доступности добавления предусловия (например, что аргумент метода является ссылочного типа, он еще не проверен на null и т.п.), а также логику по добавлению предусловия для текущего класса.

Нарушал ли данный класс SRP? Сложно сказать. И да, и нет. С одной стороны, сам интерфейс базового класса говорит о необходимости выполнения нескольких действий, с другой стороны у класса на тот момент было всего три метода, каждый из которых отвечал за определенный аспект поведения, а их реализация была довольно простой. Можно сказать, что данный класс был «крепко связанный» (highly cohesive), а значит нарушать SRP не мог.

Но по мере того, как росло мое понимание предметной области добавлялось все больше и больше граничных условий. Со временем я понял, что если аргумент уже проверяется с помощью if-throw, то контекстное действие по добавлению Contract.Requires должно отсутствовать; что действие также должно быть недоступным, если значение аргумента по умолчанию равно null, что нужно учитывать еще и абстрактные классы и т.п. В результате мой класс AddRequiresContextAction начал изменяться и граф вызовов его внутренних методов стал выглядеть следующим образом:

clip_image002

Теперь даже по рисунку видно, что в классе две группы обязанностей, которые тесно взаимодействуют внутри, но никак не взаимодействуют между собой. Очевидно, что у данного класса появилось две ярко выраженные ответственности: проверка допустимости действия и его исполнение, которые не были столь очевидны до этого. В результате, я выделил два дополнительных класса: AddRequiesAvailability и AddRequiresExecutor, которые отвечают за «доступность» действия и за его «выполнение».

Более того, со временем даже эти два класса были разбиты на более мелкие составляющие, так что один класс в результате «вырос» в целый граф взаимодействующих объектов:

clip_image004

(Текущая версия класса AddRequiresContextAction выглядит так).

А в чем проблема в исходном решении? Зачем нужно было выделять два дополнительных класса? Чтобы классы соответствовали принципу, описанному дядюшкой Бобом?

Когда класс разрастается, он просто перестает помещаться в голове. Навигация затрудняется, на глаза попадаются ненужные детали, связанные с другим аспектом, в результате, количество понятий начинают превышать священные 7 ± 2 и мы начинаем терять контроль над кодом.

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

SRP в реальной жизни

Я честно признаюсь, что не понимаю принцип SRP в формулировке от «дядюшки» Боба и его метафору с «осью изменения» (мне понятна эта метафора в контексте Open-Closed Principle, но об этом мы поговорим в следующий раз.) Определение довольно запутанное, да и пояснение лишь сбивает с толку:

«В контексте принципа SRP мы будем называть обязанностью причину изменения. Если вы можете найти несколько причин для изменения класса, то у такого класса более одной обязанности.

Следует ли разделить эти обязанности? Все зависит от того, как именно изменяется приложение.

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

Отсюда вытекает следствие. Ось изменения становится таковой, только если изменение имеет место. Неразумно применять SRP – как и любой другой принцип, – если для того нет причин.»

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

Для меня SRP – это способ поиска скрытых абстракций, достаточно сложных, чтобы им отвели отдельную именованную сущность и спрятали в их недрах все детали. Разделение классов на составляющие диктуются не «осями изменений», а здравым смыслом и попыткой справиться с нарастающей сложностью системы.

Дополнительные ссылки

11 комментариев:

  1. >>R# API предоставляет абстракцию под названием Context Actions (кстати, нечто подобное появится и в API Visual Studio 2015!). Это такая штука, которая появляется при нажатии Alt + Enter и с ее помощью можно делать много всего интересного: удалять ненужные выражения, преобразовывать LINQ-запросы и т.п.
    --А не подскажете инфу, с какой вы получили эти сведения о Visual Studio 2015? " Сейчас доступна Visual Studio "14" CTP которая благодаря Roslyn умеет уже частично делать те действия которые вы описали. Странно выглядит что Microsoft презентует новый продукт с линейки Visual Studio, не выпустив до этого окончательный вариант Visual Studio "14".

    ОтветитьУдалить
    Ответы
    1. Есть официальные заявления, что VIsual Studio "14" выйдет в 15-м году, а исходя из их маркировки она будет называться Visual Studio 2015. А исходя из того, что в 14-й студии эти действия есть уже в CTP, то можно сделать выводы, что они полноценно появятся именно в Visual Studio 2015.

      Удалить
  2. > использование Windows Forms приложения в качестве WCF сервиса

    Забавно, но на такой пример я наткнулся в "Programming WCF Services by Juval Lowy". Причем обсуждается хостинг в потоке формы, гибридный класс FormHost и прочие малоприятные вещи.
    Это годится для "коленочного решения", но никак не для учебной (относительно) литературы.

    ОтветитьУдалить
    Ответы
    1. Александр, Juval приводит эти примеры, поскольку нужно покрыть эти темы. Хотя при этом и правда нужно предупреждать, что делать так в продакшн коде нельзя не в коем случае.

      Удалить
    2. А в чем причина? Скажем, если мне нужно контролировать WCF сервис и получать от него информацию по тому, какие функции и с каким параметрами вызывали (в моем случае, имеется сервис заявок, клиенты могут ставить заявки, нужно выводить список текущих, и иметь возможность отменить их вручную на стороне сервиса, и настраивать доступ к бирже). Я так понимаю, в таком случае надо чтобы сервис реализовывал два контракта: для выставления заявок и для управлением доступа к бирже (для клиента на сервере), но в тоже время получается нужен какой-то callback на выставление сделки чтобы клиент на сервере мог получать информацию о сделках. Звучит как-то монструозно.
      И как тогда правильно хостить WCF службы? Путем создания windows services и логированием в файл или windows event viewer?

      Удалить
  3. Сергей, спасибо за этот цикл статей. Можно попросить добавить к этой статье тег SOLID, как на следующих двух? Я бы себе закладочку сделал :) Спасибо

    ОтветитьУдалить