Чистый код: Принцип подстановки Барбары Лисков (LSP)

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

Трудно предоставить разумный пример иллюстрирующий этот принцип, так как соблюдение элементарной логики и правил чистого кода по именованию методов и переменных, не позволяет его нарушить. Если в базовом классе есть метод 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 команд станет больше. Вторая потенциальная проблема возникнет, если в таблицы классификаторов понадобится записывать дополнительную информацию. Следовательно необходимо не допустить нарушения описываемого принципа, разделив команды между собой.

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

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

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

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

Диаграмма компоновки<br />
Диаграмма компоновки

Существует три таблицы значений классификатора товара. В каждую из них необходимо поместить 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()); } };

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

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

Диаграмма компоновки<br />
Диаграмма компоновки

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

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

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

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

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

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; }
Начать дискуссию