C++ Идеальная статическая иерархия или как объединить объекты в структуру

Доброго времени суток. Представлюсь, для тех, кто меня еще не знает. Меня зовут Дима. Я работаю C++ разработчиком уже более 5-ти лет. На данный момент работаю в крупной Gamedev-студии. Помимо работы увлекаюсь созданием образовательного контента для YouTube и Twitch каналов.

С++. Какой же противоречивый язык программирования. Выбираете за совместимость с Си – получаете утечки памяти, хотите высокую производительность – получаете огромное время компиляции, хотите шаблоны и метапрограммирование – получаете отладку, больше похожую на ходьбу по минному полю, где вместо взрыва – исправление в хидере(да, реализация шаблонов только в хидер-файлах и последствия этого - вообще отдельная тема для разговора) и ожидание компиляции, когда чувствуешь, что умираешь от старости. Я сделал для себя такие выводы: C++ – отличный язык программирования. Язык, который хочет включить себя всё, от простоты, а скорее сложности си, до сложности, или простоты современных мультипарадигменных языков программирования.

C++ Идеальная статическая иерархия или как объединить объекты в структуру

Что же, Вы выбрали C++ и вы не хотите бороться с утечками памяти? Ответ на первый взгляд прост – умные указатели. Но, ведь тут тоже не так всё просто, вы не хотите получить частые обращение к куче, которые сами по себе очень недешёвые (системный вызов, синхронизация), и вы не хотите в итоге получить излишнюю фрагментацию вашей памяти. Ответ тут тоже есть. Статическая иерархия объектов вашей программы. Под этим я подразумеваю, что ваша программа создаст необходимые ей объекты на момент запуска и удалит их в момент завершения своего выполнения. Увы, но тут тоже не всё так просто ибо подобный подход подходит (да-да, тавтология-повторение) для определённого класса задач. Приведу наглядные примеры, дабы было понятнее.

Геймдев. Игры жанра Match3. На момент запуска этого приложения Вы уже знаете, что Вам от него нужно. Главное меню, уровень. Уровень может конфигурироваться файлами с вашего диска или информацией с сервера, но вы уже знаете все объекты, которые вам нужно создать. Соответственно, не нужно думать, что и в какой момент выделить, что и когда удалить. Всё, что вам нужно, – правильное отображение информации. Можно привести в пример даже AAA-игры. Запускаем очередную версию Battlefield и видим, что, отображая только главное меню, игра уже выделила 4 Гб оперативной памяти. Подозрительно? Да нет, не очень. Очевидно, что разработчики следовали тому же принципу.

Симуляция. Мне приходилось работать с программными продуктами, назначение которых – симулировать процессы, происходящие на железе. ТО есть симулировать некий микроконтроллер или нечто более сложное. Жёсткий диск, процессор. Зачем это нужно? Для тестирования прошивки железа или драйверов, когда самого железа еще не существует, или устройств слишком мало, чтобы дать возможность тестировать его достаточному количеству разработчиков прошивки. По сути, винчестер не изменится в процессе работы, в нём не станет больше памяти или один контроллер вдруг не заменит другой. Соответственно, имеет смысл создать все объекты сразу на момент запуска теста и удалить их на момент его завершения.

Далее всё довольно просто. Вы создаёте некий главный объект. Например, Game. В его полях вы создаёте объекты MainMenu, Level, ScoreBoard, NetworkManager... В их полях создаёте другие объекты и так далее. Всё вроде бы хорошо. Однако ваша архитектура усложняется, вам необходимы какие-то общие методы, всё-таки у нас тут ООП и полиморфизм, например, вызов какой-то логики у объекта, каждый кадр, тот, кто работал с игровыми движками, понимает. Или же, как минимум, объектам понадобятся общие методы для поиска друг друга. И тут возникает проблема, ибо нужно все объекты объединить в некую абстрактную иерархию базовых объектов.

Я сталкивался с решениями, когда для объектов дочерних (не в смысле наследников, а в смысле полей класса) нужно вызвать что-то типа add_child и добавлять этот вызов самостоятельно для каждого созданного объекта. Мне кажется, что это чересчур опасное решение с точки зрения вероятности упустить добавление объекта, особенно человеку, который не знаком с правилами.

Цель

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

class Game { public: Player _player; Enemy _enemy; }

В приведённом примере мы хотим иметь возможность обратится к полям _player или _enemy класса Game через некий массив указателей _children;

Решение

Очевидно, что нам понадобится некий базовый класс:

