C++ Идеальная статическая иерархия или как объединить объекты в структуру
Доброго времени суток. Представлюсь, для тех, кто меня еще не знает. Меня зовут Дима. Я работаю C++ разработчиком уже более 5-ти лет. На данный момент работаю в крупной Gamedev-студии. Помимо работы увлекаюсь созданием образовательного контента для YouTube и Twitch каналов.
С++. Какой же противоречивый язык программирования. Выбираете за совместимость с Си – получаете утечки памяти, хотите высокую производительность – получаете огромное время компиляции, хотите шаблоны и метапрограммирование – получаете отладку, больше похожую на ходьбу по минному полю, где вместо взрыва – исправление в хидере(да, реализация шаблонов только в хидер-файлах и последствия этого - вообще отдельная тема для разговора) и ожидание компиляции, когда чувствуешь, что умираешь от старости. Я сделал для себя такие выводы: C++ – отличный язык программирования. Язык, который хочет включить себя всё, от простоты, а скорее сложности си, до сложности, или простоты современных мультипарадигменных языков программирования.
Что же, Вы выбрали C++ и вы не хотите бороться с утечками памяти? Ответ на первый взгляд прост – умные указатели. Но, ведь тут тоже не так всё просто, вы не хотите получить частые обращение к куче, которые сами по себе очень недешёвые (системный вызов, синхронизация), и вы не хотите в итоге получить излишнюю фрагментацию вашей памяти. Ответ тут тоже есть. Статическая иерархия объектов вашей программы. Под этим я подразумеваю, что ваша программа создаст необходимые ей объекты на момент запуска и удалит их в момент завершения своего выполнения. Увы, но тут тоже не всё так просто ибо подобный подход подходит (да-да, тавтология-повторение) для определённого класса задач. Приведу наглядные примеры, дабы было понятнее.
Геймдев. Игры жанра Match3. На момент запуска этого приложения Вы уже знаете, что Вам от него нужно. Главное меню, уровень. Уровень может конфигурироваться файлами с вашего диска или информацией с сервера, но вы уже знаете все объекты, которые вам нужно создать. Соответственно, не нужно думать, что и в какой момент выделить, что и когда удалить. Всё, что вам нужно, – правильное отображение информации. Можно привести в пример даже AAA-игры. Запускаем очередную версию Battlefield и видим, что, отображая только главное меню, игра уже выделила 4 Гб оперативной памяти. Подозрительно? Да нет, не очень. Очевидно, что разработчики следовали тому же принципу.
Симуляция. Мне приходилось работать с программными продуктами, назначение которых – симулировать процессы, происходящие на железе. ТО есть симулировать некий микроконтроллер или нечто более сложное. Жёсткий диск, процессор. Зачем это нужно? Для тестирования прошивки железа или драйверов, когда самого железа еще не существует, или устройств слишком мало, чтобы дать возможность тестировать его достаточному количеству разработчиков прошивки. По сути, винчестер не изменится в процессе работы, в нём не станет больше памяти или один контроллер вдруг не заменит другой. Соответственно, имеет смысл создать все объекты сразу на момент запуска теста и удалить их на момент его завершения.
Далее всё довольно просто. Вы создаёте некий главный объект. Например, Game. В его полях вы создаёте объекты MainMenu, Level, ScoreBoard, NetworkManager... В их полях создаёте другие объекты и так далее. Всё вроде бы хорошо. Однако ваша архитектура усложняется, вам необходимы какие-то общие методы, всё-таки у нас тут ООП и полиморфизм, например, вызов какой-то логики у объекта, каждый кадр, тот, кто работал с игровыми движками, понимает. Или же, как минимум, объектам понадобятся общие методы для поиска друг друга. И тут возникает проблема, ибо нужно все объекты объединить в некую абстрактную иерархию базовых объектов.
Я сталкивался с решениями, когда для объектов дочерних (не в смысле наследников, а в смысле полей класса) нужно вызвать что-то типа add_child и добавлять этот вызов самостоятельно для каждого созданного объекта. Мне кажется, что это чересчур опасное решение с точки зрения вероятности упустить добавление объекта, особенно человеку, который не знаком с правилами.
Цель
Мы хотим иметь возможность создавать такой класс, в поля которого можно добавить объекты, которые будут объединены в иерархию базовых классов.
В приведённом примере мы хотим иметь возможность обратится к полям _player или _enemy класса Game через некий массив указателей _children;
Решение
Очевидно, что нам понадобится некий базовый класс:
Далее разберёмся с порядком вызова конструкторов. Порядок вызова конструкторов довольно прост. Порядок вызова = порядок объявления.
Продемонстрирую на примере:
- Base() - класс Child
- Base() - поле _field1
- Fileld() - поле _field1
- Base() - поле _field2
- Fileld() - поле _field2
- Child() - класс Child
Обратимся к написанному ранее классу BaseNode. По сути будет всегда запускаться следующая цепочка. BaseNode контейнера, BaseNode полей, конструкторы полей, конструктор контейнера. Здесь уже можно обратить внимание, что это всё напоминает поиск в глубину.
Создадим глобальный стек.
Данное решение сработает, если у нас есть один класс контейнер, который в себе хранит одно поле. Ведь на данный момент нет удаления с вершины стека в нужный момент. Обратимся к примеру с порядком вызова и укажем место, где именно нужно удалять с вершины стека
- Base() - класс Child
- Base() - поле _field1
- Fileld() - поле _field1. В этой точке уже известно, что детей у field1 больше нет и мы можем удалять его с вершины стека.
Чтобы добиться нормального удаления объекта с вершины стека, не добавляя удаление в каждый новый конструктор, вместо const std::string& name в конструкторе нашего класса будем использовать свой собственный временный объект, который будет отвечать за индикацию классов по имени.
Каким будет время жизни данного объекта? Мы по-прежнему будем объявлять его в конструкторах через константную ссылку, ибо объявление по значению для объектов, которые больше по своему размеру, чем системное слово, как-то некрасиво. Но, по сути, нам нужен именно такой объект, дабы добиться его времени жизни только на тот момент, пока мы находимся в цепочке конструкторов объекта-контейнера. То есть "пытаясь" вызвать конструктор контейнера, мы попадём в базовый конструктор, затем конструкторы детей, а потом уже в конструктор контейнера, по выходу из которого NodeName и будет уничтожен. Получается, что удалять объект с вершины глобального стека нужно как раз на разрушении очередного объекта класса NodeName. Перейдём, наконец, к полной реализации.
Можно обратить внимание, что в удалении объекта нет проверки на то, что стек не пуст. Это сделано нарочно, ведь если стек окажется не пуст, значит кто-то построил иерархию неверно и приложение упадёт на этапе инициализации.
Данный макрос будем использовать для инициализации полей классов в нашей иерархии, чтобы создавать NodeName с помощью строкового литерала, то есть приводя к созданию временного объекта. Плюс получится довольно удобно, что имя объекта в иерархии будет равно имени переменной в коде. Продемонстрирую применение на примере:
В итоге, следует выполнять два пункта для работы нашей иерархии:
- Наследование от базового класса
- Правильное объявление переменных (для стандартизации предлагаю пользоваться подобными макросами).
Каждый раз объявлять конструктор и вызывать конструктор базового класса тоже выглядит довольно-таки громоздко. Для ускорения можно использовать оператор using:
Заключение
Прелесть данного решения в том, что технический долг, которого нужно придерживаться, создавая новые объекты в иерархии, крайне мал. Также присутствует защита, которая тут же сообщит разработчику-пользователю, что он объявляет объекты в иерархии неверно. Речь об удалении объекта с вершины стека, без проверки на его пустоту.
Задавайте в комментариях вопросы. Пишите, если увидите какие-то ошибки, и спасибо за внимание.
Для полной картины и объяснения в формате видео предлагаю ознакомиться с записью стрима, где я реализую код из статьи и объясняю всё максимально подробно: