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

Трудно предоставить разумный пример иллюстрирующий этот принцип, так как соблюдение элементарной логики и правил чистого кода по именованию методов и переменных, не позволяет его нарушить. Если в базовом классе есть метод save(), отвечающий за сохранение информации, а вы не пытаетесь его переделать для загрузки данных, у вас все в порядке.

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

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

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

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

-- create
CREATE TABLE PRODUCTS (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  price REAL NOT NULL,
  category INTEGER NOT NULL
);

-- insert
INSERT INTO PRODUCTS (name, price, category) VALUES ('Product1', 500.0, 1);
INSERT INTO PRODUCTS (name, price, category) VALUES ('Product2', 400.0, 2);
INSERT INTO PRODUCTS (name, price, category) VALUES ('Product3', 300.0, 3);

А интерфейс для записи данных в базу и его реализация примерно так.

class ISQLCommand {
public:
    virtual const std::string toSQL() const = 0;
};

class AddProduct : public ISQLCommand {
private:
    std::shared_ptr<Product> product;
public:
    AddProduct(std::shared_ptr<Product> product) {
        this->product = product;
    }
    virtual const std::string toSQL() const {
        std::string sql = "INSERT INTO PRODUCTS (name, price, category) VALUES ('";
        sql += product->name() + "', ";
        sql += std::to_string(product->price()) + ", ";
        sql += std::to_string(product->classifier()) + " );";
        return sql;
    }
};

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

-- create
CREATE TABLE PRODUCTS (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  price REAL NOT NULL
);

CREATE TABLE CEREALS (
  product INTEGER NOT NULL
);

CREATE TABLE DRINKS (
  product INTEGER NOT NULL
);

CREATE TABLE PACKS (
  product INTEGER NOT NULL
);

-- insert
INSERT INTO PRODUCTS (name, price) VALUES ('Product1', 500.0);
INSERT INTO CEREALS (product) SELECT id FROM PRODUCTS WHERE name = 'Product1';

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

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

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

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

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

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

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

В первую очереди рассмотрим как с помощью компоновщика создаются команды для записи в базу данных.

Диаграмма зависимостей
Диаграмма зависимостей

Существует три таблицы значений классификатора товара. В каждую из них необходимо поместить ID товара который принадлежит этому значению классификатора. Для реализации функционала создается соответствующее количество команд реализованных в виде классов AddCereals, AddDrinks, AddPacks. Каждая из этих команд может быть выполнена самостоятельно, например при пере классификации товаров.

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

Команда AddProduct записывает все данные о товаре единовременно, используя как составные части другие команды.

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

Следующий момент, на котором бы хотелось остановить внимание это функциональный объект.

class ClassifierValues {
public:
    inline std::vector<std::shared_ptr<ISQLCommand>> operator()
        (std::shared_ptr<Product> product) const {
        std::vector<std::shared_ptr<ISQLCommand>> vec{
            std::make_shared<AddCereals>(product),
            std::make_shared<AddDrinks>(product),
            std::make_shared<AddPacks>(product),
        };
        return vec;
    }
};

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

Однако этот класс не обязан быть реализован в виде полноценного функционального объекта. Это учебный пример и этот класс сделан именно так, чтобы не нарушать принципов объектно-ориентированного программирования. Но язык C++ дает нам возможность упростить этот участок, использовав для этих целей функциональные объекты из стандартной библиотеки, например std::function.

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

Последним участком кода на который хотелось бы обратить ваше внимание, является класс.

class DataBaseQuery {
private:
    std::shared_ptr<DBConnection> connection;
    std::shared_ptr<ISQLCommand> command;
public:
    DataBaseQuery(std::shared_ptr<DBConnection> connection,
        std::shared_ptr<ISQLCommand> command) {
        this->connection = connection;
        this->command = command;
    }
    void execute() const {
        connection->execute(command->toSQL());
    }
};

Его задача объединить подключение к базе данных и передаваемые команды.

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