class BaseNode { public: BaseNode(const std::string& name); protected: //Добавим каждому объекту возможность идентифицировать себя std::string _name; std::vector<BaseNode*> _children; };

Далее разберёмся с порядком вызова конструкторов. Порядок вызова конструкторов довольно прост. Порядок вызова = порядок объявления.
Продемонстрирую на примере:

class Base {}; class Field : public Base {}; class Child : public Base { Field _field1; Field _field2; };
  • Base() - класс Child
  • Base() - поле _field1
  • Fileld() - поле _field1
  • Base() - поле _field2
  • Fileld() - поле _field2
  • Child() - класс Child

Обратимся к написанному ранее классу BaseNode. По сути будет всегда запускаться следующая цепочка. BaseNode контейнера, BaseNode полей, конструкторы полей, конструктор контейнера. Здесь уже можно обратить внимание, что это всё напоминает поиск в глубину.

Создадим глобальный стек.

namespace { std::stack<BaseNode*> stack; } BaseNode::BaseNode(const std::string& name) : _name(name.) { if (!stack.empty()) { //на вершине стека находится объект-контейнер текущего объекта stack.top()->_children.push_back(this); } //Добавляем себя на вершину стека stack.push(this); }

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

  • Base() - класс Child
  • Base() - поле _field1
  • Fileld() - поле _field1. В этой точке уже известно, что детей у field1 больше нет и мы можем удалять его с вершины стека.

Чтобы добиться нормального удаления объекта с вершины стека, не добавляя удаление в каждый новый конструктор, вместо const std::string& name в конструкторе нашего класса будем использовать свой собственный временный объект, который будет отвечать за индикацию классов по имени.

struct NodeName { NodeName(const char* name); NodeName(const std::string& name); ~NodeName(); std::string _name; };

Каким будет время жизни данного объекта? Мы по-прежнему будем объявлять его в конструкторах через константную ссылку, ибо объявление по значению для объектов, которые больше по своему размеру, чем системное слово, как-то некрасиво. Но, по сути, нам нужен именно такой объект, дабы добиться его времени жизни только на тот момент, пока мы находимся в цепочке конструкторов объекта-контейнера. То есть "пытаясь" вызвать конструктор контейнера, мы попадём в базовый конструктор, затем конструкторы детей, а потом уже в конструктор контейнера, по выходу из которого NodeName и будет уничтожен. Получается, что удалять объект с вершины глобального стека нужно как раз на разрушении очередного объекта класса NodeName. Перейдём, наконец, к полной реализации.

// BaseNode.h #pragma once #include <string> #include <vector> #define NODE_DECL(obj) obj{#obj} struct NodeName { NodeName(const char* name); NodeName(const std::string& name); ~NodeName(); std::string _name; }; class BaseNode { public: BaseNode(const NodeName& name); protected: std::string _name; std::vector<BaseNode*> _children; }; ////////////////////////////////////////////////// //BaseNode.cpp #include "BaseNode.h" #include <iostream> #include <stack> namespace { std::stack<BaseNode*> stack; } BaseNode::BaseNode(const NodeName& name) : _name(name._name) { if (!stack.empty()) { //Обратились к вершине стека и добавили туда себя в качестве ребёнка stack.top()->_children.push_back(this); } // Добавили себя на вершину стека stack.push(this); } //Два конструктора для более удобной работы NodeName::NodeName(const char* name) : _name(name) {} NodeName::NodeName(const std::string& name) : _name(name) {} NodeName::~NodeName() { //Снимаем объект BaseNode с вершины стека stack.pop(); }

Можно обратить внимание, что в удалении объекта нет проверки на то, что стек не пуст. Это сделано нарочно, ведь если стек окажется не пуст, значит кто-то построил иерархию неверно и приложение упадёт на этапе инициализации.

#define NODE_DECL(obj) obj{#obj}

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

#include <iostream> #include "BaseNode.h" // Все классы должны быть наследниками BaseNode class C : public BaseNode { public: C(const NodeName& name) : BaseNode(name) {} }; //Данный класс будет хранить один дочерний объект в иерархии class A : public BaseNode { public: A(const NodeName& name) : BaseNode(name) {} private: //Дочерний объект имя дочернего объекта будет "_c" C NODE_DECL(_c); }; //Данный класс будет хранить один дочерний объект в иерархии class B : public BaseNode { public: B(const NodeName& name) : BaseNode(name) {} private: //Дочерний объект C NODE_DECL(_c); }; //Главный класс, который хранит два дочерних объекта классов A B class Parent : public BaseNode { public: Parent(const NodeName& name) : BaseNode(name) {} private: A NODE_DECL(_a); B NODE_DECL(_b); }; int main() { //Объявим данный объект на стека //По заверешнию выполнения функции мэйн он будет удалён //В случае с большим объектами можно создать его на куче //И удалить по заверешнию работы программы Parent NODE_DECL(parent); return 0; }

В итоге, следует выполнять два пункта для работы нашей иерархии:

