Глобальні об'єкти і місця їх проживання

Глобальні об'єкти набули широкого поширення із-за зручності їх використання. У них зберігають налаштування, ігрові сутності і взагалі будь-які дані, які можуть знадобитися де завгодно в коді. Передача ж функцію всіх потрібних аргументів може роздути список параметрів до дуже великого розміру. Крім зручності є і недоліки: порядок ініціалізації і руйнування, додаткові залежності, складність написання юніт-тестів. Багато програмісти упереджено вважають, що глобальні змінні використовують тільки новачки і це рівень студентських лабораторних. Однак у великих проектах, як CryEngine, UDK, OGRE, глобальні об'єкти також застосовуються. Різниця тільки в рівні володіння цим інструментом.



Отже, що ж за звір цей глобальний об'єкт, як його приручити і користуватися зручностями, звівши недоліки до мінімуму? Давайте розбиратися разом.

Існує маса способів створити глобальний об'єкт. Найпростіший — оголосити 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 викликів.


Поточний код працює повільніше звичайного глобального об'єкта майже у 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 — це цілком прийнятно в більшості випадків.


Крім того, щоб не міняти ціле сховище для тестів, можна перевизначити метод
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) в потрібних місцях.

посилання можна знайти фінальний код оболонки і описані тести.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.