Диаграмма зависимостей
Диаграмма зависимостей

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

Отделение соединения от команд позволит:

  • правильно организовать обработку исключительных ситуаций,

  • возможность использования много поточного подключения,

  • при необходимости перейти на другую базу данных без существенных затрат на переработку исходного кода.

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

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

class DBConnection {
public:
    DBConnection() {
        /* Реализация RAII */
        std::cout << "Connection..." << std::endl;
    }
    void execute(std::string str) {
        /* Имитация выполнения запроса */
        std::cout << str << std::endl;
    }
    virtual ~DBConnection() {
        std::cout << "...Disconnection" << std::endl;
    }
};

class ISQLCommand {
public:
    virtual const std::string toSQL() const = 0;
};

class AddCereals : public ISQLCommand {
private:
    std::shared_ptr<Product> product;
public:
    AddCereals(std::shared_ptr<Product> product) {
        this->product = product;
    }
    virtual const std::string toSQL() const {
        std::string sql = "";
        if (product->classifier() == Classifier::CEREALS) {
            sql += "INSERT INTO CEREALS (product) SELECT id FROM PRODUCTS WHERE ";
            sql += "name = '" + product->name() + "' AND ";
            sql += "price = " + std::to_string(product->price()) + ";";
        }
        return sql;
    }
};

class AddDrinks : public ISQLCommand {
private:
    std::shared_ptr<Product> product;
public:
    AddDrinks(std::shared_ptr<Product> product) {
        this->product = product;
    }
    virtual const std::string toSQL() const {
        std::string sql = "";
        if (product->classifier() == Classifier::DRINKS) {
            sql += "INSERT INTO DRINKS (product) SELECT id FROM PRODUCTS WHERE ";
            sql += "name = '" + product->name() + "' AND ";
            sql += "price = " + std::to_string(product->price()) + ";";
        }
        return sql;
    }
};

class AddPacks : public ISQLCommand {
private:
    std::shared_ptr<Product> product;
public:
    AddPacks(std::shared_ptr<Product> product) {
        this->product = product;
    }
    virtual const std::string toSQL() const {
        std::string sql = "";
        if (product->classifier() == Classifier::PACKS) {
            sql += "INSERT INTO PACKS (product) SELECT id FROM PRODUCTS WHERE ";
            sql += "name = '" + product->name() + "' AND ";
            sql += "price = " + std::to_string(product->price()) + ";";
        }
        return sql;
    }
};

class ClassifierValues {
public:
    inline std::vector<std::shared_ptr<ISQLCommand>> operator()
        (std::shared_ptr<Product> product) const {
        std::vector<std::shared_ptr<ISQLCommand>> vec{
            std::make_shared<AddCereals>(product),
            std::make_shared<AddDrinks>(product),
            std::make_shared<AddPacks>(product),
        };
        return vec;
    }
};

class AddClassifier : public ISQLCommand {
private:
    std::shared_ptr<Product> product;
public:
    AddClassifier(std::shared_ptr<Product> product) {
        this->product = product;
    }
    virtual const std::string toSQL() const {
        ClassifierValues classifier;
        auto vec = classifier(this->product);
        std::string str = "";
        for (auto& elem : vec) {
            str += elem->toSQL();
        }
        return str;
    }
};

class AddProduct : public ISQLCommand {
private:
    std::shared_ptr<Product> product;
public:
    AddProduct(std::shared_ptr<Product> product) {
        this->product = product;
    }
    virtual const std::string toSQL() const {
        auto classifier = std::make_shared<AddClassifier>(product);
        std::string sql = "INSERT INTO PRODUCTS (name, price) VALUES ('";
        sql += product->name() + "', ";
        sql += std::to_string(product->price()) + " );\n";
        sql += classifier->toSQL();
        return sql;
    }
};

