image

Очень часто возникает проблема с обработкой ссылок на игровые объекты (назову эти объекты сущностями). Иногда речь идёт об отношениях «предок-потомок» между сущностями. Бывает, что полезно сохранить ссылку на объект при работе с данными о событиях, в классе планировщика задач и т.д.

Иногда можно обойтись простым указателем, ссылкой или std::reference_wrapper. Но при работе с обычными указателями и ссылками есть такая проблема: если вы ими пользуетесь, то должны быть уверены, что та сущность, на которую направлена ссылка, остаётся жива и не перемещается в памяти без уведомления тех объектов, что держат ссылку на неё. Пожалуй, в хорошо спроектированной программе это достижимо.

Однако устаревшие указатели/ссылки провоцируют некоторые гнуснейшие баги. Эти баги сложно отслеживать, не так легко выявить, и они могут обрушить вашу игру. Некоторые сущности могут занимать в памяти иные адреса, нежели предполагалось (например, после того, как вы перезагрузите уровень или решите заново создать с нуля всех врагов). Конечно, можно и вручную обновить все указатели на те сущности, на которые мы ранее ссылались. Но, определённо, это лучше делать автоматически, чтобы не оставалось никакой вероятности забыть какие-либо указатели, которые потом устареют.

Давайте рассмотрим, как можно решить все эти проблемы. Новейшее решение (связанное с сохранением ссылок в Lua) было обнаружено мною не так давно и побудило меня написать эту статью, но я хочу показать, что есть и другие способы решить эту задачу. Приступим!

shared_ptr и weak_ptr


Некоторые проблемы с обычными ссылками решаемы при помощи std::shared_ptr и std::weak_ptr. Сначала мы создаём наши сущности так, как это делалось бы при помощи std::make_shared. После этого создаём все ссылки на них при помощи std::weak_ptr, что не повлияет на время жизни вашей сущности. После этого можно воспользоваться функцией std::weak_ptr::expired, чтобы проверить, действительна ли ссылка до сих пор.

Это решение несовершенно. Прежде всего, потому, что требует создавать все ваши сущности в виде shared_ptr, а это может повлечь серьёзные издержки по сравнению с unique_ptr. В конце концов, лучше не делать время жизни сущностей разделяемым, соответственно, лучше пользоваться семантикой unique_ptr. Кроме того, разделяемые указатели не решают проблему перемещения сущностей в памяти: вы не сможете поменять то, что находится в shared_ptr и заставить все weak_ptr автоматически обновить их указатели.

Использование уникальных id


Проблему можно было бы решить, просто создавая уникальные id для сущностей и храня именно эти id, а не обычные указатели или ссылки. Есть множество способов генерировать и представлять id сущностей. Id можно представить в виде обычных целых чисел при помощи EntityManager и счётчика, значение которого будет увеличиваться по мере создания новых сущностей: первая сущность получит id=0, вторая id=1 и т.д. Еще id можно генерировать при помощи какого-нибудь хеширующего алгоритма или UUID. Что бы ни случилось, ваши id должны оставаться уникальными, если вы только сами не добавите к id некоторую дополнительную информацию (например, время создания сущности или какие-нибудь теги).

Вот как мог бы выглядеть ваш класс EntityManager:

class EntityManager {
public:
    Entity* getEntity(EntityId id) const;
    bool entityExists(EntityId id) const;
    // ...
private:
    std::unordered_map<EntityId, std::unique_ptr<Entity>> entities;
    // ...
};

Используя id, также легче решить проблему с воссозданием: можно с легкостью воссоздать/перезагрузить сущность и просто присвоить ей тот же самый id, который был у неё раньше. Да, она станет занимать новый адрес в памяти, но, когда кто-нибудь повторно вызовет getEntity, ему будет возвращена обновлённая сущность. Эти id также можно с лёгкостью передавать по сети и сохранять в файлах.

При использовании id сущностей ваш код примет вид:

auto entityPtr = g_EntityManager.getEntity(entityId);
entityPtr->doSomething();

Разумеется, при этом возникают некоторые издержки, поскольку теперь у нас появляется уровень косвенности: приходится искать в unordered_map внутри EntityManager, чтобы получить обычную ссылку на сущность, но, если вы не хотите делать этого слишком часто (скорее всего – не хотите), то вас это устроит!

Здесь можно внести и еще одно улучшение: можно обернуть ваш id в структуру, а затем перегрузить operator-> так, чтобы описатель действовал как обычный указатель:

struct EntityHandle {
    EntityId id;
    EntityManager* entityManager;

