Концепции умных указателей
Давайте начнем с базы, зачем они вообще, и почему такие умные, вдруг здесь окажутся новички.
Умные указатели оборачивают сырые указатели в специальный объект, который автоматически заботится об освобождении выделенной памяти.
// Сырые указатели. Нужно вручную аллоцировать объект и освобождать память
{
MyObject* obj = new MyObject(); // Создаем объект
... // Как-то его используем
delete obj; // Удаляем объект и освобождаем память. Если забыть освободить, то память "утечет"
}
// Умные указатели. Не нужно вручную освобождать память, она освободится автоматически, когда объект-обертка прекратит свое время жизни
{
std::unique_ptr<MyObject> obj = std::make_unique<MyObject>(); // Создаем объект
... // Как-то его используем
} // Объект удаляется автоматически, т.к. переменная obj прекращает свое существование, в ее деструкторе происходит delete
Есть несколько распространенных концепций умных указателей:
Уникальные указатели (unique_ptr)
Их суть в том, что они уникальны, то есть объектом владеет только один указатель. Их нельзя скопировать, только переместить, передав правообладание другому уникальному указателю. Пример как раз разобран выше.
Шареные указатели (shared_ptr)
Сорри за англицизм, но думаю так большинству понятнее.
Суть их в том, что они разделяют владение объектом между собой. Если в уникальных указателях объектом владеет только один указатель, то в шареных может быть несколько владельцев. Все они полноправно владеют объектом, "шарят" его. Можно быть уверенным что никто из других указателей его не удалит, пока есть хотя бы один владелец.
А когда владельцев не остается, объект уничтожается. Работает это через счетчик ссылок: при создании первого шареного указателя на объект, создается специальный счетчик, и сразу инкрементируется до единицы, и инкрементируется каждый раз, когда создается новый шареный указатель на объект. При окончании жизни шареного указателя, счетчик уменьшается на единицу. Если счетчик достиг нуля, значит не осталось шареных указателей на объект, им никто более не владеет, и его можно удалить.
Интерес здесь в том, как устроен счетчик ссылок, а точнее где он лежит. Есть несколько вариантов:
"где-то в куче". При его создании, он просто аллоцируется, и шареный указатель хранить ссылку на него. Довольно удобно, но не эффективно, ведь объект и его счетчик могут быть совсем далеко друг от друга в памяти, что негативно влияет на использование кеша процессора. А значит работа с таким счетчиком - медленная. Еще один минус - невозможно сконструировать шареный указатель из сырого указателя. Ведь сам объект не знает где его счетчик и есть ли он вообще. А такая возможность иногда бывает очень полезной
внутри самого объекта. Эта концепция даже называется интрузивными указателями (intrusive_ptr). Суть в том, что счетчик - это часть объекта. Это помогает улучшить ситуацию с кешем, однако при наследовании счетчик может оказаться в середине объекта, и все равно кеш будет работать не эффективно. Так же этот подход позволяет сконструировать шареный указатель из сырого указателя, ведь объект знает о своем счетчике и может его использовать для нового шареного указателя
-
перед объектом. При создании объекта, мы выделяем единый блок памяти под счетчик и объект, и кладем счетчик перед объектом. Таким образом мы эффективно используем кеш процессора - перед обращением к объекту мы скорее всего обратимся к его счетчику, и при обращении к счетчику предзагрузим объект или его кусок в кеш. Эту концепцию можно комбинировать с интрузивностью, храня ссылку на счетчик в самом объекте.
Однако, такой подход требует специального конструирования объекта. Просто создать его через
new
не получится, ведь нам нужно переопределить логику аллокации, чтобы подпихать туда еще и счетчик. Для этого существуют специальные шаблонные функции наподобиеstd::make_shared<>();
Они делают все что нужно внутри.
Есть существенный нюанс в шареных указателях - это зацикленные ссылки. Представим себе два объекта: А и В. Объект А держит шареный указатель на В, а В держит шареный указатель на А. Это зацикленная ссылка. И такие объекты не будут освобождены, даже если они никому больше не нужны, ведь у обоих счетчик ссылок равен единице. Такая же утечка памяти, как если бы мы забыли удалить объект при ручном управлении.
Слабые указатели (weak_ptr)
Это решение описанной выше проблемы зацикленных ссылок. Слабые указатели так же работают со счетчиком ссылок, но несколько по-другому. Они НЕ увеличивают счетчик сильных ссылок, а вместо этого используют счетчик слабых ссылок.
То есть блок счетчика ссылок вырастает до 2х значений: счетчик сильных и счетчик слабых ссылок. Работает это так:
при занулении счетчика сильных ссылок, удаляется объект. Но сам счетчик остается жить
при занулении счетчика слабых ссылок, удаляется счетчик.
Таким образом слабые указатели позволяют разорвать цикличную ссылку. Достаточно одну из ссылок друг на друга сделать слабой, и счетчик сильных ссылок у одного из объектов занулится, он удалится, и занулит счетчик слабых ссылок, что удалит сам счетчик ссылок.
Так же этот механизм позволяет проверять "жив ли объект", на который ссылается указатель. По счетчику сильных ссылок мы можем понять был ли удален сам объект: если сильных ссылок нет, значит и объект удален.
Поэтому слабые указатели не дают напрямую обращаться к объекту, как шареные указатели (сильные ссылки). Если с сильной ссылкой мы точно знаем что объект существует, то со слабой мы не можем этого утверждать. Поэтому приходится проверять его валидность, а затем "захватывать", то есть брать сильную ссылку на объект из слабой
weak_ptr<MyObject> objWeak = ...;
if (objWeak) // Проверяем валидность объекта
{
auto objStong = objWeak.lock(); // Берем сильную ссылку на него
.... // Безопасно работаем с объектом
} // Сильная ссылка освобождается
Важный нюанс со счетчиком ссылок. После удаления объекта, если есть хоть одна слабая ссылка, счетчик остается жить. Это не большая проблема если счетчик внешний в куче, ведь мы удаляем объект и полностью освобождаем его память.
В случае со счетчиком, который стоит перед объектом и делит с ним участок памяти, при удалении объекта вызывается лишь его деструктор, а весь кусок памяти остается висеть неосвобожденным, пока есть хотя бы одна слабая ссылки. Это не ужасно, но нужно понимать что слабые ссылки тоже нужно освобождать и что они держат кусок памяти.
Концепция слабых ссылок звучит сложной, если не понимать семантику их применения. Банальный вопрос к примеру выше, в какой именно объект поместить слабую ссылку, в А или В? Сходу вопрос "куда именно поставить слабую ссылку" может ввести в ступор, но если понять пару простых правил, то становится гораздо проще:
сильную ссылку (shared_ptr) может держать только владелец объекта, его так скажем "родитель". По-хорошему это должен быть кто-то один. Такая схема выстраивает древовидную структуру владения, где все начинается от одного "рутового" объекта, и разрастается выше и выше. Но все же владельцев может быть несколько, например на время работы с этим объектов в каком-то другом месте или функции, где нам нужна гарантия что объект существует
-
слабую ссылку (weak_ptr) держат все остальные. Тут два варианта:
слабая ссылка на родителя. Объект хочет знать о нем, при этом не зациклить ссылку. Самое место для слабой ссылки
слабая ссылка на объект, за который ты не ответственен. Есть - хорошо, валидируем, захватываем, делаем что нужно, и отпускаем. Но в данном контексте не владеем объектом, а как бы знаем что когда-то его нам передали
Однако, в сильных и слабых ссылках все еще можно сделать ошибку и неявно зациклить ссылки. Сами указатели не скажут вам об этом, ведь цикл ссылок может быть сложным и запутанным, включающим цепочку из более чем 2х объектов. Поэтому все еще есть вероятность утечки памяти.
Сборщик мусора (garbage collector, GC)
Это выходит за рамки концепций умных указателей, но все же я бы рассматривал сборку мусора как продолжение этих концепций. Концепция сборки подразумевает что все ссылки сильные, а внешний алгоритм - сборка мусора - занимается поиском ненужных объектов и их освобождением. При этом он умеет обрабатывать ситуацию зацикленных ссылок.
Немного разберемся как это работает (использую гифки из старой статьи):
-
запускается процесс маркировки:
1. выбираем рутовые объекты. Это статические объекты и на стеке. Они считаются верхнеуровневыми (объект A на гифке)
2. делаем этот список объектов текущим списокм итерации
3. помечаем текущие объекты (красным цветом)
4. ищем объекты, принадлежащие текущим объектам, по ссылкам внутри объекта (для A это будут B и C). Если объект уже маркирован, не берем его в итерацию
5. новые объекты делаем текущим списком, переходим к п. 2. Повторяем пока на текущей итерации есть объекты
проходимся по списку всех объектов, освобождаем те, что не были промаркированы (объекты G и H)
Это очень примитивное описание, иллюстрирующее общую идею, но для понимания этого достаточно.
Казалось бы вот она, концепция идеальных умных указателей! Так и есть, она используется во многих языках: Java, C#, JS, Lua и многих других. Но в ней есть существенный минус - накладные расходы.
Процесс маркировки и проверки маркеров объектов - долгая операция. Нам ведь нужно прошерстить всю память приложения. Да, современные GC хорошо оптмизированы, однако это могут быть критичные накладные расходы. Например, в играх, зависнуть на 20 миллисекунд для сборки мусора - это фатально много, ведь это сильно скажется на плавности игры, будет заметен "рывок". Поэтому в геймдеве либо не используют GC, либо стараются его поменьше беспокоить аллокациями.
Моя концепция
Из всех этих размышлений родилась концепция своих собственных умных указателей, на основе сильных и слабых ссылок, с опциональным отладочным подключением сборщика мусора. А точнее той части, которая размечает дерево памяти, давая понять где есть зацикленные утекшие объекты. Удалять объекты этот GC не умеет, а только подсказывать где произошли зацикленные ссылки и утечки.
В качестве сильны/слабых ссылок выбраны интрузивные указатели со счетчиком перед объектом. В режиме отладки в них дефайнами инъецируется отладочный код, позволяющий запускать построение дерева всей памяти и вывод "утекших" объектов.
Это же дерево памяти можно использовать для анализа расхода памяти: мы можем увидеть кто кем владеет, сколько памяти выделено в той или иной части игры. Эту информацию можно использовать для оптимизаций памяти игры.
Для этого понадобилось написать свои умные указатели. Это не так сложно, тем более всегда полезно и интересно самому реализовать базовые примитивы, которыми мы пользуемся даже на задумываясь.
И раз уж делать свои, то почему бы не подумать над более компактным неймингом и плюшками. Сильная ссылка объявлена как Ref<>
, слабая как WeakRef<>
. Для создания объекта используется макрос mmake<>(args ...)
, который делает небольшую магию, чтобы запомнить место аллокации в коде. Плюс к этому используется движковая рефлексия, для построения именованного дерева объектов, чтобы понять как называется поле класса со ссылкой на дочерний объект. Моя текущая реализация здесь.
Далее, о тех проблемах, что я повстречал в ходе перевода сырых указателей на умные.
Ссылка на себя в конструкторе
Важное предисловие. Я использую концепцию счетчика ссылок перед объектом, в едином с ним участке памяти. Создавать такой объект через new
нельзя, т.к. выделенный участок памяти не предусматривает счетчик. Поэтому приходится делать специальную функцию, выделяющую соответствующий кусок памяти и инициализирующий счетчик. Выглядит это примерно так:
template<typename _type, typename ... _args>
Ref<_type> Make(_args&& ... args)
{
// 1. Получаем размеры счетчика и типа
constexpr auto counterSize = sizeof(RefCounter);
constexpr auto typeSize = sizeof(_type);
// 2. Аллоцируем единый кусок памяти под счетчик и объект
auto memory = (std::byte*)malloc(counterSize + typeSize);
// 3. Создаем inplace счетчик в начале выделенной памяти
auto refCounter = new (memory) RefCounter();
// 4. Создаем inplace объект за счетчиком. Выравнивание обеспечивается размером самого счетчика
_type* object = new (memory + counterSize) _type(std::forward<_args>(args)...);
// 5. Передаем счетчик в объект
object->SetRefCounter(refCounter);
// 6. Возвращаем готовую сильную ссылку на объект
return Ref<_type>(object);
}
Нюанс кроется на шаге 4: на момент вызова конструктора объекта он еще не знает что он имеет счетчик ссылок, который будет передан ему только после завершения работы конструктора.
Проблемы начинаются когда необходимо взять сильную или слабую ссылку на самого себя (this
) внутри конструктора. Это может быть регистрация в каком-то менеджере или проброс слабой ссылки на себя в дочерний объект. Взять ссылку на себя не получится, ведь объект еще не знает о своем счетчике, что мы будем инкрементировать?
Здесь я попробовал несколько вариантов решения:
хранить свежесозданный счетчик в глобальной переменной, забирая его из этой переменной в конструкторе. Вариант рабочий, но глобальная переменная лежит "где-то" в памяти, совсем не рядом, что совсем не кеш-френдли. Плюс для разных потоков нужен свой контейнер.
запретить использовать ссылки на себя из конструктора, но добавить вызов специального метода, который вызывается сразу после передачи счетчика. Например, в компайл-тайм проверить наличие метода
PostRefConstruct()
, и если таковой определен у типа, вызвать его. В нем уже можно взять ссылку на себя и "прокинуть" куда нужно. Метод рабочий, и в каком-то виде остался в текущей реализации, однако приходится делить свой алгоритм инициализации объекта на 2 фазы: инициализация без счетчика и прокидывание его внутрь структуры. Для части алгоритмов это крайне не удобно, особенно при копировании дерева объектов вглубь.передавать счетчик в конструктор объекта первым параметром. Так же в компайл-тайм можно проверить, умеет ли конструктор принимать первым параметром указатель на счетчик. Если да - то вызывать конструктор с передачей счетчика. Это решает проблему создания ссылок на самого себя из конструктора. Но это загрязняет синтаксис, счетчик в параметре конструктора нужно протаскивать по всем классам-наследникам. Однако, этот подход прижился лучше всего, и ущерб синтаксису получился не такой большой
Множестенное и виртуальное наследование
Как ведут себя ссылки и счетчик при таком сложном наследовании? Например, мы хотим унаследоваться от пары классов, которые уже унаследованы от RefCounterable
, то есть содержат в себе ссылку на счетчик. Получается, нужно несколько счетчиков?
И да, и нет. Нужно несколько ссылок на один и тот же счетчик. То есть мы создаем счетчик, затем объект, и прокидываем ссылку на счетчик во все базовые классы. Это можно сделать либо через констуктор, либо через макросно-шаблонную магию, в которой нужно перечислить все базовые типы со счетчиком. Работает это примерно так
// Структура-хелпер, для рекурсивного вызова функции SetRefCounter() у набора типов _type ... _other_types
template<typename _object_ptr_type>
struct RefCountersSetter
{
template<typename _type, typename ... _other_types>
static void Set(_object_ptr_type object, RefCounter* refCounter)
{
static_cast<_type*>(object)->SetRefCounter(refCounter);
if constexpr (sizeof...(_other_types) > 0)
Set<_other_types...>(object, refCounter);
}
};
// Макрос для перечисления базовых типов внутри класса
#define REF_COUNTERABLE_IMPL(BASE_CLASS, ...) \
void SetRefCounter(RefCounter* refCounter) { RefCountersSetter<typename std::remove_pointer<decltype(this)>::type*>::template Set<BASE_CLASS, ##__VA_ARGS__>(this, refCounter); }
// Пара классов со счетчиками
class A: public RefCounterable { ... };
class B: public RefCounterable { ... };
// Наследник, имплементирующий SetRefCounter таким образом, чтобы счетчик был прокинут в оба базовых класса
class Derived: public A, public B
{
...
REF_COUNTERABLE_IMPL(A, B);
};
Так же можно обойтись интерфейсом, который не имеет ссылки на счетчик, но имеет интерфейс его получения. В таком случае нужно реализовать этот интерфейс в наследуемом типа. Онако, есть нюанс при вызове деструктора: при работе деструктора такого интерфейса, взять ссылку на себя уже не получится (напр. чтобы разрегистрироваться из какого-нибудь менеджера), т.к. наследуемый тип со счетчиком уже разрушен ранее.
Пару слов про виртуальное наследование от RefCounterable
. Это, пожалуй, наихудший вариант. Тут лучше обойтись интерфейсом. Но если не получается, то во всех наследниках придется вручную вызывать конструирование всех виртуальных базовых классов. Это просто взрывает синтаксис...
Функция-конструктор mmake<>()
Казалось бы, в чем проблема, ведь функция выглядит практически идентично тому же new
, разве что с немного другим синтаксисом:
auto rawPtr = new MyObject(argument);
VS
auto smartPtr = mmake<MyObject>(argument);
Однако, IDE перестает понимать, что ты по факту вызываешь конструктор, и не показывает тебе список его аргументов. Для него это функция с вариативным списком шаблонных параметров, он просто не может связать это с конструктором и показать тебе подсказку.
Так же есть проблема и со стороны языка: при передаче аргументов таким образом, компилятор не может понять что initializer list нужно преобразоваться в конкретный тип. Если при вызове обычного конструктора, у него есть информация, например, что первым параметром ожидается вектор, то он преобразует initializer list в вектор. С шаблонным оборачиванием аргументов есть проблемы, нужно явно передавать вектор и кастить на месте.
// Список преобразуется в вектор без проблем
auto rawPtr = new MyObject({ "A", "B", "C" });
// Приходится явно указывать тип, передать просто { "A", "B", "C" } не получится
auto smartPtr = mmake<MyObject>(std::vector<std::string> { "A", "B", "C" });
Трекинг места аллокации в коде
Знать в каком исходнике, в какой строке был создан объект - весьма полезно. Можно элементарно и быстро профилировать откуда идут аллокации.
В работе с сырыми указателями я использовал перегрузку оператора new
, в паре с макросом. Из макроса получаем имя .cpp и номер строки, и передаем это в параметры перегруженного new
:
// Добавляем наше определение специфичного new
void* operator new(size_t size, const char* location, int line);
// Определяем макрос, который автоматом передает нужную инфу
#define mnew new(__FILE__, __LINE__)
// В нужном месте делаем аллокацию через mnew, запоминаем что в этом месте выделено n байт
auto ptr = mnew MyObject();
В случае с функцией конструирования шареного указателя - Make<>()
, такое не прокатит, просто синтаксически. Не получится нормально определить макрос, который смог бы передать имя исходника и строку в аргументы функции. Ведь макрос стоит до указания типа в треугольных скобках:
#define mmake Make(__FILE__, __LINE__) // Попытаемся определить
auto ptr = mmake<MyObject>(); // Развернется в такое: Make(__FILE__, __LINE__)<MyObject>(),
// что невозможно скомпилировать
Но если делать промежуточный объект на стеке, в котором запоминать нужную информацию, а метод конструирования - это функция этого объекта, то можно сотворить то что нам нужно
// Хитрый макрос
#define mmake RefMaker(__FILE__, __LINE__).Make
// Обертка
struct RefMaker
{
const char* location;
int line;
RefMaker(const char* location, int line):location(location), line(line) {}
template<typename _type, typename ... _args>
Ref<_type> Make(_args&& ... args) { ... }
};
// Использование
auto ptr = mmake<MyObject>();
// Разворачивается в:
auto ptr = RefMaker(__FILE__, __LINE__).Make<MyObject>();
Естественно, макрос в таком виде работает только в отладке, в продакшн-сборке никакого промежуточного объекта не создается.
Попытки напрячь ChatGPT
Честно говоря, кроме интересных архитектурных штук, перевод сырых указателей на умные - довольно рутинная штука. Синтаксис похожий, нужно напрягать буквально одну извилину. Но работы просто много, и делать ее лень.
Как раз хотелось поизучать API ChatGPT для реализации своих некоторых идей. Почему бы не попробовать попросить его порефакторить мой код?
Я могу отправлять текст исходников через API с определенным промтом. В нем подробноописать чего я хочу. Казалось что ChatGPT с легкостью справится с такой работой на одну извилину.
Началось все с промта. Его приходилось долго настраивать на примерах, смотреть что выдает ИИ и раз за разом корректировать. Он часто забывал пункты в начале, приходилось максимально четко разжевывать то, что нужно сделать. Делать акценты на что обращать внимание и т.п. Примерного описания что нужно сделать, какое я мог бы дать живому программисту, не подходит. Итоговый промт был примерно таким:
replace raw pointers with Ref<>, except void* pointers. Ref<> is a smart pointer like shared_ptr. Use const Ref<>& for function arguments with pointer types, also in overridden methods. Dont replace void* pointers!! Remove default nullptr value for class members with type Ref<>. If class variable is some kind of "parent", use WeakRef<>.
Путем проб и ошибок получилось получить более-менее работающий промт на выборке из примеров кода. Далее был сделан python скрипт, который закидывал исходники через API в несколько параллельных запросов.
Даже с учетом параллельности работало это не быстро. Провернуть 1/3 сорцов (пара мегабайт) занимало примерно полтора часа. Впрочем, это не мое личное время, поэтому я оставлял его шуршать и уходил пить чай. И это было не дорого, на все эксперименты ушло примерно 5$.
Конечный итог меня разочаровал. Качество рефакторинга было низкое, и приходилось тратить уйму времени на перепроверки. Фактически, несмотря на большой сделанный объем работы со стороны ИИ, мне приходилось просматривать все исходники в поиске ошибок.
Типы ошибок, которые встречались:
не исправлено то, что явно было прописано исправлять
исправлено то, что явно было прописано не исправлять
исправлено, но неправильно. Например забытые const и &
удаление кускров кода или комментариев
банальные опечатки и несуразицы
и конечно же поехавшее к чертям форматирование
Примеры корявой работы AI рефакторинга
В конечном итоге я отказался от такой затеи, т.к. затраты по времени на поиск ошибок были сопоставимы с ручным рефакторингм кода. Фух, пока что программисты в безопасности...
Построение дерева памяти и поиск утечек
Повторюсь, это чисто отладочный инструмент, и в релизной сборке никакой сборки мусора нет. Вся работа по корректному менеджменту памяти должна происходить посредством сильных и слабых ссылок. Функционал GC используется только чтобы построить дерево памяти и указать какие ссылки зациклены и где утекла память
Так как это чисто отладочный инструмент, то артефактом работы инструмента является отладочная информация: дерево памяти, размеченное с помощью рефлексии, отображающее кто кем владеет. Рядом с деревом лежит список "висящих" объектов, то есть по факту утекших.
struct MemoryNode
{
std::string name; // Name of object
std::string type; // Type of object
void* memory = nullptr; // Pointer to allocated memory
MemoryAnalyzeObject* object = nullptr; // Memory analyzeable object
IObject* iobject = nullptr; // Pointer to IObject, if can be casted
size_t size = 0; // Allocated size in bytes
size_t summarySize = 0; // Summary size of all children
size_t leakedSize = 0; // Summary leaked size
MemoryNode* mainParent = nullptr; // Main parent node, the owner of this node
std::vector<MemoryNode*> parents; // Parent nodes
std::vector<MemoryNode*> children; // Children nodes
};
Этот артефакт производит функция, которая на вход принимает список корневых объектов, в моем случае это ссылка на приложение и несколько дополнительных систем движка вне его. Она использует алгоритм маркировки, описанный выше для GC, попутно строя структуру памяти. Зависимости между объектами фильтруются через рефлексию в попытках понять что та или иная ссылка - это поле класса с определеным именем
Однако, есть проблема со ссылками, которые находятся в сырых аллокациях. То есть мы в объекте не держим ссылку напрямую на объект, она хранится внутри аллокации, на которую мы храним сырой указатель, поэтому не можем связать владение объектами.
Откуда сырые указатели в коде на умных указателях? Все просто - стандартные контейнеры. Внутри них работа с памятью идет в ручном режиме, фактически они оперируют сырыми указателями и ручными аллокациями. Элементарный пример это вектор ссылок на объекты. Вектор лежит в объекте, вектор сам аллоцирует память, в которой потом лежат ссылки на другие объекты, и мы не понимаем кто кем владеет, потому что между ними прослойка из сырого указателя.
Выход из ситуации - подчинить под себя еще и стандартные контейнеры. Мне повезло в этом плане в моем движке, ведь у меня уже были свои обертки над stl-ными контейнерами и они использовались везде. Достаточно было добавить в них интерфейс менеджмента зависимостей объектов и вся структура памяти стала строиться корректно.
Еще одна проблема, как понять где утек объект? Мы видим что он "повис", никто им не владеет, однако как понять откуда он взялся и где еще висит ссылка на него? Здесь помогает трассировка стека на момент создания объекта. По нему мы можем понять откуда он был создан, а так же откуда были созданы ссылки на него.
Итоговый инструмент анализа выглядит вот так:
он умеет строить дерево памяти, показывает какой объект каким владеет
показывает сумму аллокаций вглубь дерева. Аллокация суммируется только один раз, для первого владельца
показывает список "вероятно утекших" объектов. "Вероятно" здесь означает что нет гарантии что дерево построено верно, может быть случай с неучтенной сырой аллокацией.
умеет отображать отладочную информацию об объекте: стектрейс откуда он был создан, адрес, размер, превью некоторых типов объектов (напр. текстур)
Отображение дерева можно развернуть, от объекта к владельцам, чтобы понять откуда он впринципе используется. Ведь фактически дерево памяти это граф, где у узла может быть несколько родителей (владельцев). Полезно для ресурсов, например текстур. Такие ресурсы явно шарятся между подсистемами и частями игры, полезно знать где они конкретно используются.
Репозиторий движка: https://github.com/zenkovich/o2
Мой телеграм-канал для менее формального обсуждания: https://t.me/o2engine
Комментарии (46)
eao197
30.07.2024 06:34Нет ли здесь:
// 4. Создаем inplace объект за счетчиком _type* object = new (memory + counterSize) _type(std::forward<_args>(args)...);
проблем с выравниванием для типа
_type
?
Скажем, sizeof(counterSize) у вас 4, а выравнивание для_type
-- 8.anz Автор
30.07.2024 06:34В целом да, здесь явно не учтено выравнивание, но блок счетчика подбирается как раз под размер выравнивания, поэтому проблемы нет
eao197
30.07.2024 06:34Кем и как этот размер подбирается?
anz Автор
30.07.2024 06:34кхм.. мной, с учетом платформ. Ничего специфичного пока в моих планах нет, скорее всего размера 4 + 4 будет достаточно для всех платформ - ios/andoird/mac/win/linux/web. Ну а если понадобится, конечно, добавлю выравнивание здесь
eao197
30.07.2024 06:34+2ИМХО, этот момент имеет смысл отразить в статье. Потому что выравнивание -- это штука, про которые не все помнят, а кто-то и не знает. Скопируют ваш подход и наступят на грабли где-нибудь.
anz Автор
30.07.2024 06:34+1Согласен, немного дополнил это место. Спасибо!
Deosis
30.07.2024 06:34Может, стоит переложить на компилятор задачу выравнивания?
Объявить внутренний класс и больше не беспокоиться, что пользователь может определить класс с выравниванием по 32 байта.
struct pair { RefCounter counter; _type value; }
andy_p
30.07.2024 06:34+4Выскажу неожиданную точку зрения - вся эта возня с умными указателями указывает на плохой дизайн проекта, где не было изначально продумано, кто какой объект создает и уничтожает. При правильном проектировании сырых указателей вполне достаточно.
eao197
30.07.2024 06:34При правильном проектировании сырых указателей вполне достаточно.
Это утверждение относится к игровым движкам или к программированию на C++ вообще?
andy_p
30.07.2024 06:34Вообще.
eao197
30.07.2024 06:34+6Ясно-понятно, вопросов больше не имею.
andy_p
30.07.2024 06:34Ну если всё ясно-понятно, то объясните, как создавали программы на C++ до появления умных указателей.
eao197
30.07.2024 06:34Во-первых, сложно и геморройно. Охота за утечками памяти в начале-середине 1990-х годов была одним из основных занятий при разработке на C++.
Во-вторых, умные указатели появились отнюдь не вместе с C++11. Я сам их начал использовать на повседневной основе во второй половине 1990-х (при этом не был пионером, а подсмотрел в чужих разработках, так что умные указатели в мире C++ уже лет тридцать как, если не больше). Ну а самый простой из них под видом std::auto_ptr был прямо в С++98.
andy_p
30.07.2024 06:34+1Охота за утечками памяти происходит именно из-за кривого дизайна, когда нарушена парность new/delete.
eao197
30.07.2024 06:34+3Так очевидно же, что писать на C++ без утечек памяти, use after free и double free просто: достаточно не совершать ошибок. Делов-то.
andy_p
30.07.2024 06:34Именно про это я и говорю: умные указатели - это заметание ошибок дизайна под ковер.
eao197
30.07.2024 06:34+2Именно про это я и говорю
Мой ответ вам -- это был сарказм на 146%. И речь шла про то, что в программировании еще никому не удавалось не совершать ошибок.
Неужели и вы про это?
умные указатели - это заметание ошибок дизайна под ковер
Вы сейчас серьезно?
dv0ich
30.07.2024 06:34Охота за утечками памяти происходит именно из-за кривого дизайна, когда нарушена парность new/delete.
Да ну? Мне вот кажется, что кривой дизайн это как раз тот, что подстроен под особенности языка.
Да и возможно ли вообще писать на С++ так, чтобы программист не мог забыть про освобождение памяти? Я очень сомневаюсь.
andy_p
30.07.2024 06:34Вполне возможно, если не давать пользователю библиотеки самому делать new и delete.
dv0ich
30.07.2024 06:34объясните, как создавали программы на C++ до появления умных указателей
Так же, как делали вообще всё в IT: долго, геморно, с кучей ручной работы, которой заниматься трудно и скучно.
anz Автор
30.07.2024 06:34Хм, и я так считал. Но у меня буквально был опыт сравнения двух подходов - сырыу указатели в домашнем проекте и умные на работе.
Тем более что движко позволяет писать игровую логику на С++, и там вероятность ошибки слишком велика. Поэтому умные указатели здесь решают
Darell_Ldark
30.07.2024 06:34+1И да, и нет.
Игровой движок, чаще всего, так или иначе позволяет писать игровую логику, манипулируя абстракциями этого самого движка. Отсюда вытекает, что помимо внутреннего устройства движка нужно помнить о том, что пользователь этого движка может написать не самый качественный и продуманный код (а в геймдеве это обыденность) и, как итог, замучается искать ошибку.
Если же движок наружу отдает не сырые указатели, а умные, это будет форсировать пользователя движка так или иначе пользоваться умными указателями и, потенциально, это снизит количество проблем, связаанных с менеджментом памяти.
В целом, чаще всего на хорошее проектирования нет времени - фичи нужны здесь и сейчас.anz Автор
30.07.2024 06:34+1Плюсую. Как бы аккуратно не был сделан менеджмент памяти в движке, в игровом коде будет ад и содомия
andy_p
30.07.2024 06:34+2А вы сделайте свою библиотеку так, чтобы пользователь не мог ничего плохого сделать по сырому указателю..
Darell_Ldark
30.07.2024 06:34Ваш довод имеет право на жизнь и я с ним, скорее, согласен, чем нет. Но нельзя исключать человеческий фактор, который накладывается на сверхсжатые сроки реализации геймплея. Внизу фрагмент кода, который я наблюдал не на одном игровом проекте.
Entity* gameObject = Engine::createGameEntity(/* some args */); // some gameplay logic here .............................. // some code inside multiple if statemets { delete gameObject; } .............................. // and here we go again if(gameObject) gameObject->SomeFunc();
Проблема ли это движка? Нет. Но если бы движок форсировал использование умных указателей для таких сущностей, то большинства ошибок от банальной невнимательности можно было бы избежать.
Это не оправдание, конечно, суровых реалий геймдева, но иногда приходится искать баланс между "правильно" и надо еще вчера.andy_p
30.07.2024 06:34А вот не должен пользователь библиотеки делать delete. Сделайте delete приватным или protected.
Lekret
30.07.2024 06:34Я по работе пишу на Unity и C#, с низкоуровневой разработкой игр и движков знаком, но не сильно, только учусь (язык Odin). Но смотря разные видео-лекции, от того же Кейси Муратори, Майка Актона (знаменитый доклад по DOD), или Джонатана Блоу. Все они топят против умных указателей и против массивов указателей в целом.
Мол делай плотно упакованные массивы данных, вместо указателей используй индексы, смотри не на отдельные объекты, а на множества объектов, используй кастомные аллокаторы вместо new, аллоцируй и переиспользуй большие блоки памяти, и твой код будет работать в 5-10 раз быстрее, при тех же усилиях, а умные указатели отпадут за ненадобностью.
Что думаете про подобный подход? Писать как говорят джентельмены выше на практике слишком сложно? Или если оверхед небольшой, то и ладно?anz Автор
30.07.2024 06:34Это и правда классный подход, но имхо не для всего подходит. Все таки игра не это не только (и далеко не всегда) туча бегающих юнитов, куча рассчетов и т.п. Есть куча UI, особенно в мобайле, где все это просто вредит, т.к. код становится сильно сложнее.
Для такой не требовательной к производительности логике нужно максимальное удобство, и в С++ это дают умные указатели и более простая схема работы
unreal_undead2
30.07.2024 06:34С другой стороны, в AAA игре, выжимающей максимум из возможностей CPU/GPU на физику, графику и т.д., где надо следить, как объекты в памяти размещены относительно кеш линий и страниц, логика и структура игровых объектов тоже заметно сложнее, чем в казуалке - и как то умудряются совмещать оптимальную раскладку объектов в памяти и манипулирование объектами без постоянных ошибок.
Kealon
30.07.2024 06:34Базовые Движки просто слишком простые, для них пул на конвейер самое то. А вот то, что этот пул заполняет уже сложнее гораздо.
Darell_Ldark
30.07.2024 06:34Такой подход используется в том числе и в казуальных играх, правда, в чуть меньших масштабах.
Основная проблема - сложнее поддерживать и изменять такой код. В геймдеве в течение недели могут приходить правки по игровой механике, которые сравнимы с тем, чтобы переписать все заново. Поэтому в геймдеве часто встречается низкокачественный код, что ведет к очевидным ошибкам даже на банальном манипулировании объектами в куче.
То, что Вы описали, чаще всего, применяется в каких-то частных случаях. Например, при оптимизации какой-либо подсистемы, которая уже точно будет существовать в каком-то плюс-минус определенном виде.
edit: к слову, насчет умных указателей и иже с ними: в Unreal Engine они активно используются, а это игровой движок, который по праву можно назвать флагманом среди аналогичных разработок.Lekret
30.07.2024 06:34Насчет поддержки не знаю, возможно соглашусь. Автор цитаты которых я приводил, не согласились бы, Майк Актон говорил, что логика становится заметно проще, 0 абстракций, прозрачный алгоритм, но это ладно, я не настолько опытен, чтобы судить.
Касательно Unreal, умные указатели там есть, но из того, что я видел, работая с ним, часто используются сырые, потому что в движке используется сборщик мусора. В Unigine в плюсовой апишке тоже сырые, и погуглив тоже нашёл информацию про сборщик мусора. В новом O3DE всякие Entity/Component тоже по сырым (вроде без GC). Умные указатели видел только в недавнем UltraEngine. Так что все флагманы стараются от умных указателей уйти, пусть даже в сборку мусора.Darell_Ldark
30.07.2024 06:34Со сборкой мусора возникает резонный вопрос - зачем и по каким причинам ему отдается предпочтение, а не более, на первый взгляд, маловесной системе из умных указателей. Я не видел статей, которые бы сравнивали эти два варианта менеджмента памяти на каких-то реальных или около реальных кейсах.
Насчет нуля абстракций и простоты логики - это все зависит сильно от проекта, на самом деле. Где-то да, это даст максимальную простоту, а где-то это в итоге приведет к ненужным усложнениям во имя "оптимизации". К тому же важно помнить, что игровые студии предпочитают экономить не ресурсы компьютера, а время сотрудников. Так что вопрос такой, не самый простой и от случая к случаю можно получить очень разные мнения и результаты.
Я работал с чем-то отдаленно похожим на ECS. В плане производительности нареканий не было совершенно, но иногда уходило прилично времени на то, чтобы найти нужный компонент и нужную систему для того, чтобы внести в них правки. Но т.к. проект требовал множества сущностей одномоментно, это был разумный компромисс.
В целом, так уж получается, что подходы подстраивают под нужды и цели бизнеса, а не наоборот. Отсюда, в общем-то, появляются всякие абстракции, библиотеки (и движки тоже) и все движется к тому, чтобы можно было взять любого программиста на стеке Х и безшовно заменить его на другого из этого же стека. Мое личное мнение, что именно на этом все завязано. Именно отсюда идут корни всяких книг навроде "Чистый код" Роберта Мартина, именно поэтому мы начинаем жертвовать ресурсы машины во имя бизнес-процессов.
Если коротко суммировать то, что я написал - все зависит от конкретного проекта, его специфики, масштабов, конкретной студии, которая его разрабатывает и итоговых целей этого проекта. Где-то можно пожертвовать половину бюджета кадра ради того, чтобы студии было проще жить, где-то предпочтение нужно отдать производительности проекта. Отсюда выбирается уже методология и подходы работы.
azTotMD
30.07.2024 06:34shared_pointer - это понятно, а можно наоборот? Есть несколько указателей на объект. Если я по одному из указателей сделал delete, то всем остальным бы привоился nullptr?
zzzzzzerg
30.07.2024 06:34weak_ptr
azTotMD
30.07.2024 06:34ага, метод expired(), спасибо. А что если объект был уничтожен, а потом была выделена новая память и так случайно получилось, она выделилась в том же месте, где был старый объект, expired по-прежнему вернёт true?
zzzzzzerg
30.07.2024 06:34+2Насколько я понимаю, weak_ptr разделяет управляющую структуру с shared_ptr и пока существует хотя бы одна ссылка (weak_ptr или shared_ptr), то управляющая структура не будет освобождена и соответственно ее память не может быть повторно использована, а в этой структуре указано был ли освобожден shared_ptr, что будет влиять на результат expired и lock.
unreal_undead2
А в игровом движке (где, казалось бы, важна производительность) повсеместное использование умных указателей оправдано? А то видел недавно код (правда, немного из другой области) с атомарными инкрементами/декрементами, растущими из изменения счётчика ссылок, в числе основных хотспотов.
anz Автор
Да, если без фанатизма, то все впорядке. На крайний случай в какие-то тяжелые алгоритмы можно и сырой указатель передать. Умные указатели решают проблему утечек и менеджмента памяти, что в играх весьма актуально
unreal_undead2
На мой взгляд это и есть главное в движке (всяческая физика или её аппроксимация, работающая с адекватной скоростью на реальном железе), а всяческое построение деревьев объектов и т.п. - вспомогательная обвязка. В этой обвязке умные указатели, конечно, вполне имеют право на жизнь.
anz Автор
На самом деле с умными указателями не так все плохо. В целом накладные ресурсы довольно маленькие, а со счетчиком перед объектом и вовсе мизерные
unreal_undead2
Если профилировка конкретной реализации (самих указателей и основанного на них движка) это подтверждает, никаких проблем.
domix32
Если игра не слишком большая, то нормально. Если объёектов планируется спавнить много, то лучше жить с ECS, аренами и сырыми указателями. Ещё есть вариант похожий на gc - autorelease pool, как в кокосе. Там можно ручками инкрементить счётчик ссылок. Проблемы там правда похожие получаются и следить за объектами несколько сложнее, плюс дефолтная реализация управление пулом без допиливание напильником начинает заметно фрагментировать память в некоторых ситуациях. Если есть какие-то ограничения по памяти, то это надо будет патчить.