Принцип единой ответственности гласит, что программный модуль должен иметь единственную ответственность, то есть у него должна быть одна и только одна причина для изменения.
Для того, чтобы понять как рассуждать, чтобы прийти к соблюдению этот принципа, давайте разберем пару типичных примеров. Первый взят из популярного репозитория.
class textEditor {
private:
stack<char> leftStack; //Left stack
stack<char> rightStack; //Right stack
public:
void insertWord(char word[]);
void insertCharacter(char character);
bool deleteCharacter();
bool backSpaceCharacter();
void moveCursor(int position);
void moveLeft(int position);
void moveRight(int position);
void findAndReplaceChar(char findWhat, char replaceWith);
void examineTop();
};
Данный пример примечателен тем, что на его основе можно проиллюстрировать сразу несколько ошибок связанных с принципом единственной ответственности. Спорное решение с использованием стеков, для хранения текста слева и справа от курсора, пока обсуждать не будем. Остановимся на наборе методов.
В первую очередь стоит выделить два метода (deleteCharacter, backSpaceCharacter) удаления символа. По логике они дублируют друг друга. Довольно распространенное нарушение рассматриваемого принципа, особенно если работа выполняется в команде. Аналогичные алгоритмы реализованные в разных частях кода, являются значительной проблемой при модификации.
Помимо этого, методы четко разделяются на три группы, работа с данными (insertCharacter, deleteCharacter), перемещение указателя (moveCursor, moveLeft, moveRight), остальные (insertWord, findAndReplaceChar, examineTop).
Группа методов для работы с данными имеет право быть включенными в этот класс, но при проектировании, необходимо задать вопрос. Будут ли хранимые данные менять свой тип. Ну например, планируется ли использовать Unicode вместо ASCII. Если ответ положительный эти методы стоит исключить из класса, а сам класс с хранящимся текстом сделать на основании абстрактного.
Методы перемещения указателя включены в класс, из-за неудачной реализации хранения данных. Как только это изменится, необходимость в этом отпадет. К этому набору можно отнести и метод examineTop, смысл которого тесно связан с методом хранения данных.
В дальнейшем, очевидно, что метод поиска и замены следует реализовать отдельно от класса хранения данных и разделить на два отдельных, а вот метод добавления слова необходимо рассмотреть под другим углом.
В приведенном примере слово рассматривается как массив символов, но в контексте текстового редактора, символы не имеют ключевого значения. Лингвистика, в разделе синтаксиса, рассматривает слово как единицу данных, далее следует предложение и абзац. Именно так пользователи работают с редактором, значит и нам следует организовать хранение данных таким образом.
Но и без доступа к отдельным символам нам не обойтись, нам нужна проверка орфографии, приставки, окончания и другая морфология. Следовательно и хранить все эти данные необходимо отдельно, символы, слова, предложения и абзацы, но создавать столько хранилищ довольно опрометчиво. Правило гласит, данные не должны дублироваться. Поэтому отбросив остальные контейнеры языка C++, остановимся на динамическом массиве (vector), который будет хранить символы. Динамический массив ссылается через итератор, а не через позиции, как контейнер строк, не понадобится постоянного пересчета. Контейнер с символами является основой, другие контейнеры дополняют. Они/он будут хранить ссылки на массив с символами, в таких местах где находятся слова, предложения и абзацы.
Этот пример показывает, как дробя крупные задачи, на более мелкие мы не только добиваемся гибкости кода, но и имеем возможность на этапе проектирования предотвращать возможные ошибки. Конечно, при этом мы должны чем то жертвовать, поэтому проекты разделены на множество мелких классов.
Следующий пример показывает прием разделения сложного алгоритма на более мелкие. Расчет стоимости товара. При продаже товара в интернет-магазине в цене товара учитываются различные начисления и удержания.
- Если покупки на сумму более 10 000 единиц, покупателю предоставляется скидка в 3%.
- Доставка товара на сумму более 15 000 единиц осуществляется бесплатно. В ином случае цена доставки составляет 5% от суммы.
Типовое решение этой задачи выглядит примерно как на первой диаграмме, в классе создается метод выполняющий расчеты. Это решение самое очевидное и простое, но и самое неудобное с точки зрения модификации.
Представим что отдел маркетинга периодически проводит акции, устанавливает скидки, бонусы, внедряет новые программы лояльности. Каждый раз вам придется переписывать подобный метод. Но сложность даже не в этом, а в том, что новый функционал расчета удаляет устаревший, вернуться к нему становится невозможно. Приходится добавлять в класс новые методы, и выполнять модификацию связанного кода, заново тестировать и прочее, а это не самый быстрый способ. При длительной эксплуатации такого программного обеспечения временные затраты на его обслуживание становятся больше чем на внедрение нового функционала.
Поэтому, первым шагом, когда вы сталкиваетесь с подобной задачей является разделение данных и действий над ними, что отражено во второй диаграмме. Теперь разберемся, почему вместо того, чтобы все условия объединить в одном методе, мы их разделили на отдельные. В данной задаче приведено всего два расчета, в реальности эта последовательность может быть достаточно длинной, и хуже всего, что она может меняться. Например, когда маркетинг решает поменять расчеты местами сначала начисляется сумма доставки, а потом применить скидку.
Именно по этим причинам в подобных ситуациях лучше использовать пошаговое выполнение действий, и это конечно не относится только к торговле, подобные ситуации встречаются довольно часто.
Но разбить действия мало необходим инструмент управления ими, и самый очевидным инструментом является паттерн «Цепочка обязанностей».
Для начала создадим абстрактный класс на основании которого будем реализовывать цепочку.
class AbstractCalculate {
protected:
std::shared_ptr<AbstractCalculate> handler;
public:
AbstractCalculate(std::shared_ptr<AbstractCalculate> handler) {
this->handler = handler;
}
inline virtual std::shared_ptr<Product> calculate(std::shared_ptr<Product> prd) const = 0;
};
Подробное, описание паттерна можно прочитать в справочной литературе, суть его в рекурсивном вызове метода. Поэтому продолжим с создания набора статических значений для реализации условий.
class DiscountRule {
public:
const static double amount;
const static double percent;
};
const double DiscountRule::amount = 10000.0;
const double DiscountRule::percent = 0.03;
class DeliveryRule {
public:
const static double amount;
const static double percent;
};
const double DeliveryRule::amount = 15000.0;
const double DeliveryRule::percent = 0.05;
Здесь они сгруппированы в отдельные классы, но если условия более сложные логичнее создать отдельные предикаты на основании constexpr.
Следующим шагом необходимо создать конкретные реализации расчетов и тестовый пример.
Далее приведен полный код реализации.
class Product {
private:
std::string m_name; // Наименование товара
double m_price; // Цена
public:
Product(std::string name, double price) :
m_name(name), m_price(price) {}
std::string name() const { return m_name; }
double price() const { return m_price; }
};
class AbstractCalculate {
protected:
std::shared_ptr<AbstractCalculate> handler;
public:
AbstractCalculate(std::shared_ptr<AbstractCalculate> handler) {
this->handler = handler;
}
inline virtual std::shared_ptr<Product> calculate(std::shared_ptr<Product> prd) const = 0;
};
class DiscountRule {
public:
const static double amount;
const static double percent;
};
const double DiscountRule::amount = 10000.0;
const double DiscountRule::percent = 0.03;
class DeliveryRule {
public:
const static double amount;
const static double percent;
};
const double DeliveryRule::amount = 15000.0;
const double DeliveryRule::percent = 0.05;
class Discount : public AbstractCalculate {
public:
Discount() : AbstractCalculate(nullptr) {}
Discount(std::shared_ptr<AbstractCalculate> handler) : AbstractCalculate(handler) {}
inline virtual std::shared_ptr<Product> calculate(std::shared_ptr<Product> product) const override {
std::shared_ptr<Product> prd = product;
if (product->price() > DiscountRule::amount) {
prd = std::make_shared<Product>(
product->name(),
product->price() - product->price() * DiscountRule::percent
);
}
if (handler != nullptr) {
return handler->calculate(prd);
}
return prd;
};
};
class Delivery : public AbstractCalculate {
public:
Delivery() : AbstractCalculate(nullptr) {}
Delivery(std::shared_ptr<AbstractCalculate> handler) : AbstractCalculate(handler) {}
inline virtual std::shared_ptr<Product> calculate(std::shared_ptr<Product> product) const override {
std::shared_ptr<Product> prd = product;
if (product->price() < DeliveryRule::amount) {
prd = std::make_shared<Product>(
product->name(),
product->price() + product->price() * DeliveryRule::percent
);
}
if (handler != nullptr) {
return handler->calculate(prd);
}
return prd;
}
};
int main() {
auto product = std::make_shared<Product>("", 11000.0);
auto chainlet = std::make_shared<Discount>(
std::make_shared<Delivery>()
)->calculate(product);
assert(chainlet->price() == 11203.5);
return 0;
}
Комментарии (14)
JordanCpp
26.08.2024 05:43- Если покупки на сумму более 10 000 единиц, покупателю предоставляется скидка в 3%.
- Доставка товара на сумму более 15 000 единиц осуществляется бесплатно. В ином случае цена доставки составляет 5% от суммы.
Код для этого сделан плохо. Причем
Discount дублирует Delivery, см метод calculate.
Я бы сделал по другому.
Класс который применяет фильтры Discount и Delivery, то есть метод calculate внутри себя вызывает указатели на vector указателей на Discount и Delivery.
И тогда если добавится новая скидка, мне ненужно реализовывать метод
calculate, я просто напишу еще один фильтр и добавлю его как push_back к двум предыдущим. В этом же смысл SPR, не?
Попробуйте переписать код и он станет и меньше и проще.
JordanCpp
26.08.2024 05:43Пример кода, накидан в блокноте. Суть ясна.
class SaleInfo { public: int Condition; int Percent; }; class SaleService { public: bool Calc(const std::vector<SaleInfo>& list) { // sort list от большего к меньшему for (auto& i : list) // access by reference to avoid copying { if (i.Condition >= Cost) { Cost += i.Percent; //Скидка применилась return true; } } //Скидка не прошла return false; } };
JordanCpp
26.08.2024 05:43class textEditor
Тема не раскрыта. К примеру пусть у нас есть структура документа textData
Для оперирования над данной структурой. Создаем классы textFinder, textReplacer ит.д
И в аргументы методов передаем textData. textData не знает о других классах, а вот как раз внешние классы о ней знают и могут ее модифицировать.
Итог: когда показывают примеры на слонах и пингвинах, выглядит хорошо. При усложнении кода связей, требуется дополнительная аналитика.
aavezel
К SRP принцип единой ответственности не имеет отношение. Под SRP понимается принцип единого ответственного. Это хотя часто взаимозависимые понятия, но они в корне разные.
DenSigma
Простите, что за "принцип единого ответственного"? Принцип единственной ответственности (SRP) я знаю https://ru.wikipedia.org/wiki/%D0%9F%D1%80%D0%B8%D0%BD%D1%86%D0%B8%D0%BF_%D0%B5%D0%B4%D0%B8%D0%BD%D1%81%D1%82%D0%B2%D0%B5%D0%BD%D0%BD%D0%BE%D0%B9_%D0%BE%D1%82%D0%B2%D0%B5%D1%82%D1%81%D1%82%D0%B2%D0%B5%D0%BD%D0%BD%D0%BE%D1%81%D1%82%D0%B8, а что за другой принцип "единого ответственного"?
aavezel
Ну, почитайте источники, блин, а не наколенное использование:
В той же английской вики написано всё верно
DenSigma
Вы смешали вместе принципы хорошего кода для процедурного программирования и для ООП. Да, Одна функция должна отвечать за что-то одно. Но это принцип процедурного программирования. Он остался еще в шестидесятые, не надо его тянуть в SOLID. Это раз.
Да, проблема внедрения SOLID и SRP обусловлена еще и тем, что пользователи, ознакомившись с принципом "Класс (модуль) должен отвечать за что-то одно", решают, что этот класс имплементирует только один метод (я с этим сталкиваюсь). Буквально, один класс- один метод Invoke. Нет, Дядя Боб (чуть не описался Дядя Бог), объяснял, что класс может иметь хоть сто публичных методов, но он должен делать что-то одно. Да, он говорил, что класс должен делать что-то только для одного актора, грубо говоря, класс может делать выписку счета, сторнирование счета, корректировку счета - только для бухгалтера, но не имеет право делать что-то для расчетчика, считать расход электроэнергии, к примеру.
Но это тоже не то. Печать счета - это для бухгалтера, по-вашему? То есть, нужно имплементировать функционал печати счета в класс Bill, ведь в нем заинтересован один и тот-же бухгалтер?
Нет, согласно идеологии чистого кода, печать (для вывода на принтер, для вывода на экран, для вывода в PDF файл) должны осуществлять другие классы (каждый свой) по определенному интерфейсу. В классе счета (как и в классе процессора счетов), кстати, даже dependency ни на интерфейс, ни на тем более классы печати быть не должно, согласно принципу инверсии зависимостей.
И тем более, какие пользователи, группы или лица должны быть отражены в классах, скажем работы с файловой системой, скажем в Java? При том, что набор классов работы с потоками и файлами очень грамотно реализован, в соответствии с SOLID.
Вся система Spring реализована на SOLID, буквально можно изучать как учебник. Какие там акторы?
k4ir05
По сути, он верно пишет.
And this gets to the crux of the Single Responsibility Principle. This principle is about people.
DenSigma
Спасибо
aavezel
Ну во первых, не я путаюсь, а выше большая цитата из книги Чистая архитектура уже указанного автора. И если у вас есть возражения к автору, то у меня для вас плохие риторические новости.
Во вторых, мы не говорим класс в контексте SRP. Мы говорим о модуле. В модуле может быть несколько классов. А может быть и один. И если вам для реализации печати нужен какой то свой класс - то делайте его, но не называйте это следованию принципа SRP. Можете назвать это принципом разделения функционала, или принципом единообразия реализации, но не SRP. И да, если классу надо уметь себя распечатывать, то надо просто добавить в него метод print, а не создавать класс [ObjectType]Printer. За исключением, если ваш бухгалтерский отчет должен распечатывать, например, специалист по закупкам, в своем, закупочном, формате. Тогда, да, согласно SRP мы выделяем отдельный метод для формирования нового класса BookingReportPurchasing, из нового модуля, и отдельный метод print в этом классе (ну по вашему отдельный класс BookingReportPurchasingPrinter), а ответственность за аналитику модуля "возлагаем" на специалиста по закупкам.
Так что не надо чистый код путать с чистой архитектурой. Это 2 раздельных уровня. Архитектору, часто, не важно как ваш internal код реализован, это важно техлиду вашей группы, максимум. А вам, как чистокодмену не важна архитектура, пока к вам не спустится архитктор и не спросит: а какого нахрен черта.
В этом и есть смысл SOLID. Если у вас актор из Spring.Boot начнет влиять на реализацию в Spring.Security - это нарушение SRP. Но, если у вас у какого то класса оказалось 100500 методов внутри Spring.Boot, вопросы конечно возникнут в сфере разделения функционала, но вопросы в сфере разделения ответственности - не будет.
Matie_Bal
Вы оперируете в контексте единой ответственности функциями, хотя главное это данные, с которыми работают функции. Вся соль в связности классов.
k4ir05
Да это бесконечный холивар, тут бесполезно убеждать. Сам принцип слишком абстрактый. Поэтому его каждый понимает по-своему. Либо из-за того, что он изначально сформулирован не совсем удачно.
just-a-dev
Это не может быть правдой, потому что в английском не употребляются прилагательные без существительного. Для мифического "принципа единого ответственного" аббревиатурой было бы, например, SROP (single responsible object principle).
aavezel
Хоть принцип действительно называется single-responsibility principle (через дефис), но отвечает он не за функционал, а за актора: A module should be responsible to one, and only one, actor