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

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

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;
}

Автор: Юрий Е. - 2024 г.