Применение принципа единой ответственности на практике

Применение принципа единой ответственности на практике

27 марта 2024 г.

Принцип единой ответственности (SRP) — один из пяти SOLID-принципов проектирования кода. Он гласит, что единица кода должна иметь только одну причину для изменения. Однако этот принцип часто неправильно понимают и неправильно применяют, как мы можем видеть в слишком многочисленных «больших (или распределенных) комках грязи» и «спагетти» базах кода.

В этой статье мы рассмотрим, что такое SRP и как сделать его соблюдение практичным и полезным.

Прежде всего, почему SRP? Почему нас должно это волновать? SRP помогает писать код, который легче понять, поддерживать и расширять. По сути, возможность следовать SRP — один из ключевых факторов написания чистого кода. Следующее, что SRP можно применять не только к классам, но и к методам и контрактам в целом.

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

Ирония в том, что, наверное, нет ни одного разработчика, который бы не знал о SRP или сказал бы, что это не важно.

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

Как часто вводят SRP и почему это на самом деле не помогает

Обычно описание SRP начинается с примера, в котором несколько различных обязанностей объединены вместе, а затем эти обязанности разделяются на разные классы и указывается, что вот и все, вот SRP.

>

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

После того, как задачи разделены, нам все равно необходимо реализовать функциональность, которая использует их вместе в одном потоке. Таким образом, SRP заключается не в разделении различных задач, а в первую очередь в том, как правильно определить обязанности. А определение «правильного» исходит из определения самого SRP: «у единицы кода должна быть только одна причина для изменения».

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

Давайте зададим себе вопрос: понадобится ли когда-нибудь этому программному обеспечению другой способ компиляции или построения отчета? Или другой способ распечатать его или сохранить в другой форме представления? Действительно ли у него есть такие причины для изменения?

Ответ на эти вопросы исходит из бизнес-требований — речь идет не о самом коде или только о самом коде.

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

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

Хорошо, все это звучит хорошо, но может возникнуть вопрос: почему бы просто не разделить проблемы и покончить с этим? Даже если такое разделение никогда не потребуется нашему программному обеспечению, даже если у нас будет только одна реализация каждой из этих задач и, возможно, только одно применение для каждой из них — почему бы не разделить их? Мы можем иметь более чистую реализацию, более соответствующую SRP, «чистому коду» и т. д.

Опять же, если наше программное обеспечение маленькое и простое, такое разделение не повредит.

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

Таким образом, это не поможет сделать наш код чище и проще для понимания и сопровождения. При описании принципов SOLID и SRP в частности часто упускают из виду, что за все приходится платить, и нам необходимо сбалансировать эти затраты, чтобы сохранить наш код поддерживаемым и понятным.

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

Ну и следующий возникающий и весьма обоснованный вопрос может быть о тестируемости такого кода, если не разделять опасения. И да, разделение задач может быть вполне оправдано, чтобы сделать код более тестируемым, поскольку это часто является частью наших NFR (нефункциональных требований). Но опять же, стоит ли нам действительно вводить новые абстракции и зависимости только для того, чтобы сделать наш код более тестируемым? Можно ли его протестировать без введения новых абстракций?

И ответ, как ни удивительно, часто может быть «да». Следуя примеру Википедии, мы по-прежнему можем применять SRP на уровне методов, не вводя дополнительных абстракций, и такие методы вполне можно тестировать.

SRP с точки зрения контрактов

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

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

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

Контракт можно описать в терминах отношений «зависимость-потребитель»: другой фрагмент кода обеспечивает некоторые варианты поведения, используемые нашей единицей кода, и это поведение может создавать некоторые данные, необходимые нашей единице кода. В свою очередь, наша единица кода потребляется другими единицами кода.

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

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

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

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

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

Главный вопрос заключается в стоимости: что будет дешевле: изменить наш контракт и реализацию при изменении зависимостей, тем самым вызывая, в свою очередь, потенциальные изменения в наших потребителях, или ввести дополнительные абстракции, которые изолируют наш контракт от этих изменений?

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

Под «стоимостью» я подразумеваю не только время и усилия, необходимые для реализации конкретного изменения, но и то, насколько дорогостоящим будет поддерживать и расширять код в будущем. Многие из нас слишком хорошо знают: то, что стоит сделать сразу, может и будет стоить очень дорого в будущем.

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

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

SRP и дырявые абстракции

<блок-цитата>

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

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

Другой пример — когда выходные данные метода класса должны быть дополнительно обработаны другим методом того же класса в определенных сценариях, которые потребляющий код не контролирует, но вынужден проверять.

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

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

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

* Действительно ли контракту нужен определенный входной параметр?

* Является ли представленное поведение атомарным и самодостаточным, или потребителям придется вызывать дополнительные действия нашего контракта на условиях, не определенных потребителем?

* Раскрывает ли контракт какие-либо внутренние или даже публичные положения, которые не требуются потребителям?

Давайте рассмотрим эти контрольные точки более подробно, чтобы понять, почему они могут быть связаны с дырявыми абстракциями и SRP.

Действительно ли контракту нужен определенный входной параметр?

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

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

Является ли представленное поведение атомарным и самодостаточным?

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

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

Раскрывает ли контракт какое-либо внутреннее или даже публичное состояние, которое не требуется потребителям?

Помимо риска потенциального нарушения изменений для потребителей, раскрытие внутреннего или ненужного состояния может также привести к неожиданному связыванию или «причинам для изменения». Допустим, потребители нашего контракта стали полагаться на непреднамеренно раскрытое состояние, и по какой-то причине обновлять потребителей теперь невозможно или слишком дорого; при этом это состояние не может или не должно поддерживаться нашим кодом, поскольку требования изменились, и код следует обновлять.

Таким образом, наш код будет вынужден поддерживать это состояние и реализовывать дополнительный обходной путь, чтобы потребители работали — теперь у нас есть дополнительная «причина для изменений» для нашего кода.

Использование дырявых абстракций

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

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

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

Подведем итог...

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

Тем не менее, можно обозначить некоторые моменты, на которые следует обратить внимание при создании проектов или реализации кода, которые помогут в большей степени соответствовать принципу единой ответственности в контексте, существующем на данный момент.

  1. Опишите обязанности внутри единиц кода на основе действительных «причин для изменений», которые определяются бизнес- и нефункциональными требованиями, а не самим кодом. «Если оно не сломано, не чините его. Если оно не нужно, не добавляйте его».
  2. 2. Прежде чем извлекать или создавать новую абстракцию, подумайте, действительно ли она нужна — проверьте «причины изменения» для уже существующего кода и для новой абстракции. Если они не отличаются, возможно, лучше сохранить код вместе.

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

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

    5. Проверьте наличие дырявой абстракции в вашем коде и необходимых ему зависимостях. Если вы видите, что абстракции просачиваются, рассмотрите возможность замены их более стабильными абстракциями. Стоимость использования дырявых абстракций, или, лучше сказать, риск, должна быть сбалансирована со стоимостью их замены.

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


    Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE