Liskov Substitution Principle (LSP)

The Liskov Substitution Principle states that if a method uses a base class, it should be able to use any of its derived classes without needing to know anything about the derived class.

It's hard to come up with a reasonable example to illustrate this principle, because following basic logic and clean code rules for naming methods and variables doesn't allow you to violate it. If your base class has a save() method that's responsible for saving information, and you're not trying to rework it to load data, you're fine.

Let's consider the subtleties of observing this principle using a rather complex example. Let's start with the data storage class.

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

The peculiarity of this data is the presence of a classifier implemented through an enumeration. In the most primitive case, the database table would look like this.

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

And the interface for writing data to the database and its implementation is approximately like this.

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

There are no violations of the Liskov substitution principle in this fragment. However, such a database and code structure is not optimal. When a product is included in several groups, it will be necessary to duplicate product records or create new categories. For example, a book may belong to the category printed products and gifts. Duplication will lead to clogging of the database, and new categories will require changing the source code. Bringing tables to normal form will change the database in the following way.

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

Now there is no need for duplication, since each category has a separate table that stores links to products. Marketers can create new categories every day, we will simply add a new table.

However, such changes will force us to abandon the solution where the entire SQL query is made in one method. After all, to record a specific product value, two consecutive records must be made. The first line makes a record in the product table, and the second receives the ID value of the previous record and in turn makes a record in the corresponding table.

If we try to simply add another record to the same method, we not only unnecessarily complicate the method itself, but also directly violate the Liskov Substitution Principle. The client using this method will have to “account for” this behavior.

This is not a big problem, but writing clean code implies readability and the ability to easily expand functionality. Our code has at least two potential problems. The first will arise when we need to add another classifier. There will be more SQL commands. The second potential problem will arise if additional information needs to be written to the classifier tables. Therefore, it is necessary to prevent violation of the described principle by dividing the commands among themselves.

The chain of reasoning is quite simple, the commands for writing to different tables should be separated, but at the execution stage they should be combined into one command, since writing a product without a classifier and a classifier without a product does not make sense.

From the description it is clear that the pattern called Composer is best suited for organizing work with commands, which will allow us to compose complex sets from a set of small, similar commands.

The full program code is given at the end of the article, but I would like to comment on several techniques used.

First of all, let's look at how the composer creates commands for writing to the database.

Dependency diagram

Dependency diagram

There are three tables of product classifier values. Each of them must contain the product ID that belongs to this classifier value. To implement the functionality, a corresponding number of commands are created, implemented as classes AddCereals, AddDrinks, AddPacks. Each of these commands can be executed independently, for example, when reclassifying goods.

The listed commands allow you to work with each classifier value separately. However, we need to perform a record for the entire classifier using one command AddClassifier. In fact, this command receives information about the product classification and itself selects which table to write the data to.

The AddProduct command writes all product data at once, using other commands as components.

Now the violation of the Liskov substitution principle is eliminated. Each of these commands can be executed separately and “not know” about the structure of the others, although in fact they work together.

The next point I would like to draw attention to is the functional object.

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

It is intended to group all commands responsible for recording classifier values. In fact, it provides an array of pointers, from which the AddClassifier command subsequently selects the necessary one. I think it is clear why it is necessary to select it, the classifier can be expanded over time.

However, this class does not have to be implemented as a full-fledged functional object. This is a training example and this class is made in such a way as not to violate the principles of object-oriented programming. But the C++ language gives us the opportunity to simplify this section by using functional objects from the standard library for these purposes, for example std::function.

In a real project, I personally would do it this way, because it is much easier to add a new function than to take into account the possibility of changing the classifier through inheritance.

The last piece of code I would like to draw your attention to is the class.

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

Its task is to combine the connection to the database and the commands transmitted.

In many projects this is done linearly, where we open a connection, send a command, receive a response, and close the connection. However, it is better to stick to this structure.

Dependency diagram

Dependency diagram

A separate article can be written about this, I think I will write it. But within the framework of the current one, I would like to explain that such a construction allows creating conditions for further modification and avoiding many difficulties when implementing interaction with external resources.

Separating the connection from the commands will allow:

  • properly organize the handling of exceptional situations,

  • the ability to use multi-stream connection,

  • if necessary, switch to another database without significant costs for reworking the source code.

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

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *