Глобальные объекты и места их обитания

в 12:17, , рубрики: c++, архитектура приложений, Блог компании Playrix, разработка игр, с++ templates

Глобальные объекты получили широкое распространение из-за удобства их использования. В них хранят настройки, игровые сущности и вообще любые данные, которые могут понадобиться где угодно в коде. Передача же в функцию всех нужных аргументов может раздуть список параметров до очень большого размера. Помимо удобства есть и недостатки: порядок инициализации и разрушения, дополнительные зависимости, сложность написания юнит-тестов. Многие программисты предвзято считают, что глобальные переменные используют только новички и это уровень студенческих лабораторных. Однако в больших проектах, как CryEngine, UDK, OGRE, глобальные объекты также применяются. Разница только в уровне владения этим инструментом.

Глобальные объекты и места их обитания - 1

Итак, что же за зверь этот глобальный объект, как его приручить и пользоваться удобствами, сведя недостатки к минимуму? Давайте разбираться вместе.

Существует масса способов создать глобальный объект. Самый простой — объявить 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(). Больше примеров и обоснований, почему не стоит использовать синглтон, можно почитать в посте.

Однако совсем без глобальных объектов обойтись сложно. Если нужен доступ к настройкам или прототипам, то к каждому объекту не прицепишь все нужные контейнеры, фабрики и прочие параметры. Этот случай мы и будем рассматривать.

Для начала постановка задачи:

  1. К объекту должен быть доступ из любой части программы.
  2. Все перерабатываемые глобальные объекты должны храниться централизованно — для простоты поддержки.
  3. Возможность добавлять и/или заменять глобальные объекты в зависимости от контекста — реальный запуск или тестирование.

Чтобы считать реализацию успешной, важно выполнение всех обозначенных условий.

Интересное решение было подсмотрено в недрах CryEngine (смотреть SSystemGlobalEnvironment). Глобальные объекты завёрнуты в одну структуру и являются указателями на абстрактные сущности, которые инициализируются в нужный момент в нужном месте программы. Никаких дополнительных накладных расходов, никаких лишних надстроек, контроль за типом во время компиляции – красота!

CryEngine представляет собой достаточно старый и годами обточенный проект, где все интерфейсы устаканились, а новое прикручивается подобно тому, что существует на данный момент. Поэтому нет необходимости придумывать дополнительные обёртки или способы работы с глобальными объектами. Есть и другой вариант — молодой и бурно развивающийся проект, где нет строгих интерфейсов, где функционал постоянно меняется, что сподвигает вносить правки в интерфейсы достаточно часто. Хочется иметь решение, которое поможет в старых проектах производить рефакторинг, а в новых, где всё же необходим глобальный доступ, минимизировать недостатки использования. Для поиска ответа можно попробовать подняться на уровень выше и посмотреть на проблему под другим углом – создать хранилище глобальных объектов, наследуемых от GlobalObjectBase. Использование оболочки добавит операции во время исполнения, поэтому обязательно нужно обратить внимание на производительность после изменений.

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

	class GlobalObjectBase
	{
	public:
		virtual ~GlobalObjectBase() {}
	};

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

Класс хранилища
class GlobalObjectsStorage
{
private:
	using ObjPtr = std::unique_ptr<GlobalObjectBase>;
	std::vector<ObjPtr> m_dynamic_globals;
private:
	GlobalObjectBase* GetGlobalObjectImpl(size_t i_type_code) const
	{ … }
	void AddGlobalObjectImpl(std::unique_ptr<GlobalObjectBase> ip_object)
	{ … }
	void RemoveGlobalObjectImpl(size_t i_type_code)
	{ … }
public:
	GlobalObjectsStorage() {}	

	template <typename ObjectType>
	void AddGlobalObject()
	{
		AddGlobalObjectImpl(std::make_unique<ObjectType>());
	}
	template <typename ObjectType>
	ObjectType* GetGlobalObject() const
	{
		return static_cast<ObjectType*>(GetGlobalObjectImpl(typeid(ObjectType).hash_code());
	}

	template <typename ObjectType>
	void RemoveGlobalObject()
	{
		RemoveGlobalObjectImpl(typeid(ObjectType).hash_code());
	}
};

Для работы с данным видом объектов достаточно их типа, поэтому интерфейс GlobalObjectsStorage составляют шаблонные методы, которые передают нужные данные реализации.

Итак, первый тест-драйв – работает!

class FooManager : public GlobalObjectBase
{
public:
	void Initialize() {}
};

static GlobalObjectsStorage g_storage; // имитируем глобальность хранилища

void Test()
{
	// делаем объект "глобальным"
	g_storage.AddGlobalObject<FooManager>();
	// используем
	g_storage.GetGlobalObject<FooManager>()->Initialize();
	// и удаляем
	g_storage.RemoveGlobalObject<FooManager>();
}

Но это ещё не всё – подменять объекты для разных контекстов нельзя. Исправляем, добавив класс-родитель для хранилища, перенеся шаблонные методы туда, и сделав виртуальными методы имплементации.

Базовый класс хранилища
template <typename BaseObject>
class ObjectStorageBase
{
private:
	virtual BaseObject* GetGlobalObjectImpl(size_t i_type_code) const = 0;
	virtual void AddGlobalObjectImpl(std::unique_ptr<BaseObject> ip_object) = 0;
	virtual void RemoveGlobalObjectImpl(size_t i_type_code) = 0;
public:
	virtual ~ObjectStorageBase() {}
	template <typename ObjectType>
	void AddGlobalObject()
	{
		AddGlobalObjectImpl(std::make_unique<ObjectType>());
	}

