company_banner

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

    Глобальные объекты получили широкое распространение из-за удобства их использования. В них хранят настройки, игровые сущности и вообще любые данные, которые могут понадобиться где угодно в коде. Передача же в функцию всех нужных аргументов может раздуть список параметров до очень большого размера. Помимо удобства есть и недостатки: порядок инициализации и разрушения, дополнительные зависимости, сложность написания юнит-тестов. Многие программисты предвзято считают, что глобальные переменные используют только новички и это уровень студенческих лабораторных. Однако в больших проектах, как 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) в нужных местах.

    По ссылке можно найти финальный код оболочки и описанные тесты.
    Метки:
    Playrix 104,09
    Разработчик мобильных free-to-play игр
    Поделиться публикацией
    Комментарии 18
    • +2
      Вектор. С линейным поиском по хэшу. Но вектор. Тут что-то явно не логично.
      То есть, пока объектов мало, и все влезает в кэш (процессора) — все будет хорошо, и даже быстрее std::map какого-нибудь. А вот если объектов будут сотни тысяч, это все повылезает и будет смотреть на вас с немым укором.

      Без иронии — вы гонитесь за скоростью, но используете линейный поиск. В таком случае, стоило бы пояснить, почему вы не используете структуру с логарифмическим временем доступа.
      • +3
        Предположительно, в проекте не будет больше 30-40 глобальных переменных. В том же CryEngine насчитал в структуре около 45. С этим расчётом разрабатывалось и тестировалось. В случае, если количество глобальных объектов переваливает за сотню, то, возможно, с архитектурой проекта не всё хорошо.
        В любом случае замечание дельное и в ближайшее время постараюсь потестировать с большим количеством объектов и разными контейнерами. Благодарю за отзыв.
      • +1

        После небольшой доработки получится реализация паттерна Dependency Injection Container (контейнер внедрения/инъекции зависимостей, IoC container).


        Конкретно, отличия предложенной реализации от "классической":


        • Ключами в контейнере являются интерфейсы:


          dic.get<IInAppPurchases>()
          
          dic.get<IGameConfig>()

        • Регистрируются "фабрики" объектов, вместо экземпляров. Это позволяет конструкторам одних объектов получать доступ к методам других, не беспокоясь о порядке инициализации (пока у нас нет циклических зависимостей):


          div.registerInstance<IInAppPurchases>([](DIC& dic){ 
            return new AppStoreInAppPurchases(dic.get<IConfiguration>().getPurchasesList(),
                 dic.get<IConfiguration>().get("appstore.appid")); 
          });
          
          div.registerInstance<IGameConfig>([](){ 
            return new XMLConfiguration("gameconfig.xml"); 
          });

        • В соответствии с предыдущим пунктом, экземпляры создаются лениво.


        • Также предложенная реализация предоставляет не свойственный контейнеру зависимостей функционал получения списка объектов.

        Небольшое замечание по реализации: type_info::hash_code не обязана возвращать разные значения для разных типов. Вероятность столкнуться с этой проблемой невелика, пока число объектов мало, но парадокс дней рождения не дремлет. В С++11 есть std::type_index специально для целей поиска по типу в контейнерах.

        • +1
          Вы пытаетесь один паттерн подменить другим.
          Зачем в AppStoreInAppPurchases передавать и хранить список покупок и ид приложения, если у нас есть глобальный объект, который уже содержит эти данные.
          В контексте оптимизации: могут быть тысячи объектов, требующих какой-либо сервис. Промахи кэша в высоконагруженом приложении могут стать критичными. Приведенное решение является компромиссом между медленными независимыми компонентами и быстрыми связанными.
        • 0
          то есть вы придумали пул объектов?
          • 0
            Насколько я понимаю, пул объектов предназначен для того, чтобы во время использования получить готовый объект, а не создавать его. В пуле может быть множество объектов одинакового типа. Также он рассчитывается на достаточно большое количество объектов. Расчёт при разработке был в другом — небольшое количество объектов, которые будут храниться в единственном экземпляре. Здесь есть что-то и от пула объектов, и, как уже писали, от паттерна Dependency Injection.
            • 0
              не совсем. В одном месте объект инициализируется и помещается в пул, в другом — запрашивается и используется. Можно запросить объект и запомнить по месту использования — тогда время доступа к объекту в пуле будет влиять только на время инициализации приложения. Можно забирать объекты и деинициализировать вручную. То есть в целом этот паттерн решает как раз таки ваши проблемы.
              • 0
                А в случае, если мест использования несколько? Кто тогда отвечает за то, что это место будет одно? Что помешает в пул добавить несколько одинаковых объектов? Ведь данный паттерн будет решать данные вопросы только в случае доработки и введения данных ограничений.
                • 0
                  А в случае, если мест использования несколько?

                  а в чем проблема запросить объект из нескольких мест? Сам-то пул является синглтоном
                  Кто тогда отвечает за то, что это место будет одно?

                  а оно не обязано быть одно. Запрашиваете объект из пула сколько угодно раз. Хотите эксклюзивный доступ? Забираете (запрашиваете + удаляете) объект из пула и используете сами.
                  Что помешает в пул добавить несколько одинаковых объектов?

                  Один и тот же объект в пул несколько раз не добавишь, по кр. мере в самой логичной версии реализации. Несколько объектов одного типа? — запрашиваем у пула полный список этих объектов и выбираем нужный. Так, например, реализована система плагинов в QtCreator'е.
                  • 0
                    Хотите эксклюзивный доступ? Забираете (запрашиваете + удаляете) объект из пула и используете сами.

                    То есть пул не отвечает за уникальность объекта и нужно самому потом следить за этим. Тут и есть проблема. Задача же стояла в том, чтобы заменить именно глобальные объекты, а не все подряд. Чтобы у программиста при использовании не было мыслей про то, что и где он должен проинициализировать или сохранить. В вашем варианте, как минимум, нужно ещё одно место, откуда будет забираться и удаляться объект из пула, а потом доступ к объекту нужно будет совершать через эту сохранённую переменную. В итоге, мы приходим всё к тем же минусам глобальных объектов, что и при использовании оных через extern переменные или синглтоны, но ещё через одну обёртку.
                    • 0
                      я не пытаюсь доказать, что пул объектов лучше вашего решения. Я пытаюсь доказать, что они практически одно и то же
                      • 0
                        Соглашусь в том, что они очень похожи. Если обобщить, то обёртка это доработанный пул объектов с некоторыми ограничениями.
          • 0
            1. Кто отвечает за инициализацию глобальных объектов? В вашем примере, насколько, я понимаю, этим занимается функция Test().
            2. У вас не рассматривается случай взаимозависимости глобальных объектов и возникающие при этом проблемы с созданием и, особенно, удалением глобальных объектов.
            • 0
              1. За инициализацию объектов отвечает программист. Но так как у вас есть контроль за порядком создания объектов, то это облегчает весь процесс.
              2. Если есть объекты, которые зависят друг от друга, то и удалять и регистрировать и удалять их можно в соответствующем порядке. Опять же, в данной обёртке у вас есть контроль за порядком создания и удаления, поэтому избежать обращения одного глобального объекта к другому, когда последний уже удалился, проще.
              • +1
                Если глобальные объекты используют друг друга, значит программист ответственен за то, чтобы они создавались и удалялись в правильном порядке. Более того, правильного порядка удаления может и не существовать, так как при любом порядке уже удаленный глобальный объект может снова понадобиться, и тогда его придется пересоздавать.

                В общем, ваш подход хорош для глобальных объектов, которые сами никого не используют, а все используют их. Например, для таблиц свойств, генератора случайных чисел и прочего. Mock-объекты создавать с таким подходом, действительно, удобно.
                • +1
                  Не совсем. Допустим, у нас есть GameInfo, который содержит информацию об игровых объектах. И есть система AI, которой нужны игровые сущности. В случае глобальных объектов нету уверенности, что сначала создастся GameInfo и AI не будет при создании использовать не инициализированный указатель. В случае с обёрткой вы можете написать
                  	g_storage.AddGlobalObject<GameInfo>();
                  	g_storage.AddGlobalObject<AI>();
                  

                  И уже определённо сначала создастся GameInfo, а потом AI. При удалении будет обратный порядок
                  	g_storage.RemoveGlobalObject<AI>();
                  	g_storage.RemoveGlobalObject<GameInfo>();
                  

                  При таком создании-удалении у AI время жизни меньше, чем у GameInfo, и возможно использование одного глобального объекта другим без боязни получить nullptr.
                  • +1
                    Просто глобальные переменные с неопределенным порядком инициализации, разумеется, хуже любого организованного подхода. Но у организованного подхода тоже есть свои проблемы. Например, у вашего — это код, выполняющий g_storage.AddGlobalObject<> / g_storage.RemoveGlobalObject<>.
                    В нем легко допустить ошибку, особенно учитывая, что что для каждого набора Mock-объектов необходимо создать и поддерживать отдельную функцию, заполняющую g_storage.
            • +1
              Согласен, в коде всё так же можно допустить ошибку. По поводу Mock-объектов, то для них возможна замена проще, чем переписывать функцию, заполняющую g_storege. Допустим, есть набор глобальных объектов, которые необходимы для реального запуска. Этот же набор дублируется для всех тестов. Данные объекты-дубликаты будут с пустыми реализациями виртуальных методов, чтобы можно было их создать. Есть объект AI, который необходимо заменить на конкретизированный для тестов.
              g_storage.RemoveGlobalObject<AI>();
              g_storage.AddGlobalObject<AISpecific>();
              

              AISpecific будет выдавать тип AI в методе RecalcHashCode. Поэтому всё хранилище заменять не надо будет. Естественно, остаются сложности с поддержкой двух функций-регистраторов и подобной заменой объектов. Однако в случае с глобальными переменными это уже лучше, чем ничего.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое