Принцип открытости/закрытости гласит, что программные объекты (классы, методы, функции и т. д.) должны быть открыты для расширения, но закрыты для модификации.

Идеальной реализацией данного принципа является интерфейс. Ничего лишнего, нечего модифицировать, можно только расширять.

class IMyInterface {
public:
	virtual void execute() = 0;
};

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

interface IRepository<T> : IDisposable 
        where T : class
{
    IEnumerable<T> GetBookList(); // получение всех объектов
    T GetBook(int id); // получение одного объекта по id
    void Create(T item); // создание объекта
    void Update(T item); // обновление объекта
    void Delete(int id); // удаление объекта по id
    void Save();  // сохранение изменений
}

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

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

Остались последние четыре метода. Очевидно, что автор примера пытался применить понятие CRUD — акроним, обозначающий четыре базовые функции, используемые при работе с базами данных: создание (англ. create), чтение (read), модификация (update), удаление (delete)i. Но даже в определении сказано, что это только сокращение, не требующее строгого соответствия. Следовательно и включать все эти методы в один класс не целесообразно.

Здесь стоит сделать отступление. Искушенный читатель, может указать, что реализация CRUD в одном классе является шаблоном ActiveRecordii. Но сам шаблон является довольно спорным. Читателю стоит ознакомиться с определением, материалами в примечаниях, и самому принять решение, использовать ли его в разработке.

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

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

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

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

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

Диаграмма классов
Диаграмма классов

Согласно условию задачи, мы должны иметь возможность расширить функционал не нарушая описываемого принципа. Класс Product может быть расширен путем добавления полей в дочерних классах. Инструмент преобразования элемента в строку мы можем создавать для каждого дочернего класса с данными, не исключая базового. К тому же можем создавать новые форматы преобразования отличные от JSON, не меняя структуру программы. Класс сериализации массива данных, обеспечивает форматирование только концевых элементов, следовательно его можно изменять, когда существенно меняется формат данных, например с JSON на XML, или SQL.

Поясняющая диаграмма классов выглядит следующим образом.

Диаграмма классов
Диаграмма классов

Ниже приведен полный код примера.

enum Classifier { NONE, CEREALS, DRINKS, PACKS };

class Product {
private:
    std::string m_name;          // Наименование товара
    Classifier m_category;       // Классификатор товара
    double m_price;              // Цена
public:
    Product(std::string name, Classifier category, double price) :
        m_name(name), m_category(category), m_price(price) {}
    std::string name() const { return m_name; }
    Classifier classifier() const { return m_category; }
    double price() const { return m_price; }
};

class ISerialize {
public:
    virtual std::string serialize(const std::shared_ptr<Product> obj) const = 0;
};

class ProductToJSON : public ISerialize {
public:
    virtual std::string serialize(const std::shared_ptr<Product> obj) const {
        std::string str;
        str += "{name:" + obj->name() + ",";
        str += "classifier:" + std::to_string(obj->classifier()) + ",";
        str += "price:" + std::to_string(obj->price()) + "}";
        return str;
    };
};

class ISerialization {
    virtual void serialization() = 0;
    virtual std::string str() const = 0;
};

class SerializationToJSON : public ISerialization {
private:
    std::stringstream sstream;
    std::list<std::shared_ptr<Product>> products;
    std::shared_ptr<ISerialize> serializer;
public:
    SerializationToJSON(std::shared_ptr<ISerialize> serializer,
        const std::list<std::shared_ptr<Product>>& products) {
        this->serializer = serializer;
        this->products = products;
    }
    virtual void serialization() {
        sstream << "[";
        for (auto& elem : products) {
            sstream << serializer->serialize(elem);
        }
        sstream << "]";
    }
    virtual std::string str() const {
        return sstream.str();
    }
};

int main() {

    std::list<std::shared_ptr<Product>> products{
        std::make_shared<Product>("Product 1", Classifier::CEREALS, 500),
        std::make_shared<Product>("Product 2", Classifier::DRINKS, 400),
        std::make_shared<Product>("Product 3", Classifier::PACKS, 300)
    };

    SerializationToJSON serializer(std::make_shared<ProductToJSON>(), products);
    serializer.serialization();
    std::cout << serializer.str() << std::endl;

    return 0;
}