	template <typename ObjectType>
	ObjectType* GetGlobalObject() const
	{
		return static_cast<ObjectType*>(GetGlobalObjectImpl(typeid(ObjectType).hash_code()));
	}

	template <typename ObjectType>
	void RemoveGlobalObject()
	{
		RemoveGlobalObjectImpl(typeid(ObjectType).hash_code());
	}
	virtual std::vector<BaseObject*> GetStoredObjects() = 0;
};


class GameGlobalObject : public GlobalObjectBase
{
	public:
		virtual ~GameGlobalObject() {}

		virtual void Update(float dt) {}
		virtual void Init() {}
		virtual void Release() {}
};

class DefaultObjectsStorage : public ObjectStorageBase<GameGlobalObject>
{
private:
	using ObjPtr = std::unique_ptr<GameGlobalObject>;
	std::vector<ObjPtr> m_dynamic_globals;

private:
	virtual GameGlobalObject* GetGlobalObjectImpl(size_t i_type_code) const override
	{    …	}
	virtual void AddGlobalObjectImpl(std::unique_ptr<GameGlobalObject> ip_object) override
	{    …	}
			virtual void RemoveGlobalObjectImpl(size_t i_type_code) override
	{    …	}

public:
	DefaultObjectsStorage() {}
	virtual std::vector<GameGlobalObject*> GetStoredObjects() override { return m_cache_objects; }
};

static std::unique_ptr<ObjectStorageBase<GameGlobalObject>> gp_storage(new DefaultObjectsStorage());

void Test()
{
	// делаем объект "глобальным"
	gp_storage->AddGlobalObject<ResourceManager>();
	// используем
	gp_storage->GetGlobalObject<ResourceManager>()->Initialize();
	// и удаляем
	gp_storage->RemoveGlobalObject<ResourceManager>();
}

Часто над глобальными объектами нужно проводить разные манипуляции во время создания или удаления. В наших проектах это чтение данных с диска (например, файла настроек для подсистемы), обновление данных игрока, которое происходит при загрузке приложения и через определённый интервал времени во время игры, и обновление внутриигрового цикла. У других программ могут быть дополнительные или совершенно иные действия. Поэтому конечный базовый тип будет определяться пользователем класса и позволит избежать множественного вызова одинаковых методов.

for (auto p_object : g_storage->GetStoredObjects())
p_object->Init();

Всё ли в итоге у нас хорошо?

Понятно, что производительность от подобной обёртки будет хуже, чем от использования глобального объекта напрямую. Для теста было создано десять различных типов. Сначала они использовались как глобальный объект без наших изменений, затем через DefaultObjectsStorage. Результат для 1 000 000 вызовов.

Глобальные объекты и места их обитания - 2

Текущий код работает медленнее обычного глобального объекта почти в 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 <typename BaseObject>
class ObjectStorageBase
{
	…
public:
	template <typename ObjectType>
	void AddGlobalObject()
	{
		auto p_object = std::make_unique<ObjectType>();
		p_object->RecalcHashCode();
		AddGlobalObjectImpl(std::move(p_object));
	}
	template <typename ObjectType>
	ObjectType* GetGlobalObject() const
	{
		static size_t type_hash = typeid(ObjectType).hash_code());
		return static_cast<ObjectType*>(GetGlobalObjectImpl(type_hash);
	}
	…	
};

class DefaultObjectsStorage : public ObjectStorageBase<GameGlobalObject>
{
	…
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 — это вполне приемлемо в большинстве случаев.

Глобальные объекты и места их обитания - 3

Кроме того, чтобы не менять целое хранилище для тестов, можно переопределять метод 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<FooTest>();
g_getter.GetGlobalObject<Foo>()->SetX();

Первопроходцем для внедрения этого подхода был Fishdom, где несколько объектов стали использоваться через данную обёртку. Это позволило убрать зависимости, покрыть часть кода тестами и сделать удобнее монотонную работу по вызову методов (Init, Release, Update) в нужных местах.

По ссылке можно найти финальный код оболочки и описанные тесты.

Автор: Playrix

Источник

Поделиться новостью

* - обязательные к заполнению поля