📜 ⬆️ ⬇️

Global objects and their habitats

Global objects are widely used because of their ease of use. They store settings, game entities and in general any data that may be needed anywhere in the code. Passing to the function all the necessary arguments can inflate the list of parameters to a very large size. In addition to convenience, there are also disadvantages: the order of initialization and destruction, additional dependencies, the complexity of writing unit tests. Many biased programmers believe that global variables are used only by beginners and this is the level of student labs. However, in large projects like CryEngine, UDK, OGRE, global objects also apply. The only difference is in the level of ownership of this tool.



So, what kind of beast is this global object, how to tame it and use the facilities, minimizing the disadvantages? Let's figure it out together.

There are many ways to create a global object. The easiest way is to declare an extern variable in the header file and create its instance in cpp:
')
// header file extern Foo g_foo; // cpp file Foo g_foo; 

A more abstract approach is the singleton pattern .

 void PrepareFoo(...) { FooManager::getInstance().Initialize (); } 

What is good about this decision, that he gets so much attention? It allows you to use the object anywhere in the program. Very convenient, and the temptation to do so is very great. Problems begin when it is necessary to replace a part of the system without disrupting the work of the rest, or to test the code. In the latter case, we will have to initialize almost all global variables that are used by the method we are interested in. Moreover, the difficulties listed above make it very difficult to replace the behavior of an object with a desired one for tests. There is also no control over the order of creation and deletion, which can lead to undefined behavior or program crashes. For example, when referring to a not yet created or already deleted global object.

In general, it is preferable to use local variables instead of global ones. For example, if you need to draw a certain object and there is a global Renderer, then it is better to transfer it directly to the void Draw(Renderer& render_instance) method void Draw(Renderer& render_instance) , rather than using the global Render::Instance (). More examples and rationales why you should not use singleton can be read in the post .

However, it is difficult to do without global objects. If you need access to settings or prototypes, then you will not attach all the necessary containers, factories and other parameters to each object. This case we will consider.

To begin with, setting the task:

  1. The object must be accessible from any part of the program.
  2. All recyclable global objects must be stored centrally - for ease of support.
  3. The ability to add and / or replace global objects, depending on the context, is a real launch or test.

To consider the implementation successful, it is important to fulfill all the indicated conditions.

An interesting solution was peeped in the depths of CryEngine (see SSystemGlobalEnvironment ). Global objects are wrapped in one structure and are pointers to abstract entities that are initialized at the right time in the right place in the program. No extra overhead, no extra add-ons, type control during compilation - beauty!

CryEngine is a rather old project that has been grinded over the years, where all the interfaces have settled down, and the new one is screwed similar to what exists at the moment. Therefore, there is no need to invent additional wrappers or ways of working with global objects. There is another option - a young and rapidly developing project, where there are no strict interfaces, where the functionality is constantly changing, which makes it necessary to make changes to the interfaces quite often. I want to have a solution that will help in old projects to refactor, and in new ones, where global access is still needed, minimize the disadvantages of using. To search for an answer, you can try to go up a level and look at the problem from a different angle — create a repository of global objects inherited from GlobalObjectBase . Using the shell will add operations at runtime, so be sure to pay attention to performance after changes.

First you need to create a base class, the heirs of which can be placed in the storage object.

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

Now the storage itself. To access from any part of the program, an object of this class must be made global using one of the standard methods, which you will like more.

Storage class
 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()); } }; 

To work with this kind of objects, their type is sufficient, so the GlobalObjectsStorage interface GlobalObjectsStorage methods that transfer the necessary implementation data.

So, the first test drive works!

 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>(); } 

But that's not all - you cannot substitute objects for different contexts. We correct by adding a parent class for the repository, transferring the template methods there, and making the virtual implementation methods.

Base storage class
 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>(); } 

Often, global objects need different manipulations during creation or deletion. In our projects, this reads data from the disk (for example, the settings file for the subsystem), updates the player data that occurs when the application is loaded and after a certain time interval during the game, and updating the in-game cycle. Other programs may have additional or completely different actions. Therefore, the final base type will be determined by the class user and will allow to avoid multiple invocation of the same methods.

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

Is everything good in the end?


It is clear that the performance of such a wrapper will be worse than using a global object directly. For the test, ten different types were created. First, they were used as a global object without our changes, then through DefaultObjectsStorage . Result for 1,000,000 calls.


The current code runs almost 18 times slower than a regular global object! Profiler suggests that typeid(*obj).hash_code() takes the most time. Since the extraction of data about the types during execution spends a lot of CPU time, you need to get around it. The easiest way to do this is to store a type hash in the base global object class ( 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(); } }; 

It is also worth changing the ObjectStorageBase::AddGlobalObject DefaultObjectsStorage:: GetGlobalObjectImpl . Additionally, we statically store type data in the template function of the parent class ObjectStorageBase::GetGlobalObject .

Storage Optimization
 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(); } … }; 

The above changes can significantly reduce the search time of the desired object, and the difference will no longer be 18 times, and 1.25 - this is quite acceptable in most cases.


In addition, in order not to change the whole repository for tests, you can override the GlobalObjectBase::RecalcHashCode and selectively replace only the necessary objects. To replace the main class, it is necessary to make the methods necessary for the test and the test class inheritor virtual.

Replacement example
 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 was a pioneer in introducing this approach, where several objects were used through this wrapper. This made it possible to remove dependencies, cover part of the code with tests, and make more convenient the monotonous work of calling methods (Init, Release, Update) in the right places.

Under the link you can find the final shell code and the tests described.

Source: https://habr.com/ru/post/316416/


All Articles