class DataBaseQuery {
private:
    std::shared_ptr<DBConnection> connection;
    std::shared_ptr<ISQLCommand> command;
public:
    DataBaseQuery(std::shared_ptr<DBConnection> connection,
        std::shared_ptr<ISQLCommand> command) {
        this->connection = connection;
        this->command = command;
    }
    void execute() const {
        connection->execute(command->toSQL());
    }
};

int main() {

    auto product = std::make_shared<Product>("Product1", 500, CEREALS);

    auto connection = std::make_shared<DBConnection>();
    auto command = std::make_shared<AddProduct>(product);

    DataBaseQuery db(connection, command);
    db.execute();

    return 0;
}

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


  1. HyperWin
    09.09.2024 04:56

    Объяснено достаточно понятно, хороший пример, благодарю


    1. Tiriet
      09.09.2024 04:56
      +7

      а мне непонятно с самого начала. рассматривается "товар", который может иметь "категорию". или несколько. При этом у одного товара может быть несколько категорий, а одна категория может содержать разные товары. Это вроде как связь "многие ко многим", которая технически реализуется в реляционных БД через три таблицы. Автор городит свой велосипед, и начинает рассуждения о подстановках Барабары Лисков- концептуальные рассуждения в сочетании с базовой безграмотностью вызывают сомнения в том, стоит ли вообще продолжать чтение статьи. Но я прочитал дальше, и вижу, что у "продукта" в БД есть "primary_key" ID, однако класс AddDrinks при внесении классификатора определяет этот ID по "price" и "name". То есть, в коде примари-кеем работает именно пара имя-цена, но а в БД- ID- а это прямой путь к получению вских интересных эффектов, типа если я добавлю "Лук:100:спортинвентарь", а следом добавлю "Лук:100:еда", то у меня композитный лук со стрелами окажутся на полке с редиской, а мешок лука останется непрокатегоризированный и сгниет на складе, но при этом целостность БД никак не пострадает. Это плохое учебное пособие...


  1. jakobz
    09.09.2024 04:56

    Т.е. вот так низя? :(((

    public static int Count<T>(this IEnumerable<T> seq) {
       switch (seq) {
          case Array a:                   return a.Length;
          case ICollection<T> c:          return c.Count;
          case IReadOnlyCollection<T> c:  return c.Count;
          // Matches if seq is not null
          case IEnumerable<T> _:          return seq.Count();
          // Discard pattern when seq is null
          default:                        return 0;
       }
    }


    1. mayorovp
      09.09.2024 04:56

      Можно, но только в рамках оптимизации. Т.е. все ветки обязаны возвращать то же самое, что вернула бы наиболее общая реализация.

      Ну и, к слову, в LINQ уже так и сделано.


  1. KamiSempai
    09.09.2024 04:56
    +2

    Заводить отдельную таблицу на каждую категорию выглядит сомнительно. Мало того что это приводит дублированию кода (методы toSQL), так ещё и усложняет добавление новых категорий.

    Лучше сделать как-то так:

    CREATE TABLE PRODUCT_CATEGORY(
      product_id INTEGER NOT NULL,
      category_id INTEGER NOT NULL
    );
    
    CREATE TABLE CATEGORY(
      id SERIAL PRIMARY KEY,
      name TEXT NOT NULL,
    );

    Искать id продукта по названию и ЦЕНЕ - это вообще что-то с чем-то. Что мешало добавить свойство id в объект Product?


    1. Tiriet
      09.09.2024 04:56
      +3

      и добавить как-то так:

      CREATE TABLE PRODUCT_CATEGORY (
          product_id  int FOREIGN KEY REFERENCES PRODUCTS(ID) ON DELETE CASCADE,
          category_id  int FOREIGN KEY REFERENCES CATEGORY(ID) ON DELETE RESTRICT
      );

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


  1. tolich_the_shadow
    09.09.2024 04:56

    Немного не по теме, но про чистый код: при вставке в запрос строковых данных необходимо экранировать апострофы и прочие спецсимволы.