Глобальные объекты и места их обитания
Итак, что же за зверь этот глобальный объект, как его приручить и пользоваться удобствами, сведя недостатки к минимуму? Давайте разбираться вместе.
Существует масса способов создать глобальный объект. Самый простой — объявить extern-переменную в заголовочном файле и создать её экземпляр в cpp:
// header file
extern Foo g_foo;
// cpp file
Foo g_foo;
Более абстрактным подходом является шаблон одиночка (singleton).
void PrepareFoo(...)
{
FooManager::getInstance().Initialize ();
}
Чем хорошо данное решение, что ему уделяется так много внимания? Оно позволяет использовать объект в любом месте программы. Весьма удобно, и соблазн сделать так очень велик. Проблемы начинаются, когда нужно заменить часть системы, не нарушив работу всего остального, или же протестировать код. В последнем случае нам придётся инициализировать чуть ли не все глобальные переменные, которые использует интересующий нас метод. Более того, вышеперечисленные трудности очень усложняют замену поведения объекта на желаемое для тестов. Также нет контроля за порядком создания и удаления, что может привести к неопределённому поведению или падениям программы. Например, когда обращаются к ещё не созданному или уже удалённому глобальному объекту.
В общем случае предпочтительно использование локальных переменных вместо глобальных. К примеру, если вам нужно отрисовать некий объект и есть глобальный Renderer, то лучше его передать напрямую в метод void Draw(Renderer& render_instance)
, а не использовать глобальный Render::Instance
(). Больше примеров и обоснований, почему не стоит использовать синглтон, можно почитать в посте.
Однако совсем без глобальных объектов обойтись сложно. Если нужен доступ к настройкам или прототипам, то к каждому объекту не прицепишь все нужные контейнеры, фабрики и прочие параметры. Этот случай мы и будем рассматривать.
Для начала постановка задачи:
- К объекту должен быть доступ из любой части программы.
- Все перерабатываемые глобальные объекты должны храниться централизованно — для простоты поддержки.
- Возможность добавлять и/или заменять глобальные объекты в зависимости от контекста — реальный запуск или тестирование.
Чтобы считать реализацию успешной, важно выполнение всех обозначенных условий.
Интересное решение было подсмотрено в недрах CryEngine (смотреть SSystemGlobalEnvironment
). Глобальные объекты завёрнуты в одну структуру и являются указателями на абстрактные сущности, которые инициализируются в нужный момент в нужном месте программы. Никаких дополнительных накладных расходов, никаких лишних надстроек, контроль за типом во время компиляции — красота!
CryEngine представляет собой достаточно старый и годами обточенный проект, где все интерфейсы устаканились, а новое прикручивается подобно тому, что существует на данный момент. Поэтому нет необходимости придумывать дополнительные обёртки или способы работы с глобальными объектами. Есть и другой вариант — молодой и бурно развивающийся проект, где нет строгих интерфейсов, где функционал постоянно меняется, что сподвигает вносить правки в интерфейсы достаточно часто. Хочется иметь решение, которое поможет в старых проектах производить рефакторинг, а в новых, где всё же необходим глобальный доступ, минимизировать недостатки использования. Для поиска ответа можно попробовать подняться на уровень выше и посмотреть на проблему под другим углом — создать хранилище глобальных объектов, наследуемых от GlobalObjectBase
. Использование оболочки добавит операции во время исполнения, поэтому обязательно нужно обратить внимание на производительность после изменений.
Для начала необходимо создать базовый класс, наследников которого можно будет поместить в объект-хранилище.
class GlobalObjectBase
{
public:
virtual ~GlobalObjectBase() {}
};
Теперь само хранилище. Для доступа из любой части программы объект этого класса необходимо сделать глобальным при помощи одного из стандартных способов, который вам понравится больше.
class GlobalObjectsStorage
{
private:
using ObjPtr = std::unique_ptr;
std::vector m_dynamic_globals;
private:
GlobalObjectBase* GetGlobalObjectImpl(size_t i_type_code) const
{ … }
void AddGlobalObjectImpl(std::unique_ptr ip_object)
{ … }
void RemoveGlobalObjectImpl(size_t i_type_code)
{ … }
public:
GlobalObjectsStorage() {}
template
void AddGlobalObject()
{
AddGlobalObjectImpl(std::make_unique());
}
template
ObjectType* GetGlobalObject() const
{
return static_cast(GetGlobalObjectImpl(typeid(ObjectType).hash_code());
}
template
void RemoveGlobalObject()
{
RemoveGlobalObjectImpl(typeid(ObjectType).hash_code());
}
};
Для работы с данным видом объектов достаточно их типа, поэтому интерфейс
GlobalObjectsStorage
составляют шаблонные методы, которые передают нужные данные реализации.Итак, первый тест-драйв — работает!
class FooManager : public GlobalObjectBase
{
public:
void Initialize() {}
};
static GlobalObjectsStorage g_storage; // имитируем глобальность хранилища
void Test()
{
// делаем объект "глобальным"
g_storage.AddGlobalObject();
// используем
g_storage.GetGlobalObject()->Initialize();
// и удаляем
g_storage.RemoveGlobalObject();
}
Но это ещё не всё — подменять объекты для разных контекстов нельзя. Исправляем, добавив класс-родитель для хранилища, перенеся шаблонные методы туда, и сделав виртуальными методы имплементации.
template
class ObjectStorageBase
{
private:
virtual BaseObject* GetGlobalObjectImpl(size_t i_type_code) const = 0;
virtual void AddGlobalObjectImpl(std::unique_ptr ip_object) = 0;
virtual void RemoveGlobalObjectImpl(size_t i_type_code) = 0;
public:
virtual ~ObjectStorageBase() {}
template
void AddGlobalObject()
{
AddGlobalObjectImpl(std::make_unique());
}
template
ObjectType* GetGlobalObject() const
{
return static_cast(GetGlobalObjectImpl(typeid(ObjectType).hash_code()));
}
template
void RemoveGlobalObject()
{
RemoveGlobalObjectImpl(typeid(ObjectType).hash_code());
}
virtual std::vector GetStoredObjects() = 0;
};
class GameGlobalObject : public GlobalObjectBase
{
public:
virtual ~GameGlobalObject() {}
virtual void Update(float dt) {}
virtual void Init() {}
virtual void Release() {}
};
class DefaultObjectsStorage : public ObjectStorageBase
{
private:
using ObjPtr = std::unique_ptr;
std::vector m_dynamic_globals;
private:
virtual GameGlobalObject* GetGlobalObjectImpl(size_t i_type_code) const override
{ … }
virtual void AddGlobalObjectImpl(std::unique_ptr ip_object) override
{ … }
virtual void RemoveGlobalObjectImpl(size_t i_type_code) override
{ … }
public:
DefaultObjectsStorage() {}
virtual std::vector GetStoredObjects() override { return m_cache_objects; }
};
static std::unique_ptr> gp_storage(new DefaultObjectsStorage());
void Test()
{
// делаем объект "глобальным"
gp_storage->AddGlobalObject();
// используем
gp_storage->GetGlobalObject()->Initialize();
// и удаляем
gp_storage->RemoveGlobalObject();
}
Часто над глобальными объектами нужно проводить разные манипуляции во время создания или удаления. В наших проектах это чтение данных с диска (например, файла настроек для подсистемы), обновление данных игрока, которое происходит при загрузке приложения и через определённый интервал времени во время игры, и обновление внутриигрового цикла. У других программ могут быть дополнительные или совершенно иные действия. Поэтому конечный базовый тип будет определяться пользователем класса и позволит избежать множественного вызова одинаковых методов.
for (auto p_object : g_storage->GetStoredObjects())
p_object->Init();
Всё ли в итоге у нас хорошо?
Понятно, что производительность от подобной обёртки будет хуже, чем от использования глобального объекта напрямую. Для теста было создано десять различных типов. Сначала они использовались как глобальный объект без наших изменений, затем через
DefaultObjectsStorage
. Результат для 1 000 000 вызовов.Текущий код работает медленнее обычного глобального объекта почти в 18 раз! Профайлер подсказывает, что больше всего времени занимает
typeid(*obj).hash_code()
. Раз добыча данных о типах во время исполнения тратит очень много процессорного времени, то нужно её обойти. Самый простой способ сделать это — хранить хеш типа в базовом классе глобальных объектов (GlobalObjectBase
).class GlobalObjectBase
{
protected:
size_t m_hash_code;
public:
...
size_t GetTypeHashCode() const { return m_hash_code; }
virtual void RecalcHashCode() { m_hash_code = typeid(*this).hash_code(); }
};
Также стоит поменять метод
ObjectStorageBase::AddGlobalObject и DefaultObjectsStorage:: GetGlobalObjectImpl
. Дополнительно статически сохраняем данные о типе в шаблонной функции родительского класса ObjectStorageBase::GetGlobalObject
.template
class ObjectStorageBase
{
…
public:
template
void AddGlobalObject()
{
auto p_object = std::make_unique();
p_object->RecalcHashCode();
AddGlobalObjectImpl(std::move(p_object));
}
template
ObjectType* GetGlobalObject() const
{
static size_t type_hash = typeid(ObjectType).hash_code());
return static_cast(GetGlobalObjectImpl(type_hash);
}
…
};
class DefaultObjectsStorage : public ObjectStorageBase
{
…
private:
virtual GlobalObjectBase* GetGlobalObjectImpl(size_t i_type_code) const override
{
auto it = std::find_if(m_dynamic_globals.begin(), m_dynamic_globals.end(), [i_type_code](const ObjPtr& obj)
{
return obj->GetTypeHashCode() == i_type_code;
});
if (it == m_dynamic_globals.end())
{
// здесь можно добавить ассерт о том, что что-то пошло не так
return nullptr;
}
return it->get();
}
…
};
Вышеуказанные изменения позволяют существенно уменьшить время поиска нужного объекта, и отличие будет уже не в 18 раз, а в 1,25 — это вполне приемлемо в большинстве случаев.
Кроме того, чтобы не менять целое хранилище для тестов, можно переопределять метод
GlobalObjectBase::RecalcHashCode
и выборочно заменять только нужные объекты. Для замены в основном классе необходимо сделать виртуальными нужные для теста методы и тестовый класс-наследник.struct Foo : public GlobalObjectBase
{
int x = 0;
virtual void SetX()
{
x = rand()%1;
}
};
struct FooTest : public Foo
{
virtual void SetX() override
{
x = 5;
}
virtual void RecalcHashCode() { m_hash_code = typeid(First).hash_code(); }
};
g_getter.AddGlobalObject();
g_getter.GetGlobalObject()->SetX();
Первопроходцем для внедрения этого подхода был Fishdom, где несколько объектов стали использоваться через данную обёртку. Это позволило убрать зависимости, покрыть часть кода тестами и сделать удобнее монотонную работу по вызову методов (Init, Release, Update) в нужных местах.
По ссылке можно найти финальный код оболочки и описанные тесты.
Комментарии (2)
29 ноября 2016 в 15:54
+1↑
↓
Вектор. С линейным поиском по хэшу. Но вектор. Тут что-то явно не логично.
То есть, пока объектов мало, и все влезает в кэш (процессора) — все будет хорошо, и даже быстрее std: map какого-нибудь. А вот если объектов будут сотни тысяч, это все повылезает и будет смотреть на вас с немым укором.Без иронии — вы гонитесь за скоростью, но используете линейный поиск. В таком случае, стоило бы пояснить, почему вы не используете структуру с логарифмическим временем доступа.
29 ноября 2016 в 16:00
0↑
↓
Предположительно, в проекте не будет больше 30–40 глобальных переменных. В том же CryEngine насчитал в структуре около 45. С этим расчётом разрабатывалось и тестировалось. В случае, если количество глобальных объектов переваливает за сотню, то, возможно, с архитектурой проекта не всё хорошо.
В любом случае замечание дельное и в ближайшее время постараюсь потестировать с большим количеством объектов и разными контейнерами. Благодарю за отзыв.