내 코드에서는 Event 를 관리하는 클래스를 Event System 이라고 이름은 붙였지만 Event Manager 와 같이 다양하게 불릴 뿐 역할은 모두가 동일하다고 본다. 이 포스트를 작성하기 전까지 C# 에도 Event 가 있는 줄 몰랐는데 포스트 작성을 위해 Event 관련 자료를 찾아보니 C# 의 기능까지 확인하게 되었다. 내가 필요한 기능만 적당히 구현해놓은 Event System 과는 달리 좀더 세분화되고 사용하기 좋게 되어있던 것 같아서 내가 만든 Event System 을 개선할때 참고하려고 한다. (참고로 Microsoft C++ 에도 __event 라는 키워드가 존재하는데 아직 공부한 영역이 아니라서 자세히 보진 않았지만 비슷한 기능을 하는 듯 하다.)
처음 구현해본 Event System 로써 아직 보완할 점이 상당히 많이 보인다. 멀티스레드로도 사용할 수 있을 것 같고, 사용 편의성을 더욱 늘릴 필요가 있을 것 같으며(C# 스타일의 이벤트 처럼) 외부에서 이벤트를 등록/제거 하기 쉽게 만들 필요가 있는 것 같다(현재는 자신의 클래스에 국한하여 이벤트를 처리하는 방식). 여유가 있다면 이 형태에서 점점 개선하여 사용할 예정이다.
내가 받아들인 Event 는 핵심만 말하자면 특정한 Event 가 발생했을 때, 그 Event 를 구독한 객체가 그에 맞는 행동을 하는 것이다. 여기서 Event 는 개별적으로 구분이 가능한 무엇이든 상관이 없다. 내 엔진의 특성상 wstring을 사용할 일이 많아서 wstring_view 객체로 사용하였으나 int , string 등 고유한 값을 가지고 있는 것이면 무엇이든 가능하다 (사실 고유하지 않아도 되지만 그러면 의도치 않은 일이 벌어질 수 있을테니).
그렇다면 'Event 를 왜 쓰는걸까?' 라는 의문이 생길 수 있다. 내가 받아들인 Event 의 가장 큰 장점은, 클래스간의 결합도를 낮추는 것이라고 생각한다.
단적인 예를 들어보겠다. 똑같은 방식으로 생성된 몬스터 100 마리가 있을 때, 그 중 68 번째로 생성된 몬스터에게 특별한 의미를 부여하려고 한다. 그 몬스터가 사망했을 때 플레이어에게 500 골드를 주는 것이다. 가장 직관적으로 구현한다고 하면 68번째 생성된 몬스터를 그것을 관리하는 시스템 차원의 클래스에서 직접 들고 있다가, 그 몬스터가 죽었는지 계속 확인하면서 죽는다면 우리가 원하는 행위를 수행하는 것이다. 글로는 쉽게 설명했지만 직접 구현하다보면 이런 생각이 들며 머리가 아파오기 시작할 것 같다. '굳이 이 한마리 때문에 이걸 멤버 변수로 들고 있어야 한다고?' 게다가 필드에 몬스터가 이 한 종류 뿐이라는 보장도 없다. 몬스터의 종류는 수도 없이 많을텐데 그리고 발생하는 행위가 모두 같다는 보장도 없기 때문에 개별적인 모든 것을 위해서 멤버변수를 하나하나 들고 있자니 벌써 하기가 싫어진다.
이런 문제를 Event 로 쉽게 해결이 가능하다. 객체가 Event 를 받았을 때, 해야할 일을 객체에게 등록 해주면 되기 때문이다. 위의 예를 보자면, 68 번째 몬스터에게 "사망시 플레이어에게 500골드 지급" 이라는 함수를 구현해서 넣어주는 것이다. 등록 이라는 표현을 사용했는데, 이에 대한 구현 방식은 정말 개인의 자유인 것 같다. 본론으로 돌아가서, 위와 같이 Event 를 사용한다면 관리자 클래스에서 몬스터 객체를 개별적으로 알 필요가 없게 되어 클래스간 결합도가 떨어지는 결과를 얻을 수 있다. 당연히 플레이어에게 500골드를 주는 행위 조차도 Event 로 처리한다면 몬스터가 플레이어도 알 필요가 없기에 같은 맥락으로 볼 수 있다.
- 특정
EventID를 받았을 때 반응하고 싶은 클래스가EventHandler클래스를 다중 상속을 받은 후, 자신의 멤버 함수를EventFunction시그니처std::function<void(std::any)>에 맞춰서 특정EventID에 반응하는 함수를EventHandler::MakeListenerInfo함수의 인자로 넣는다.EventID를 발행하고 싶은 객체가EventSystem의 싱글톤 객체에 접근하여EventSystem::PublishEvent을 사용한다.EventSystem::m_CustomEvents에 (1) 에서 발행된Event객체가 push_back 되고, 해당 프레임의 마지막(실행 주기 상EventSystem이 가장 마지막에 위치함)에EventSystem::Update -> EventSystem::ProcessEvent을 통해Event객체에 대한 처리를 한다. 기본적으로m_CustomEvents컨테이너는 선입선출의 형태(Queue)를 가지기 위해 먼저 들어온Event객체부터 처리하지만, 지연 시간이 존재하는Event객체는 해당 시간이 만료될 때 까지 나중에 들어온Event객체보다 늦게 처리된다.- 객체의 실행 조건(지연 시간 만료)이 만족하면
EventSystem::ProcessEvent에는 내부적으로EventSystem::DispatchEvent가 실행되는데, 이 함수가 실제로EventFunction을 실행하는 부분이다.EventSystem::DispatchEvent에서EventFunction이 실행되면, (0) 에서 등록한 함수가 실행된다. (실제 코드는 사용 예시 참조)
#pragma once
#include <functional>
#include <queue>
#include <map>
#include <string_view>
#include <any>
namespace McCol
{
class EventHandler;
struct Event
{
std::wstring_view EventID; // 이벤트명
std::any Parameter; // 매개변수(필요시 적절한 캐스팅으로 사용)
float DelayedTime; // 지연 시간(기본값 0)
Event(std::wstring_view id, std::any param, const float& delayed)
: EventID(id), Parameter(param), DelayedTime(delayed) {}
};
struct ListenerInfo
{
const EventHandler* Listener = nullptr; // 이벤트를 받을 객체(dangling 포인터를 방지하기 위한 객체 검사용, 실제 사용 X)
std::function<void(std::any)> EventFunction; // 이벤트를 받을 시 수행할 함수
ListenerInfo(const EventHandler* listener, const std::function<void(std::any)>& func)
: Listener(listener), EventFunction(func) {}
};
class EventSystem
{
private:
EventSystem();
~EventSystem() = default;
private:
static EventSystem* m_Instance;
std::vector<Event> m_CustomEvents; // 사용자 정의 이벤트
std::multimap<std::wstring_view, ListenerInfo> m_Listeners; // 이벤트 구독자 정보 컨테이너
public:
static EventSystem* GetInstance()
{
if (m_Instance == nullptr)
m_Instance = new EventSystem;
return m_Instance;
}
void Initialize();
void Update(const float& deltaTime);
void Finalize();
public:
// 이벤트 발행
void PublishEvent(std::wstring_view evtID, const std::any& param = nullptr, const float& delayed = 0.0f);
void Subscribe(std::wstring_view evtID, const ListenerInfo& listenerInfo); // 이벤트 구독
void Unsubscribe(std::wstring_view evtID, const EventHandler* listener); // 해당 이벤트 구독 해지
void RemoveListener(const EventHandler* listener); // 해당 리스너가 구독한 모든 구독 취소
void RemoveListenersAtEvent(std::wstring_view evtID); // 해당 이벤트 모든 구독자 제거
void RemoveAllEvents(); // 모든 이벤트 제거
void RemoveAllSubscribes(); // 모든 구독 취소
private: // 내부 함수
void ProcessEvent(const float& deltaTime); // 이벤트 큐 확인
void DispatchEvent(const Event& evt); // 이벤트 처리
bool CheckSubscribe(std::wstring_view evtID, const EventHandler* listener); // 이벤트 중복 구독 확인
};
}
Event
EventSystem안에서만 사용되는 구조체로, 구성요소는 식별하기 위한EventID값,Event를 발행하는 객체가 발행할 때 해당Event를 받는 객체에서 필요로 하는 인자를 전달하기 위한Parameter, 원하는 시간 뒤에 수행하기 위한DelayedTime이다.- 세가지 멤버 변수 모두
Event객체를 이루는 필수적인 요소이기 때문에 생성자로 모두 받고 있다.
ListenerInfo
- 마찬가지로
EventSystem안에서만 사용되는 구조체. 하지만 생성은Event를 구독하는 객체가 직접하고EventSystem이 그걸 넘겨받는 형식이다. 구성요소는Event를 받을 객체의 주소값Listener, 특정Event를 받을 시 수행하는 함수EventFunction(문법 참조 : std::function) 이 있다.Listener는 함수를 수행하는데 사실 필요가 없지만,dangling pointer를 방지하기 위해 저장한다. 내가 찾아본 바에 의하면std::function에는 함수 주인의 정보를 알 수 없었다. 따라서 주인의 주소값Listener을 통해std::function과 생명 주기를 동일시 하기 위해 사용한다. 즉 주인이 사라지면 연결된std::function도 함께 삭제하기 위함이다. 실제로 사용되는 방식은EventHandler.h의 소멸자를 확인하길 바란다.EventFunction의 타입으로std::function을 사용한 이유는 함수를 하나의 객체처럼 다루기 위해서이다.EventSystem에서 함수를 관리 할 때 함수에 수행 명령을 내리는 것은EventSystem이기 때문에 하나의 객체처럼 저장할 수 있어야 하는데 이 방법을 위한 적절한 C++ 문법이std::function이었다.- 형태가
std::function<void(std::any)>인 이유 : 내EventSystem특성상 해당EventID를 Subscribe(구독) 한 객체가 설정한 함수EventFunction이 수행되었을 때, 객체는 그EventID를 알 필요가 없다. 이미 등록한 함수EventFunction이 수행되었다는 것 부터가 그 함수에 대응하는EventID가 Publish(발행) 되었다는 뜻이기 때문이다. (Subscribe, Publish 에 대한 설명은 EventSystem.cpp 에서 추가 후술) 그렇다면 인자가 굳이std::any일 필요가 있나 싶겠지만EventFunction중 특정 함수는 매개변수를 반드시 필요로 하는 함수가 있을 수 있기 때문에 그 매개변수를std::any로 지정하여 어떤 타입이든 올 수 있게 한 것이다. 하지만 인자로 들어 올 수 있는 것은 하나 뿐이기에 인자로 넘겨야 할 것이 여러개라면 구조체화 해서 넘겨야 한다는 단점이 있다. 반환형이void인 이유는 단순하다. 내가 생각하기에EventFunction이 무언가를 반환할 이유가 없었고 함수 수행은 해당 객체가 하지만 수행 명령을 내리는 객체는EventSystem이기 때문이다.
EventSystem Member Variable
m_Instance:Event를 Subscribe 하거나 Publish 하는 행위는 프로그램 전체에서 광범위하게 일어나기 때문에 사용 편의성을 위해서 Singleton Pattern 을 사용하였다.m_CustonEvents: 사용자가 정의한Event가 저장되는 컨테이너.Event는 ProcessEvent 라는 내부 함수가 수행될 때 마다 모두 비워지는게 맞기 때문에 처음에는std::vector가 아닌std::queue였으나 지연 처리DelayedTime기능을 추가하게 되면서 특정Event만 처리되어야 했고, pop 방식을 사용할 수 없게 되었기 때문에 변경하게 되었다.m_Listeners: 구독자(Subscribe 한 객체들) 정보를 담는 컨테이너(std::multimap). 구독자 정보이기 때문에multimap<std::wstring_view, ListenerInfo>타입을 갖고 있는데 여기서wstring_view는Event::EventID이다.- 각 멤버 함수에 대한 개별적인 설명은 cpp 에서 후술.
#include "pch.h"
#include "EventSystem.h"
McCol::EventSystem* McCol::EventSystem::m_Instance = nullptr;
McCol::EventSystem::EventSystem()
{
}
void McCol::EventSystem::Initialize()
{
}
void McCol::EventSystem::Update(const float& deltaTime)
{
ProcessEvent(deltaTime);
}
void McCol::EventSystem::Finalize()
{
RemoveAllEvents();
RemoveAllSubscribes();
SAFE_DELETE(m_Instance)
}
void McCol::EventSystem::PublishEvent(std::wstring_view evtID, const std::any& param, const float& delayed)
{
m_CustomEvents.push_back({evtID, param, delayed});
}
void McCol::EventSystem::Subscribe(std::wstring_view evtID, const ListenerInfo& listenerInfo)
{
// 유효성 및 중복 검사
if(listenerInfo.Listener == nullptr || CheckSubscribe(evtID, listenerInfo.Listener))
{
return;
}
m_Listeners.emplace(evtID, listenerInfo);
}
void McCol::EventSystem::Unsubscribe(std::wstring_view evtID, const EventHandler* listener)
{
auto [first, last] = m_Listeners.equal_range(evtID);
for(auto& it = first; it != last;)
{
if(it->second.Listener == listener)
{
it = m_Listeners.erase(it);
}
else
{
++it;
}
}
}
void McCol::EventSystem::RemoveListener(const EventHandler* listener)
{
// info : EventHandler 를 상속받은 객체가 소멸될 경우 자동으로 호출되는 함수
// info : listener 의 구독목록을 받아온다면 성능상 개선의 여지가 있음, 현재는 O(N)
for(auto it = m_Listeners.begin(); it != m_Listeners.end();)
{
if(it->second.Listener == listener)
{
it = m_Listeners.erase(it);
}
else
{
++it;
}
}
}
void McCol::EventSystem::RemoveListenersAtEvent(std::wstring_view evtID)
{
auto [first, last] = m_Listeners.equal_range(evtID);
for (auto& it = first; it != last;)
{
it = m_Listeners.erase(it);
}
}
void McCol::EventSystem::RemoveAllEvents()
{
while(!m_CustomEvents.empty())
{
m_CustomEvents.clear();
}
}
void McCol::EventSystem::RemoveAllSubscribes()
{
m_Listeners.clear();
}
void McCol::EventSystem::ProcessEvent(const float& deltaTime)
{
std::vector<McCol::Event> tempEvents = m_CustomEvents;
m_CustomEvents.clear();
for (auto& evt : tempEvents)
{
evt.DelayedTime -= deltaTime;
if (evt.DelayedTime <= 0)
{
DispatchEvent(evt);
}
else
{
m_CustomEvents.push_back(evt);
}
}
}
void McCol::EventSystem::DispatchEvent(const McCol::Event& evt)
{
auto [first, last] = m_Listeners.equal_range(evt.EventID);
for (auto& it = first; it != last; ++it)
{
// 이벤트 함수 호출
// EventFunction : 해당 이벤트를 구독한 Listener 가 실행할 함수
// evt.Parameter : 해당 이벤트에 대한 매개변수
it->second.EventFunction(evt.Parameter);
}
}
bool McCol::EventSystem::CheckSubscribe(std::wstring_view evtID, const EventHandler* listener)
{
auto [first, last] = m_Listeners.equal_range(evtID);
for (auto& it = first; it != last; ++it)
{
if (it->second.Listener == listener)
{
return true;
}
}
return false;
}
EventSystem Member Function
EventSystem::EventSystem: 생성자에서는 딱히 해줄 일이 없다.EventSystem::Initialize: 초기화에서도 딱히 해줄 일이 없다. (시스템 관련 함수의 모양새를 맞추기 위해 있는 함수)EventSystem::Update: 매 프레임(실행 주기)마다 이벤트 처리 함수ProcessEvent를 수행한다.EventSystem::Finalize: 시스템이 종료(Finalize)된다는 것은 게임이 끝난 다는 것이기 때문에 모든 이벤트를 삭제하고, 모든 구독자를 삭제한다. 이후 싱글톤 객체를 삭제한다.SAFE_DELETE는 단순히 들어온 포인터를delete하고nullptr로 바꾸어주는 매크로(pch.h에 정의됨). (소멸자에서 처리해도 되지만 시스템 관련 함수의 모양새를 맞추기 위해 있는 함수)EventSystem::PublishEvent: 외부에서 싱글톤 객체에 접근하여 사용하는 함수로Event를 발행할 때 사용한다. 인자로 식별값(ID), 파라미터, 지연 시간을 가지며 지연 시간은 기본 값으로0.0f를 갖는다. 인자는 모두Event객체의 생성자에 그대로 들어간다. 생성된Event객체는m_CustomEvents에 push_back 으로 들어가기 때문에 복사되어 들어간다. 복사라는 단점이 있음에도 불구하고 emplace_back 대신 push_back 을 사용한 이유는 참조 형식으로 받아온param객체의 수명이 불분명하기 때문에Event가 실제로 수행 될 때 까지param객체가 존재한다는 보장이 없기 때문이다.EventSystem::Subscribe: 외부에서 싱글톤 객체에 접근하여 사용하는 함수로Event를 구독할 때 사용한다. 중복 구독할 이유는 없기 때문에 유효성과 중복 검사를 해주고 문제가 없다면m_Listeners에 넣어준다.EventSystem::ProcessEvent: 지연 시간을 확인하여 적절한Event객체들을 처리하는 함수. range-based for loop 을 사용하여 순회하는데, 순회 함과 동시에 조건이 만족하는Event객체는EventSystem::DispatchEvent로 넘겨서 실행하게 된다. 실행 도중Event를 생성하는EventFunction이 존재 할 수 있으므로EventSystem::Pusblish를 통해m_CustonEvents에 새로운Event객체가 추가 될 수 있다. 이로 인해 for loop 순회에 문제가 생기게 된다. 따라서m_CustomEvents를tempEvents에 복사하여 생성하고m_CustomEvents는 clear 를 통해 비워준다. 그 뒤tempEvents를 순회하며DelayedTime을deltaTime만큼 빼주고DelayedTime의 값을 확인해가며 DispatchEvent 의 수행 여부를 결정한다. 수행하지 않는다면 다시m_CustomEvents에 넣어주어 다음 프레임에 같은 행위를 반복하도록 한다. 함수가 종료되면tempEvents는 수명이 만료되어 삭제되고m_CustomEvents에는 아직 지연 시간이 남은Event객체만 유지되게 된다.EventSystem::DispatchEvent:m_Listeners의 equal_range 를 사용하여 key 값(evt.EventID) 이 일치하는std::pair<wstring_view, ListenerInfo>객체를 가르키는iterator를[first, last]형태로 가져온다. 이후first ~ last를 순회하며 해당iterator.second의EventFunction에 매개변수로 들어온evt.Parameter를 넣어주고 실행한다.- 이외 함수 :
EventSystem.h함수 주석 참조
#pragma once
#include <functional>
#include "EventSystem.h"
namespace McCol
{
class EventHandler
{
public:
// EventHandler 로 등록된 모든 이벤트를 제거
virtual ~EventHandler()
{
EventSystem::GetInstance()->RemoveListener(this);
}
// EventSystem 에 등록하기 위한 Callable 생성
template <typename T>
ListenerInfo MakeListenerInfo(void (T::* func)(std::any))
{
// 유효성 검사
static_assert(std::is_base_of_v<EventHandler, T>, "T must be derived from EventHandler");
// Callable 반환 (인자 설명은 ListenerInfo 구조체 참조)
return ListenerInfo(this, [this, func](std::any handler) { (static_cast<T*>(this)->*func)(handler); });
}
};
}
EventHandler Member Function
EventHandler::~EventHandler:EventSystem싱글톤 객체에 접근하여 이 객체로 등록된 모든ListenerInfo를 제거한다.EventHandler::MakeListenerInfo:ListenerInfo객체를 생성한다.[this, func](std::any handler) { (static_cast<T*>(this)->*func)(handler); }부분은 람다식으로 [ ] 캡처 부분에서this는 함수를 수행하는 객체를,func는 수행되는 멤버 함수를 나타내며(std::any handler)는handler라는std::any타입의 매개변수를 받아오는 것을 의미한다. 그리고 이를 이용하여 람다식의 본문인{ (static_cast<T*>(this)->*func)(handler); }부분을 작성한다. 본문은this를T*로 캐스팅하여 사용하는데 그 이유는MakeListenerInfo함수가 템플릿 함수이기 때문이다.this는EventHandler객체를 가르키므로func를 알 수가 없기 때문에 이를 캐스팅하여func를 수행할 수 있도록 한다. 또한 매개변수로handler를 넣어준다. 이 함수가 템플릿 함수인 이유는 어떤 클래스던 간에 해당 클래스가Event를 구독할 필요가 있다면EventHandler클래스를 상속받아MakeListenerInfo를 사용할 수 있도록 하기 위해서이다.
#include "EventSystem.h"
#include <iostream>
class Player : public McCol::EventHandler
{
public:
Player()
{
McCol::EventSystem::GetInstance()->Subscribe(L"PlayerDied", MakeListenerInfo(&Player::OnPlayerDied));
}
void OnPlayerDied(std::any data)
{
int lives = std::any_cast<int>(data);
std::wcout << L"Player died with " << lives << L" lives remaining." << std::endl;
if (lives <= 0)
{
McCol::EventSystem::GetInstance()->PublishEvent(L"GameOver");
}
}
};
class Game : public McCol::EventHandler
{
public:
Game()
{
McCol::EventSystem::GetInstance()->Subscribe(L"GameOver", MakeListenerInfo(&Game::OnGameOver));
}
void OnGameOver(std::any data)
{
running = false;
}
void Run()
{
int lives = 3;
running = true;
while (running)
{
McCol::EventSystem::GetInstance()->Update(0.0f);
// 게임 로직 (생략)
// ...
lives--;
McCol::EventSystem::GetInstance()->PublishEvent(L"PlayerDied", lives);
}
std::wcout << L"Game Over!" << std::endl;
}
private:
bool running;
};
int main()
{
McCol::EventSystem::GetInstance()->Initialize();
Player player;
Game game;
game.Run();
McCol::EventSystem::GetInstance()->Finalize();
return 0;
}
직접 사용한 코드를 가져오려고 했으나 해당 이벤트를 처리를 이해하는데 수반되는 코드가 많기 때문에 이벤트의 처리 과정을 보여주는데는 적합하지 않다고 판단되어 따로 만들었다. 편의상 이곳의
EventSystem::Update의 인자로0.0f를 주었지만 원래는 지연 시간 처리를 위해 게임 엔진상의 실제 델타 타임을 주는게 옳다. 위에서 언급한 코드 진행 방식에 기반하여 설명하자면Player가 구독한EventID를Game에서 발행하는 형식이다. 포스트의 도입부에서 이야기 했던Event의 필요성에서 나온 사용 방향과는 조금 다르지만 이렇게도 사용할 수 있고 다른 방식으로도 얼마든지 응용이 가능하다. 모두 같은 결론을 도출해 낼 수 있는데, 객체간 의존성이 적어진다는 것이다!
Player생성자에서L"PlayerDied"를 구독하고 실행할 함수를 지정한다.Game생성자에서L"GameOver"를 구독하고 실행할 함수를 지정한다.- 매 실행 주기(프레임)마다
Game::Run이 실행되고lives값이 줄어들며 이에 해당하는L"PlayerDied"를 발행하며 인자로 남은lives를 넘겨준다.Player::OnPlayerDied에서 이 함수가 수행 될 때마다 인자로 받은 남은lives를 출력한다.- 출력 도중
lives가if (lives <= 0)조건을 만족하면L"GameOver"를 발행한다.Game::OnGameOver함수가L"GameOver"를 받고 자신이 속한 객체의running을false로 변경하여 게임을 종료한다.
Youtube : C++ - Event System