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

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

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

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

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

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

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

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

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

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

Участок диаграммы
Участок диаграммы

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

  • Заказ (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; }
};

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

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

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

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

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

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

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

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

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

class Product {
private:
    std::string m_name;          // Наименование товара
    double m_price;              // Цена
public:
    Product(std::string name, double price) :
        m_name(name), m_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 - Объектная модель

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


  1. Gapon65
    24.06.2024 23:30

    Идея в целом понятна. Проблема в том, что полиморфизм в иерархии базового класса Product и его специализаций (что требует работы с указателями) подменяется полиморфизмом в иерархи т.н. "декораторов" (базовый класс Properties и его специализаций), что по-прежнему требует указателей. С моей точки зрения, в качестве универсального рецепта, пример неубедительный. Хотя возможны специальные случаи где подобный дизайн может чем-то помочь.


  1. naviUivan
    24.06.2024 23:30

    Это не статья, это курсовая работа студента начавшего изучать ООП и С++.

    Немного по существу.

    1. Тема иммутабельности не раскрыта - зачем она вообще нужна эта иммутабельность? В чем проблема мутабельности?

    2. Если уж решили использовать наследование и работаете с объектами ExpandProduct через указатель на базовый класс Product, то позаботьтесь хотябы о виртуальном деструкторе в этом базовом классе, иначе все эти ваши std::shared_ptr<Manufacturer> до лампочки.


    1. eao197
      24.06.2024 23:30
      +1

      то позаботьтесь хотябы о виртуальном деструкторе в этом базовом классе, иначе все эти ваши std::shared_ptr до лампочки.

      Разделяю ваше мнение по поводу уровня статьи и подозрения о том, что автор плохо знает C++.
      Но как раз в случае с std::shared_ptr наличие виртуального деструктора в базовом классе не всегда необходимо, т.к. shared_ptr умудряется вызывать деструктор именно того класса, который был в shared_ptr передан: https://wandbox.org/permlink/yNve1UFjSbaJCHx6

      Хочется верить, что автор статьи знает про эту особенность и намеренно ее использует. Но, боюсь, это у него случайно получилось.


      1. naviUivan
        24.06.2024 23:30
        +1

        Все верно, согласен с вами. Подзабыл про этоу особенность shared_ptr. Там при создании shared_ptr генерируется делитер с вызовом правильного деструктора, так как статический тип в тот момент известен, и он сохраняется в ControlObject-е этого shared_ptr. Так что да, будет вызван деструктор ExpandProduct не смотря на то, что базовый класс не имеет виртуального деструктора. Но проблема остается для других типов с менеджметом ресурсов которые не обладают такой особенностью как shared_ptr.


  1. sinelnikof88
    24.06.2024 23:30
    +1

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

    Мы строим дом. Заложил фундамент. Бизнес говорит , тут в стене должна быть дыра.но стены ещё нету. Один тип делает всё стены с дырой. (Потом где ненужно -замажем). Не дошли до крыши. Давай делать крыльцо- комиссия приезжает. Нахуй крышу, делаем крыльцо. Один делает из бетона другой из камней. Сливают вместе этого Франкенштейна - приставляют к стене. Оп-па у проекта оказывается двери нет. Похуй. Берём огромную болгарку режим стену. Пока это делали, пару разрабов сказали- Ну нахуй, и ушли. Но только они знали как этот дом должен выглядеть. Пол года перекрашивали стены. Нужен второй этаж. Такой же как первый. Копируем , вставляем. Первый этаж рухнул - стены были нерасчитаны на такой вес. Приходит менеджер , какого хуя так долго? Тут копировать -вставить.!! На вам ещё в помощь чуваков с других проектов. Один делал самолёты. Другой отлично плавает на дальние дистанции. Пусть у вас трубы проводят. Ну все. Хуево но готово- нужно крышу ставить. И тут выясняется что пока мы стены красили подвал залило водой. Потому что крыши не было. Уже въехало 2 семьй!!!! Откачиваем воду. Срочно приходит правка. Эту стену делаем из стекла - так модно! , Но там туалеты!? Да похуй - делай. Меняем стену на стекло. Теперь будет летом жарко, зимой холодно! Заебись . И тут отказывается фундамент был херово спроектирован. И подвал завален костылями - чтоб не провалился. Меняем фундамент, по ночам, потому что такой хуйни в смете не заложенно. Нужно блядь балконны вешать. Кстати , крышу так и не сделали. И если ты житель последнего этажа , по ночам тебе идёт дождь прямо в морду....

    Вот с чем в реальности работать. За последние 10 лет в коммерческих проектах восновном такая херня творится. Ни разу не видел что бы был проект весь сделан по принципам. Особенно которые долго в разработке типа корпоративных сервисов.