Комментарии (11)


  1. hardtop
    02.09.2024 07:01
    +7

    Странная статья, начало и конец писали как будто 2 разных человека. Начали с GetBook, а потом переключились на Product. Использование double для цены (вместо decimal) - прекрасный способ выстрелить себе в ногу.


  1. amazingname
    02.09.2024 07:01

    Что насчёт принципа разделения интерфейсов?


    1. xentoo
      02.09.2024 07:01

      Ага. И еще зависимость от абстракций (dependency inversion) автор профукал. Расширять он продукт захотел, и сразу же зависимый от простого класса интерфейс создал. Если автор планирует сразу расширяемый "продукт" то ему надо начинать с абстрактного класса. Ну так себе..


  1. AzatJ
    02.09.2024 07:01
    +3

    Непонятно, почему первые два метода нарушают принцип, GetBookList возвращает IEnumerable, а пользователь уже может с этим IEnumerable сделать все что угодно


  1. grgdvo
    02.09.2024 07:01
    +1

    Больше похоже, что пример в начале нарушает SRP, а не OCP.


    1. mrobespierre
      02.09.2024 07:01

      Поддержу, статья про другой принцип SOLID. Принцип открытости-закрытости простыми словами звучит так: "любые изменения в систему должны вноситься за счёт написания нового кода, а не изменения старого". Т.е. мы пишем новый модуль/компонент и заменяем им старый, остальные модули/компоненты не изменяются и работают как раньше. Интерфейс, кстати, действительно в этом помогает: замена одной реализации интерфейса на другую - идеальная демонстрация.


  1. Gsom1
    02.09.2024 07:01

    Если мы просто нарезаем на тонкие интерфейсы, что имеется ввиду под расширением?
    interface IRepository : IDisposable where T : class { IEnumerable GetAll(); }


  1. glebchansky
    02.09.2024 07:01

    Что ж это за привычка-то такая делать пример кода на плюсах, когда на них кодишь откровенно плохо?

    1) Передача строки в конструктор/сеттер класса по значению, а дальше просто присваивание полю класса без мув, в итоге 2 лишних копирования в худшем случае

    2) Аналогично передача жирного std::list и отсутствие его перемещения в поле класса

    3) Геттер строки без константной ссылки в классе Product. Там будет чистейшее копирование, это не то место, где будет оптимизация NRVO/Copy Elision

    4) Использование `operator +` для строк вместо append, из-за чего там также куча глубоких копирований строк


    1. voldemar_d
      02.09.2024 07:01

      С оператором + не всё так просто:

      https://en.cppreference.com/w/cpp/string/basic_string/operator%2B

      Емнип, как раз в std::string предприняты усилия, чтобы оператор + не делал лишних копирований.


  1. rukhi7
    02.09.2024 07:01

    Здесь представлены сразу несколько нарушений ограничивающих гибкость кода.

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

    На самом деле достаточно рассмотреть единственное нарушение которое не просто ограничивает, а убивает на корню всю гибкость кода.

    Дело в том что тип этого интерфейса не полностью определен так как для его определения нужен еще тип параметра темплейта.

    Код который работает с таким интерфейсом определенным для какого-то класса А гарантированно не сможет работать если вместо указателя на IRepository<А> в него попадет указатель на IRepository<B> :

    например:

    var *ptr = getPtr();

    А item;

    ptr->Create(item);//странная функция создания объекта который уже создан, кстати, но бог с ней, что есть с тем и работаем

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

    Нетрудно заметить что даже функция getPtr(); в данном случае должна быть параметризована, но чтобы она смогла вернуть объект другого типа не получится обойтись без перекомпиляции этого вызывающего кода.


  1. AlexeySu
    02.09.2024 07:01

    Параметры в виде shared_ptr точно нарушают ISP