  • Наследование от базового класса
  • Правильное объявление переменных (для стандартизации предлагаю пользоваться подобными макросами).

Каждый раз объявлять конструктор и вызывать конструктор базового класса тоже выглядит довольно-таки громоздко. Для ускорения можно использовать оператор using:

// Без using class C : public BaseNode { public: C(const NodeName& name) : BaseNode(name) {} }; //Иcпользуя using class A : public BaseNode { public: using BaseNode::BaseNode; };

Заключение

Прелесть данного решения в том, что технический долг, которого нужно придерживаться, создавая новые объекты в иерархии, крайне мал. Также присутствует защита, которая тут же сообщит разработчику-пользователю, что он объявляет объекты в иерархии неверно. Речь об удалении объекта с вершины стека, без проверки на его пустоту.
Задавайте в комментариях вопросы. Пишите, если увидите какие-то ошибки, и спасибо за внимание.

Для полной картины и объяснения в формате видео предлагаю ознакомиться с записью стрима, где я реализую код из статьи и объясняю всё максимально подробно:

Мы на других ресурсах

  • YouTube
  • Twitch
  • Telegram - для извещения о трансляции и появлении новых видео.
1919 показов
7.1K7.1K открытий
35 комментариев

Просто эталон бесполезной статьи.

Человек, не знающий языка, не поймёт что ты тут написал.

Человек, знающий язык, поймёт, что ты написал лютый бред, да ещё и крайне плохо.

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

Я б прошел мимо ничего не сказав, но к сожалению есть риск, что кто-то посередине прочитает статью и подумает, что всё написанное в ней — хорошая идея. А это непрямым образом создаёт проблемы конкретно мне. И так внятных джуниоров сложно найти, так ещё и из каждого надо потом выбивать подобную ересь, которую он где-то в интернетах прочитал.

Дружище, если ты хочешь написать хорошую статью по программированию:

1. Определись для кого пишешь. Либо для людей, не знающих про конструкторы, либо для людей, которым зачем-то понадобился твой недо-рефлекшн. Это две разные группы людей.

2. Определись, какую задачу решаешь. Потрать время на объяснение цели, а не оправдание своего письменного стиля:

(да-да, тавтология-повторение)

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

4. Самое важное: попроси ревью у коллег. Со всеми бывают задвиги, когда вроде написал что-то, а потом смотришь через два дня и стыдно. В этом нет ничего страшного. Для того и нужно работать в команде, чтоб такая фигня не попадала в продакшн, ну или в твоём случае в статью.

Ответить

Если вы хотите дать совет, не стоит начинать комментарий с фразы "эталон бесполезной статьи". но я Вам подыграю.
Эталон бесполезного комментария.
"Человек, знающий язык, поймёт, что ты написал лютый бред, да ещё и крайне плохо." - тоже весьма конструктивно. Данное решение успешно работает и работало на двух проектах, находящихся в продакшне, и оно себя отлично зарекомендовало. Более того, я заметил недостаток отсутствия этого подхода на проекте, работающем по такому же принципу, но без автоматического построения иерархии.
Макрос для объявления поля - не такая уж и большая плата, и иерархия защищена от неправильного построения.
"Я б прошел мимо ничего не сказав, но к сожалению есть риск, что кто-то посередине прочитает статью и подумает, что всё написанное в ней — хорошая идея." - в статье был описан определённый класс задач и архитектур, для которых это хорошая идея.
"так ещё и из каждого надо потом выбивать подобную ересь, которую он где-то в интернетах прочитал." - это конкретно ваша проблема, не стоит всем её вываливать.

Ответить

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

Ответить

Спасибо, отличная мотивирующая статья для изучения С# ) 

Ответить

За Страуструпа и двор стреляю в упор.

Ответить

Честно говоря так и не понял чего вы хотите добиться такой ex-machina? Зачем вообще нужен общий стек объектов?

ЗЫ. Потокобезопасность, как я понял, тут и не предполагается?

Ответить

"Зачем вообще нужен общий стек объектов?" - в статье написано. На вершине стека лежит объект, в дети к которому надо добавить текущий объект.
"Потокобезопасность, как я понял, тут и не предполагается?" — в каком контексте тут необходима потокобезопасность? О каких разделяемых ресурсах идёт речь? В данной ситуации можно обернуть только инициализацию "главного" объекта. Однако в рамках статьи это было бы излишним.

Ответить