Паттерн Singleton(Одиночка) гарантирует, что при его использовании будет создан только один экземпляр класса, и предоставляет глобальную точку доступа к методам этого класса.
Пример использования в классических ++:
class Singleton
{
public:
// Метод для доступа
static Singleton& GetInstance()
{
static Singleton instance;
return instance;
}
// Пример внутренний метода
void DoSomething()
{
std::cout << "Singleton is working!" << std::endl;
}
// Ограничение копирования и перемещения
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
Singleton(Singleton&&) = delete;
Singleton& operator=(Singleton&&) = delete;
private:
// Закрытые конструктор и деструктор
Singleton() { std::cout << "Singleton Constructor" << std::endl; }
~Singleton() { std::cout << "Singleton Destructor" << std::endl; }
};
Пример использования:
int main()
{
Singleton Instance = Singleton::GetInstance();
Instance.DoSomething();
return 0;
}
Синглтон в классическом исполнении вызывается с помощью функции доступа, в моем примере Singleton::GetInstance()
, и существует до конца жизни программы.
Если описывать словами то, что происходит – мы прячем конструкторы/деструкторы в private, что позволяет запретить прямое создание/уничтожение класса, создаем статик функцию, которая при первом вызове создает статик класс себя же и возвращает его при последующих вызовах, ограничиваем возможности копирования и перемещения класса.
Как результат, получаем единственный экземпляр класса, данные которого глобальны для вызова в любом месте программы.
int main() {
Singleton& singleton1 = Singleton::GetInstance(); // Первый вызов - создается объект
Singleton& singleton2 = Singleton::GetInstance(); // Второй вызов - возвращается тот же объект
singleton1.DoSomething();
singleton2.DoSomething();
if (&singleton1 == &singleton2) {
std::cout << "Both references point to the same object!" << std::endl;
}
return 0;
}
result:
Singleton Constructor
Singleton is working!
Singleton is working!
Both references point to the same object!
В играх синглтоны в подавляющем большинстве случаев будут использоваться как менеджеры каких-либо систем. Например:
Это лишь толика того, что может представлять синглтон, но общее представление, надеюсь, дает. Конечно, в играх, большинство синглтонов отходят от классического описания (по Банде Четырёх) отбрасывая определение времени жизни. Т.е. если в классическом варианте мы говорим, что синглтон существует с момента первого обращения к нему до завершения работы программы, то для игровых задач используются вариации, временем жизни которых мы управляем, например, от уровня к уровню.
В UE классические синглтоны, как можно было сделать вывод из прошлого раздела, нужно стараться избегать. Забегая вперед, для подобных задач рекомендуют использовать Subsystems или прочие механизмы. Но кто мне запретит реализовать принцип синглтона просто смотря на классическую реализацию? В общем, попробую собрать свой собственный велосипед, да еще и страшненький.
Задача, на мой взгляд, не очень полезная для широкой публики, но вполне подходящая для упрощения жизни с моими текущими рабочими задачами: Представим, что мы делаем небольшие игры, которые буквально состоят из одного уровня, и хотим всегда иметь прямой доступ к важным игровым системам уже приведенным к нужному типу. Например, быстро обращаться к игровым системам типа GameMode, GameInstance и прочим.
Одна из очевидных проблем, с которой сталкиваются при создании самописного синглтона для UE – время его жизни в PIE. Если мы создаем синглтон в PIE и не уничтожаем его, то он будет жить при перезапуске PIE. Чтобы избежать этого момента, нужно предусмотреть его с помощью FWorldDelegates::OnWorldCleanup
, забиндив функцию на очистку указателя на прошлый экземпляр. Но все это актуально лишь для реализации максимально приближенного к классическому синглтону.
Сигнатура для OnWorldCleanup
:
// World.h
DECLARE_MULTICAST_DELEGATE_ThreeParams(FWorldCleanupEvent, UWorld* /*World*/, bool /*bSessionEnded*/, bool /*bCleanupResources*/);
Перейдем, наконец, к реализации самого класса описанного задачей и реализуем вызов определенного GameMode.
UCLASS()
class PATTERNS_API UGameplayFrameworkManager : public UObject
{
GENERATED_BODY()
public:
/**
* Singleton Initializer and getter
* @param WorldContext UObject for getting needed world
* @return Pointer to Instance of class to operate with
*/
UFUNCTION(BlueprintCallable, Category = "Patterns | Singleton | LifeCycle")
static UGameplayFrameworkManager* GetGameplayFrameworkManager(UObject* WorldContext);
/**
* Manual Instance cleanup
*/
UFUNCTION(BlueprintCallable, Category = "Patterns | Singleton | LifeCycle")
static void ResetInstance();
/**
* Resetting current Instance to nullptr and calling Cleanup() for rese
*
* Signature using for FWorldCleanupEvent bind delegate
* DECLARE_MULTICAST_DELEGATE_ThreeParams(FWorldCleanupEvent, UWorld, bool bSessionEnded, bool bCleanupResources);
*
* Params not used if calling manually
*/
void ResetInstancePIE(UWorld* World, bool bCleanupResources, bool bSessionEnded);
/**
* @return Current GameMode pointer
*/
UFUNCTION(BlueprintCallable, Category = "Patterns | Singleton | Getters", BlueprintPure)
static APatternsGameMode* GetCurrentGameMode();
private:
UGameplayFrameworkManager();
/**
* Singleton instance
*/
static UGameplayFrameworkManager* Instance;
/**
* Pointers reset
*/
void Cleanup();
UPROPERTY()
APatternsGameMode* CurrentGameMode = nullptr;
};
#include "GameplayFrameworkManager.h"
#include "GameFramework/GameModeBase.h"
#include "Kismet/GameplayStatics.h"
#include "Patterns/GameplayFramework/PatternsGameMode.h"
UGameplayFrameworkManager* UGameplayFrameworkManager::Instance = nullptr;
UGameplayFrameworkManager::UGameplayFrameworkManager()
{
FWorldDelegates::OnWorldCleanup.AddUObject(this, &UGameplayFrameworkManager::ResetInstancePIE);
}
UGameplayFrameworkManager* UGameplayFrameworkManager::GetGameplayFrameworkManager(UObject* WorldContext)
{
if (!Instance)
{
UWorld* World = nullptr;
if (WorldContext)
{
World = WorldContext->GetWorld();
}
else
{
ResetInstance();
UE_LOG(LogTemp, Error,
TEXT(
"UGameplayFrameworkManager::GetGameplayFrameworkManager Failed to create instance. Invalid WorldContext."
));
return nullptr;
}
Instance = NewObject<UGameplayFrameworkManager>(World, UGameplayFrameworkManager::StaticClass());
if (!Instance)
{
ResetInstance();
UE_LOG(LogTemp, Error,
TEXT(
"UGameplayFrameworkManager::GetGameplayFrameworkManager Failed to Initialize Instance. Instance is not valid."
));
return nullptr;
}
}
return Instance;
}
void UGameplayFrameworkManager::ResetInstance()
{
if (Instance)
{
Instance->ResetInstancePIE(Instance->GetWorld(), true, true);
}
}
void UGameplayFrameworkManager::ResetInstancePIE(UWorld* World, bool bCleanupResources, bool bSessionEnded)
{
if (Instance)
{
Instance->Cleanup();
Instance = nullptr;
}
}
void UGameplayFrameworkManager::Cleanup()
{
if (CurrentGameMode)
{
CurrentGameMode = nullptr;
}
FWorldDelegates::OnWorldCleanup.RemoveAll(this);
}
APatternsGameMode* UGameplayFrameworkManager::GetCurrentGameMode()
{
if (Instance && Instance->CurrentGameMode)
{
return Instance->CurrentGameMode;
}
if (Instance)
{
UWorld* World = Instance->GetWorld();
if (!World)
{
UE_LOG(LogTemp, Error, TEXT("UGameplayFrameworkManager::GetCurrentGameMode World is not valid"));
return nullptr;
}
AGameModeBase* GameModeBase = UGameplayStatics::GetGameMode(World);
Instance->CurrentGameMode = Cast<APatternsGameMode>(GameModeBase);
if (Instance->CurrentGameMode)
{
return Instance->CurrentGameMode;
}
UE_LOG(LogTemp, Error,
TEXT("UGameplayFrameworkManager::GetCurrentGameMode Instance->CurrentGameMode is not valid"));
}
UE_LOG(LogTemp, Error, TEXT("UGameplayFrameworkManager::GetCurrentGameMode Instance is not valid"));
return nullptr;
}
Теперь мы можем вызвать наш синглтон из любого места нашего проекта.
Реализация cpp:
#include ".../GameplayFrameworkManager.h"
//...
void MyClass::foo(){
UGameplayFrameworkManager* Instance = UGameplayFrameworkManager::GetGameplayFrameworkManager(this);
Instance->GetCurrentGameMode();
}
Реализация bp:
Очевидно при такой такой реализации мы много внимания уделяем управлению временем жизни и определением контекста объекта, что может привести к ошибкам при расширении системы. Далее я разберу один из рекомендуемых способов реализации синглтон систем.
Подсистемы являются рекомендованной альтернативой для реализации синглтонов в UE. Хотя подсистемы нельзя назвать классическим синглтоном, их можно смело считать ближайшей реализацией этого паттерна, адаптированной под особенности игровой разработки. Перед дальнейшим разбором подсистем давайте отметим их основные отличия от синлглтонов:
GetInstance()
).FWorldDelegates::OnWorldCleanup
для очистки при выгрузке уровня.Initialize()
и Deinitialize()
, которые вызываются движком в нужные моменты. Это исключает необходимость ручной очистки и минимизирует риск утечек памяти.UWorld
или UGameInstance
). Это обеспечивает четкое разделение систем по их области действия и предотвращает конфликты между состояниями разных уровней или сессий.Этот список различий явно демонстрирует преимущества подсистемы как нативной системы UE над классическим исполнением синглтона. С таким подходом мы сохраняем идею синглтона, но модифицируем ее под требования игры.
Но, все же есть оговорки. В сравнениях выше есть исключение – UDynamicSubsystem
. Разберем основную группу объектов наследуемую от USubsystem
На момент UE 5.5.х существует 4 основных объекта наследованных от USubsystem:
UWorldSubsystem
UWorld
, который представляет игровой мир или уровень.UWorld
) и уничтожается при выгрузке этого уровня.UGameInstanceSubsystem
UGameInstance
, который существует на протяжении всей игровой сессии.UGameInstance
) и уничтожается при завершении сессии.ULocalPlayerSubsystem
ULocalPlayer
, который представляет локального игрока.UDynamicSubsystem
UWorld
, UGameInstance
или ULocalPlayer
.Как мы видим из беглого описания каждой системы, UDynamicSubsystem
ближе всего классическому синглтону, но все же имеет архитектурное ограничение – использование UDynamicSubsystem
допускается только для создания подсистем в контексте движка или редактора. С другой стороны, остальные 3 типа подсистем должны покрывать большинство кейсов, в которых нам может быть полезен синглтон.
На сколько я знаю, в использовании подсистем есть некоторые подводные камни, которые стоит учитывать прежде, чем начать их клепать одну за другой. Для этого давайте разберемся, как они работают.
Когда мы создаем класс подсистемы, например class USingletonWorldSubsystem : public UWorldSubsystem
, мы “регистрируем” подсистему в общем списке подсистем.
При компиляции проекта UHT (Unreal Header Tool) извлекает необходимую информацию и записывает новую систему в общий список систем. Фактически – определяет контекст подсистемы.
Экземпляр подсистемы создается всегда в своем контексте, вне зависимости от того используем ли мы в коде или нет. Например, создав систему USingletonWorldSubsystem
и не используя ее в ++/bp, она все равно будет инициализирована и занимать свою область в памяти при загрузке уровня.
В этом можно убедиться просто создав логи для конструктора и деструктора класса. Или вызвав команду obj list=SingletonWorldSubsystem
. Не пугайтесь, если заметите 2 экземпляра объекта, это связано с особенностью PIE, т.к. он создает два отдельных мировых контекста.
Как итог, представление о том, как создаются подсистемы в движке, как бы намекает нам, что с ними нужно работать аккуратно, чтобы не создать тяжелую подсистему, которая всегда будет использовать большой пласт памяти и не будет применяться. Вот, наверное, самый большой подводный камень который я нашел. Что ж, давайте перейдем к реализации.
Для приведенного ниже примера, в моем случае, система использует 0.06кб памяти на экземпляр, соответственно 0.12кб на два при использовании в редакторе. Я также оставляю логи, чтобы было очевидно что система инициализируется/деинициализируется при запуске/завершении PIE.
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/WorldSubsystem.h"
#include "SingletonWorldSubsystem.generated.h"
class APatternsGameMode;
UCLASS()
class PATTERNS_API USingletonWorldSubsystem : public UWorldSubsystem
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, Category = "Patterns | Subsystem | Getters", BlueprintPure)
APatternsGameMode* GetCurrentGameMode();
private:
UPROPERTY()
APatternsGameMode* CurrentGameMode;
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
};
#include "SingletonWorldSubsystem.h"
#include "Kismet/GameplayStatics.h"
#include "GameFramework/GameModeBase.h"
#include "Patterns/GameplayFramework/PatternsGameMode.h"
void USingletonWorldSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
UE_LOG(LogTemp, Warning, TEXT("USingletonWorldSubsystem created."));
}
void USingletonWorldSubsystem::Deinitialize()
{
CurrentGameMode = nullptr;
UE_LOG(LogTemp, Warning, TEXT("USingletonWorldSubsystem deleted."));
Super::Deinitialize();
}
APatternsGameMode* USingletonWorldSubsystem::GetCurrentGameMode()
{
if (!CurrentGameMode)
{
UWorld* World = GetWorld();
if (!World)
{
UE_LOG(LogTemp, Error, TEXT("USingletonWorldSubsystem::GetCurrentGameMode - World is not valid"));
return nullptr;
}
AGameModeBase* GameModeBase = UGameplayStatics::GetGameMode(World);
CurrentGameMode = Cast<APatternsGameMode>(GameModeBase);
if (!CurrentGameMode)
{
UE_LOG(LogTemp, Error,
TEXT("USingletonWorldSubsystem::GetCurrentGameMode - CurrentGameMode is not valid"));
}
}
return CurrentGameMode;
}
Реализация cpp:
#include ".../SingletonWorldSubsystem.h"
//...
void MyClass::foo(){
if (USingletonWorldSubsystem* SingletonSubsystem = GetWorld()->GetSubsystem<USingletonWorldSubsystem>())
{
if (APatternsGameMode* PatternsGameMode = SingletonSubsystem->GetCurrentGameMode())
{
UE_LOG(LogTemp, Warning, TEXT("AcppOpener::BeginPlay PatternsGameMode ok"));
// PatternsGameMode->DoSomething();
}
}
}
Реализация bp:
Как видите, реализация подсистемы намного нативнее к движку и проще. Но, не стоит забывать что к созданию подсистем стоит подходить с умом, впрочем, как и ко всему остальному.
Я не углублялся во многие проблемы синглтона, в популярные вариации и прочие нюансы, в том числе подсистем, но надеюсь дал примерное представление о том что такое синглтон на концептуальном уровне, где можно его использовать и как с ним лучше работать в контексте UE. На этом мое небольшое изложение подходит к концу. Буду рад любым дополнениям и правкам по технической части, пишите в телеграм!
Доп материалы:
Ozon – Паттерны объектно-ориентированного проектирования (Банда Четырёх)
en.Wiki Singleton
Официальная документация Unreal Engine о подсистемах
Код из примеров