    EntityHandle(EntityId id, EntityManager* entityManager) :
        id(id), entityManager(entityManager)
    {}

    Entity* operator->() const {
        return get();
    }

    Entity* get() const {
        assert(entityManager->entityExists(id));
        return entityManager->getEntity(id);
    }
};

Теперь можно делать такие вещи:

EntityHandle handle(someEntityId, &g_EntityManager);
handle->doSomething();

// or...

auto entityPtr = handle.get();
entityPtr->doSomething();

Обратите внимание: также у нас появилась определённая проверка ошибок, позволяющая выдвигать и проверять утверждения о том, действительны ли ссылки. Отлично! Теперь давайте посмотрим, как можно ссылаться на сущности в скриптах на Lua.

Сохранение ссылок на сущности в Lua


Полная реализация находится здесь: вариант на C++, вариант на Lua.

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

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

Если вы хотите удалить или воссоздать какую-либо сущность, то должны уведомить об этом главный описатель Lua, который будет храниться в какой-нибудь глобальной таблице Lua. Поскольку все ваши описатели на Lua будут ссылками на главный описатель, о них можно не волноваться: как только вы обновите главный описатель, он обновится повсюду.
Ещё одно достоинство заключается в следующем: как только сущность удаляется, можно просто установить isValid в false и обычную ссылку в nil – просто для дополнительной безопасности.

Перейдём к реализации! Воспользуемся sol2 в качестве библиотеки для связывания Lua/C++. Давайте напишем простые классы Entity и EntityManager для тестирования:

using EntityId = int;

class Entity {
public:
    explicit Entity(EntityId id) :
        name("John"), id(id)
    {}

    const std::string& getName() const { return name; }
    void setName(const std::string& n) { name = n; }
    EntityId getId() const { return id; }
private:
    std::string name;
    EntityId id;
};

sol::state lua; // глобальные значения – это плохо, но мы воспользуемся ими, чтобы упростить реализацию 

class EntityManager {
public:
    EntityManager() : idCounter(0) {}

    Entity& createEntity()
    {
        auto id = idCounter;
        ++idCounter;

        auto inserted = entities.emplace(id, std::make_unique<Entity>(id));
        auto it = inserted.first; // итератор для созданной пары id/сущность 
        auto& e = *it->second; // созданная сущность
        lua["createHandle"](e);
        return e;
    }

    void removeEntity(EntityId id)
    {
        lua["onEntityRemoved"](id);
        entities.erase(id);
    }
private:
    std::unordered_map<EntityId, std::unique_ptr<Entity>> entities;
    EntityId idCounter;
};

Вот как мы создадим описатель на Lua:

function createHandle(cppRef)
    local handle = {
        cppRef = cppRef,
        isValid = true
    }
    setmetatable(handle, mt)
    Handles[cppRef:getId()] = handle
    return handle
end

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

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

local mt = { }
mt.__index = function(handle, key)
    if not handle.isValid then
        print(debug.traceback())
        error("Error: handle is not valid!", 2)
    end

    return function(handle, ...) return Entity[key](handle.cppRef, ...) end
end

Быстро напомню: метатабличная функция __index вызывается, когда ключ в таблице не найден, и передаются сама таблица (наш описатель) и недостающий ключ.

Вот пример, показывающий, как все это работает. Если сделать так:

handle:setName("John")

то Lua проверяет, есть ли в таблице-описателе ключ “setName”, но его там нет. Поэтому вызывается метатабличная функция __index, параметрами которой являются описатель и значение “John”. Возвращается обёртка функции экземпляра Entity, и эта функция вызывается. Та функция, которая нам возвращается – это замыкание, вызывающее функцию экземпляра класса Entity по исходной обычной ссылке. Почему же нам просто не вернуть Entity[key]? В таком случае возникла бы проблема: в Entity[key]был бы передан наш описатель, тогда как функция ожидает, что ей будет передана обычная ссылка (cppRef:setName(«John»), и функционально это будет аналогично вызову Entity.setName(cppRef, «John»)).

Проверка ошибок, организованная нами здесь, исключительно важна и полезна! Она позволяет с лёгкостью отлаживать проблемы, которые могут возникнуть в коде: мы даже выводим на экран стек вызовов, чтобы найти конкретное место, в котором отказал наш код!
Обратите внимание: в качестве второго аргумента функции error мы передаём “2”. Этот аргумент сообщает, что проблема заключается не в той функции, что ее вызвала, а в другой функции, расположенной ниже в стеке вызовов.

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

Давайте сначала протестируем нашу ссылку:

