Чистый код: Данные

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

Неизменяемым называется объект (англ. immutable object), состояние которого не может быть изменено после создания (1). Это понятие не так широко используется в различной литературе, поэтому начну с более подробного разбора этого понятия и обоснования, почему стоит применять этот шаблон.

Классическое определение гласит - Объектно ориентированное программирование (ООП), парадигма программирования, в рамках которой программа представляется в виде совокупности объектов, а её выполнение состоит во взаимодействии между объектами. Объектом называется набор из данных и операций, которые можно выполнить над этими данными (2).

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

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

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

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

Слова, которые служат названием предмета в широком смысле, то есть имеют значение предметности, называются именами существительными (3).

Глагол - разряд слов, которые обозначают действие или состояние предмета как процесс (4).

Давайте разберем эту методику на примере небольшого участка с примера бизнес процессов.

Участок из примера диаграммы бизнес процессов<br />
Участок из примера диаграммы бизнес процессов

Значение предметности в этом участке имеют:

  • Заказ (class Order),
  • Счет (class Account),
  • Клиент (class Client),
  • Денежные средства (class Money).

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

Обозначают действия:

  • Создание счета (interface CreatingAccount ),
  • Направление клиенту (interface ReferralToTheClient ),
  • Ожидание поступления (interface WaitingForReceipt).

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

Действия с интерфейсами выходят за рамки этой статьи, поэтому остановимся на работе с данными. Чтобы не ошибиться при проектировании, можно использовать прием реализованный в языке C# ключевым словом readonly. Описывайте классы с данными таким образом, чтобы поля нельзя было изменять после выхода из конструктора.

class Product { private: std::string name; // Наименование товара double price; // Цена public: Product(std::string name, double price): name(name), price(price) {} std::string name() const { return name; } double price() const { return price; } };

Спросите как это работает, если мы сами себя ограничили. Взгляните на диаграмму.

Диаграмма разделения Действий и данных<br />
Диаграмма разделения Действий и данных

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

Во вторых при необходимости мы можем хранить и старое и новое значение цены, либо динамически пересчитать её при изменении условий.

Усложним пример, сделав преимущество такого подхода еще более наглядным.

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

Диаграмма разделения действий<br />
Диаграмма разделения действий

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

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

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

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

class Product { private: std::string name; // Наименование товара double price; // Цена public: Product(std::string name, double price): name(name), price(price) {} std::string name() const { return m_name; } double price() const { return m_price; } }; class Manufacturer {}; class ExpandProduct : public Product { private: std::shared_ptr<Manufacturer> manufacturer; public: ExpandProduct(std::string name, std::shared_ptr<Manufacturer> manufacturer, double price): Product(name, price) { this->manufacturer = manufacturer; } std::shared_ptr<Manufacturer> getManufacturer() const { return manufacturer; } };

Если мы используем наследование при расширение возможности классов, то единственной возможностью использовать, старые и вновь создаваемые алгоритмы является полиморфизм на основании указателей.

int main() { std::list<std::shared_ptr<Product>> products{ std::make_shared<Product>("Product1", CEREALS, 500), std::make_shared<Product>("Product2", DRINKS, 400), std::make_shared<Product>("Product3", PACKS, 300) }; std::list<std::shared_ptr<Product>> newproducts; std::ranges::for_each(products, [&newproducts](auto& elem) { auto temp = std::make_shared<ExpandProduct>( elem->getName(), elem->getClassifier(), std::make_shared<Manufacturer>(), elem->getPrice() ); newproducts.push_back(temp); }); for (auto& elem : newproducts) { std::cout << elem->getName() << std::endl; } return 0; }

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

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

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

Используя этот подход, мы как бы позволяем данным лежать на одном и том же месте, а обращаемся к ним по ссылкам, как к плитам из примера.

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

Следовательно и пирамида данных имеет примерно три уровня (5)(6). Именно так устроены сборщики мусора в таких языках как C# и Java. В C++ управлением памятью занимается компилятор и операционная система, а программист может влиять на эти процессы только через инструменты динамического управления памятью (7).

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

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

Можно сделать вывод, что все объекты лучше использовать по ссылке. Собственно так все и выполняется в языках более высокого уровня C#, Java. В них есть всего два типа данных, базовый и ссылочный. Да и на языке С++ есть примеры использования такого подхода, например в Qt есть один класс QObject от которого происходят все остальные (8).

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

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

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

Обойти это ограничение помогает паттерн декоратор. Вот пример на его основе.

enum Classifier { NONE, CEREALS, DRINKS, PACKS }; /* Декоратор — это структурный паттерн проектирования, * который позволяет динамически добавлять объектам новую * функциональность, оборачивая их в полезные «обёртки». */ class Properties {}; class PropertiesCereals : public Properties {}; class PropertiesDrinks : public Properties {}; class PropertiesPacks : public Properties {}; class Product { private: std::string name; // Наименование товара double price; // Цена Classifier category; // Классификатор товара std::shared_ptr<Properties> properties; public: Product(std::string name, double price, Classifier classifier, std::shared_ptr<Properties> properties): name(name), price(price), category(classifier), properties(properties) {} std::string getName() const { return name; } double getPrice() const { return price; } Classifier getClassifier() const { return category; } const std::shared_ptr<Properties> getProperties() const { return properties; } };

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

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

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

  1. Википедия - Неизменяемый объект.

  2. Большая российская энциклопедия - Объектно ориентированное программирование.

  3. Казанский (Приволжский) федеральный университет - Лекции.

  4. Казанский (Приволжский) федеральный университет - Лекции.

  5. Основы сборки мусора C# - Основы сборки мусора.

  6. Реализация сборщика мусора Java - Реализация сборщика мусора.

  7. Динамическое управление памятью C++ - Динамическое управление памятью.

  8. Объектная модель Qt Framework Qt 6 - Объектная модель.

Начать дискуссию