function test(cppRef)
    local handle = createHandle(cppRef)
    testHandle(handle)
end

function testHandle(handle)
    print("Hello, my name is " .. handle:getName())
    handle:setName("Mark")
    print("My name is " .. handle:getName() .. " now!")
end

Вывод:

Работает! Что же нам делать, когда сущность удаляется? Давайте создадим для этого функцию:

function onEntityRemoved(id)
    local handle = Handles[id];
    handle.cppRef = nil
    handle.isValid = false
    Handles[id] = nil
end

Её необходимо вызвать до того, как интересующая нас сущность будет удалена. Эту функцию можно поместить в деструктор Entity или в функцию removeEntity от EntityManager. Обратите внимание: сам описатель от этого не удаляется: кто-нибудь по-прежнему может на него ссылаться. Но все равно полезно устанавливать соответствующее значение таблицы Handles в nil, так как если кто-нибудь попробует подобрать этот описатель позже, в ответ он получит nil. Ещё важнее в данном случае, что isValid устанавливается в false. Поэтому, когда в следующий раз кто-нибудь попытается воспользоваться описателем, возникнет ошибка.

Теперь давайте рассмотрим, что произойдёт, если попытаться воспользоваться недействительной ссылкой. Теперь мы даже обработку ошибок можем проводить на стороне Lua!

function testBadReference()
    local handle = Handles[0] -- этот описатель существует и проблем не вызывает
    handle.isValid = false -- но предположим, что этот объект был удален!
    local _, err = pcall(testHandle, handle)
    if err then
        print(err)
    end
end

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

stack traceback:
    script.lua:23: in function 'getName'
    script.lua:57: in function <script.lua:56>
    [C]: in function 'pcall'
    script.lua:65: in function <script.lua:62>
script.lua:57: Error: handle is not valid!

Что насчёт производительности? Мои тесты показывают, что на вызов функции экземпляра С++ уходит около 600 наносекунд. Нет так плохо, но кого-то всё равно может не устроить. В таком случае легко получить обычную ссылку, а затем пользоваться ею без каких-либо дополнительных издержек на проверку ошибок:

local rawRef = handle.cppRef
print("Raw reference used. Name: " .. rawRef:getName())

Также мы можем ускорить функцию __index. Я обнаружил, что самая серьёзная издержка – всякий раз создавать замыкание… поэтому давайте просто запомним наши функции-обёртки! Первым делом создадим таблицу, в которой будут храниться наши функции-обёртки:

local memoizedFuncs = {}

A затем изменим наш метод __index вот так:

mt.__index = function(handle, key)
    if not handle.isValid then
        print(debug.traceback())
        error("Error: handle is not valid!", 2)
    end

    local f = memoizedFuncs[key]
    if not f then
        f = function(handle, ...) return Entity[key](handle.cppRef, ...) end
        memoizedFuncs[key] = f
    end
    return f
end

Замыкание для каждой функции будет создаваться всего однажды, а затем – использоваться повторно. В таком случае вся работа существенно ускорится! Издержки составят примерно по 200 наносекунд на вызов.

Что ещё? При вызове функции через __index также возникают дополнительные издержки. Предположим, что мы очень часто пользуемся функцией getName и хотим включить её в таблицу-описатель, чтобы эта функция вызывалась напрямую. Давайте это сделаем!

function createHandle(cppRef)
    local handle = {
        cppRef = cppRef,
        isValid = true
    }
    handle.getName = function(handle, ...)
        return Entity.getName(handle.cppRef, ...)
    end

    setmetatable(handle, mt)
    Handles[cppRef:getId()] = handle
    return handle
end

Секундочку… а что произойдет, если вызвать через getName неисправный описатель? Ведь проверки ошибок нет! Давайте это исправим:

f
unction getWrappedSafeFunction(f)
    return function(handle, ...)
            if not handle.isValid then
                print(debug.traceback())
                error("Error: handle is not valid!", 2)
            end
            return f(handle.cppRef, ...)
        end
end

а затем в createHandle напишем:

handle.getName = getWrappedSafeFunction(Entity.getName)

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

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


  1. Plesser
    26.12.2022 14:07

    Вы что то совсем перестали выпускать книги в электронном виде. Это направление закрыто?

    PS

    Будет ли напечатано последние издание книги от Big Nerd Ranch Программирование под Android?


    1. ph_piter Автор
      26.12.2022 16:13
      +1

      1. После НГ выпуск.

      2. Да, данная книга будет издана в конце 23 года.


      1. Plesser
        26.12.2022 16:19

        1. когда она уже устареет..... очень жаль