C++/Game

[게임 프레임워크 개발 일지] #7 Variadic Template과 Lambda

Kareus 2022. 7. 20. 19:58

게임 프레임워크에 한정되는 이야기는 아니지만, 개발하면서 헤매던 내용이라 정리할 겸 포스팅으로 올립니다.

 

template은 어떤 타입이 함수나 클래스에 오더라도 대응할 수 있게 해줍니다.

그만큼 강력하고, 또 그만큼 어렵습니다.

제 게임 프레임워크에서  template을 주로 사용하는 클래스는 Entity와 Event 였습니다.

 

Entity는 Component로 구성되어 있습니다.

벽과 상자는 모두 충돌 판정이 필요하고 (Collidable), 그릴 그래픽이 있지만 (Drawable)

벽은 이동하지 않고 상자는 플레이어가 민다든지 하는 작용으로 이동할 수도 있습니다 (Movable).

상호작용이 필요할 수도 있겠네요. (Interactable) 물론 게임마다 다르겠지만 말입니다.

괄호 속에 병기한 클래스들은 모두 Component지만, 결국 Entity를 설계하는 시점에서는 뭐가 올지는 알 수 없습니다.

 

그래서 이러한 Component가 무엇이 되든 Entity에 등록할 수 있도록 하기 위해서, template을 사용합니다.

template <typename T>
T& addComponent(entt::entity& entity)
{
    return registry.emplace_or_replace<T>(entity);
}

제가 사용하는 ecs system은 entt 라는 라이브러리입니다.

이 라이브러리의 소스 코드를 보면 알 수 있지만, Component를 생성하는 데에 parameter가 필요한 경우도 있습니다.

struct Movable
{
    double x, y;
    
    Movable(double x, double y) : x(x), y(y)
    {}
};

Component의 생성자 parameter는 몇 개가 될 지, 무엇이 될 지도 알 수 없습니다.

무엇이 되냐는 template에서 많이 보던 일이지만, 몇 개가 되냐는 또 다른 문제죠.

이 문제를 해결하기 위한 template이 variadic template (가변 인자 템플릿)입니다.

몇 개냐의 문제가 추가되었기 때문에 훨씬 어려워졌습니다.

 

variadic template은 타입 앞에 ...을 붙입니다.

template <typename T, typename... Args>
void print(T first, Args... args)
{
    cout << first << ' ';
    print(args...);
}

Args... args에는 여러 개의 template parameter가 올 수 있습니다.

여기서는 맨 앞의 element를 출력하고 다음 여러 개에 대해서 재귀 호출하는 방식입니다.

여기까지는 나름 괜찮네요.

 

template <typename Component, typename ...Args>
Component& setComponent(entt::entity& entity, Args&& ...args)
{
    return registry.emplace_or_replace<Component, Args...>(entity, std::forward<Args>(args)...);
}

보기만 해도 머리가 아프네요.

perfect forwarding을 하려다가 이렇게 되었습니다.

 

Args ...args를 parameter pack이라고 하는데

parameter pack에서 &&는 forward reference입니다.

무슨 소린지? 를 저도 잘 모르겠어서 검색해봤는데 제가 이해한 선에서는 다음과 같습니다.

 

variadic template arguments를 Args... args로 정의했다면,

함수에서는 이 args를 pass by value로 copy해서 가져옵니다.

만약 pass by reference로 넘겨주고 싶다면, Args&... args라는 선택지가 있습니다.

 

문제는, Args&...는 가져오는 모든 arguments를 pass by reference로 가져온다는 점입니다.

일반적인 변수는 pass by value나 pass by reference나 상관이 없지만, 상수 등의 r-value는 컴파일에서 에러가 발생합니다.

template <typename... Args>
void test(Args&... args)
{
    //do something
}

//...
int a = 0, b = 1;

test(a, b); //OK
test(a, 3); //compile error!

 

따라서 이 문제를 처리하기 위해 perfect forwarding이 필요합니다. 말그대로, 완벽하게 전달해줘야 됩니다.

Args&&... args로 전달하게 되면, l-value reference가 되는 argument는 그 값을 r-value로 취급해서 r-value reference로 전달해도 문제가 없습니다.

r-value reference만 가능한 argument는, r-value reference로 전달해야겠죠.

이렇게 type deduction (타입 추론)이 필요하고, 경우에 따라 l-value, r-value로 취급이 달라지는 r-value reference를 forwarding reference라고 부른다고 합니다. universal reference라고 부르는 사람도 있네요. 지식이 늘었다.

 

그렇다면 이제 받아온 arguments들을 l-value냐 r-value냐에 따라 구분지어줘야 합니다.

이건 std::forward를 사용해야 하는데, 이건 귀찮으니 코드로 봅시다.

template <typename T>
void inc(T& a, T& b)
{
    cout << "reference increment." << endl;
    a++;
    b++;
}

template <typename T>
void inc(const T& a, const T& b)
{
    cout << "val cannot be incremented." << endl;
}

template <typename T>
void printVal(T a, T b)
{
    cout << a << ' ' << b << endl;
}

template <typename... Args>
void call(Args&&... args)
{
    inc(std::forward<Args>(args)...);
    printVal(args...);
}

//...
int a = 3, b = 5;
call(3, 5);
call(a, b);

//result
val cannot be incremented.
3 5
reference increment.
4 6

 

inc는 모호성을 피하기 위해서 T&과 const T&을 정의해줬습니다.

cosnt T&은 pass by value를 처리하기 위함인데, 그냥 T로 정의하면 T&이 T&으로도 T로도 전달될 수 있기 때문입니다.

참고로, call(a, 3); 을 호출하면 3이 r-value이기 때문에 int(const T&, const T&)이 호출됩니다.

 

entt에서 이미 variadic template를 사용해서 Component 추가 함수를 구현해줬기 때문에, 여기서는 그냥 template type을 넘겨주는 일만 할 겁니다.

entt::registry regsitry;
entt::entity entity;

//...

template <typename T, typename... Args>
void setComponent(Args&&... args)
{
    registry.setComponent<T, Args...>(entity, std::forward<Args>(args)...);
}

 


머리가 아프군요. 다음은 Event입니다.

EventManager 설계에 대해서 #3에서 다룬 적 있습니다. 그게 작년 마지막 날이었으니 반년만에 Event를 다루는 군요.

 

Event는 trigger에 의해 발동되고, parameter로 무엇이든 받을 수 있어야 합니다.

여기서 무엇이든 될 수 있는 parameter가 문제가 되었습니다.

보통의 상황에서는 template type으로 해결이 되었지만, Event는 좀 다릅니다.

 

일단 template을 쓴다고 가정하고 생각해봅시다.

Event에는 함수 객체가 저장되어 있고, call 함수를 통해 parameter를 넘겨줘서 호출하게끔 설계가 되어있습니다.

parameter 타입을 Args...라고 한다면, 함수는 std::function<void(Args...)>가 되고, call의 설계는

void call(Args&&... args) 가 될 겁니다.

 

여기서 발생하는 문제는, 함수 타입을 저장하는 방법 중 가장 생각하기 쉬운 방법이 Event를 template class로 선언하는 것인데, 이렇게 되면 manager에서 event를 list로 관리하기가 어렵습니다.

Event마다 template type이 다르니 heterogeneous list가 될 것이고, C++에서는 이걸 구현하기도 어렵고 구현해도 그닥 사용성이 좋지는 않습니다.

또, 함수가 바뀌면 Args의 type이 언제든지 바뀔 수 있다는 점입니다. Event를 template class로 선언해버리면 처음 Event를 선언할 때의 type이 고정되겠죠.

 

그러면, Event는 그냥 class로 두되 event function은 무슨 타입이 되든 그냥 저장할 방법은 없을까?

해서 entt를 참고한 바로는, template type을 id로 하는 map에 함수를 저장하는 방법이 있습니다.

이건... 이렇게까지 해야되나? 싶네요. 이론상 type이 array로 들어오더라도 id로 할 수는 있는데...

다음 포스팅에서 다뤄보겠습니다. 구현해봤는데 제약이 너무 많고 어렵네요. 사용하기에 편하냐고 하면... 그건 또 그런 것 같지는 않고 말이죠. 말그대로, 이렇게까지 해야되나 싶을 정도입니다.

 

그래서 또! 다르게 생각한 방안은 그냥 parameter를 any로 통일시키는 겁니다.

'나는 any로 받고 넘겨줄테니, cast는 네들이 알아서 해라' 같은 거죠.

std::any는 말그대로 무슨 타입이든 들어올 수 있습니다. 다만 다시 받아올 때는 무슨 타입인지 내가 알고 있어야 하기 때문에 std::any_cast를 사용해야 합니다.

 

그렇게 해서 만들어진 Event의 setListener와 call은 이렇습니다.

void Event::setListener(const std::function<void(const std::vector<std::any>&)>& func)
{
    listener = func;
}

void Event::setListener(const std::function<void()>& func)
{
    listener = [func](const std::vector<std::any>&) {func(); };
}

void Event::call(const std::vector<std::any>& args)
{
    listener(args);
}

이러면 Event는 간단해지죠. listener의 생성이 좀 복잡해집니다.

auto f = [] (const std::vector<std::any>& v) {
    sf::String eventName = std::any_cast<sf::String>(v.at(0));
    std::cout << "event triggered: " << eventName.toAnsiString() << std::endl;
};

Event e(f);
EventManager eventManager;
eventManager.connect("customEvent", e);

eventManager.call("customEvent");

Event Type에 따라 호출할 이벤트가 다르기 때문에, 0번째 인자는 이벤트 타입으로 고정됩니다.

 

call에서 뭔가 더 처리하고 싶다면 Event를 상속받아서 처리해주면 됩니다.

void TimeEvent::call(const std::vector<std::any>& args)
{
    std::string type = std::any_cast<std::string>(args.at(0));
    //event type check
    
    double delta = std::any_cast<double>(args.at(1));
    time += delta;

    if (time >= interval)
    {
        time -= interval;

        std::vector<std::any> new_args;
        for (size_t i = 2; i < args.size(); i++) new_args.push_back(args[i]);

        Event::call(new_args);
    }
}

Event를 상속받는 TimeEvent의 call입니다.

정해진 시간마다 listener를 실행하게끔 하는 하위 이벤트로 설계했습니다.

 

TimeEvent 등의 custom event는 이벤트 타입을 string으로 정의했습니다.

여기서는 매 프레임마다 call을 호출받는 것을 전제로 하는데, 그 전 call과의 시간 간격 delta를 1번째 인자로 받습니다.

이렇게 사용자가 정의한 type 대로 parameter가 들어올 것을 가정하고 함수 내에서도 그 type대로 cast하고 있습니다.

 

사용자에게 좀 불편하지만, 내 말대로 하지 않으면 bad cast가 될 것이란 협박이 가능하네요.

뭐.. 일단 돌아가긴 하니 넘어갑시다.

 

C++에서 함수 객체는 std::function 이외에 lambda 식도 있습니다.

그런데 lambda 식은 미리 정의된 타입이 아닙니다. 컴파일 시기에 아무렇게나 이름이 결정됩니다.

따라서 Event의 생성자 parameter (std::function)로 들어가지가 않습니다.

lambda 자체는 std::function 생성자에 들어가는데 말이죠.

 

그래서 setListener(lambda); 는 실행이 잘 된다는 점에서 template constructor를 만들고 거기서 람다식인지 아닌지 구별해주기로 했습니다.

컴파일 시간에 template type이 어느 조건을 만족하는지 알아보려면 type_traits의 기능들이 필요합니다.

우선 lambda와 std::function, 그리고 그 외의 타입들을 구분해봅시다.

 

lambda는 std::function과 같은 타입이 아닙니다. std::is_same이 필요하겠네요.

반면 lambda는 std::function으로 변환은 가능합니다. std::is_convertible은 참이 된다는 얘기죠.

위에서 슬쩍 코드로 보였듯이, 현재 Event는 std::function<void()>와 std::function<void(const std::vector<std::any>&)>를 parameter로 받을 수 있습니다.

따라서 두 타입에 대해서 is_same과 is_convertible을 수행할 겁니다.

 

정리하면,

is_same이 true -> std::function이다. -> 종료 (std::function에 대해 정의된 다른 생성자가 호출됨)

is_same이 false -> lambda이거나 다른 타입이다. -> is_convertible을 실행

is_convertible이 true -> lambda -> setListener를 수행 후 종료

is_convertible이 false -> lambda도 function도 아니다. -> 생성자 호출 불가

 

이걸 이용해서 생성자를 허용하냐 마느냐는 std::enable_if가 있습니다.

template <typename T, typename std::enable_if<!std::is_same<T, std::function<void()>>::value && !std::is_same<T, std::function<void(const std::vector<std::any>&)>>::value && !std::is_convertible<T, Event>::value &&
    (std::is_convertible<T, std::function<void()>>::value || std::is_convertible<T, std::function<void(const std::vector<std::any>&)>>::value), T>::type* = nullptr>
Event(T lambda) : callCount(0), maxCall(0)
{
    setListener(lambda);
}

 

복잡하군요. 이걸 어떻게 읽으라는 겁니까?

그런 당신을 위해 C++20에서 추가된 concept가 있습니다.

 

concept는 template 등에서 조건을 지정하기 위한 키워드입니다. 위와 같은 상황이죠.

type_traits를 사용하는 template 조건들은 concept로 표현할 수 있고, 가독성이 더 좋습니다.

에러도 알아듣기 쉽게 설명해줍니다. 그 외에 concept가 할 수 있는 건, 아직 제가 공부 안 했습니다. ㅎㅎ;;

class Event;

template <typename T>
concept IsLambda = !std::is_same<T, std::function<void()>>::value && !std::is_same<T, std::function<void(const std::vector<std::any>&)>>::value && !std::is_convertible<T, Event>::value &&
(std::is_convertible<T, std::function<void()>>::value || std::is_convertible<T, std::function<void(const std::vector<std::any>&)>>::value);

template <typename T> requires IsLambda<T>
Event(T lambda) : callCount(0), maxCall(0)
{
    setListener(lambda);
}

야호! 간단해졌군요 ... 간단해진 거 맞나요?

적어도 Event 생성자에 한해서는 그렇습니다. 에러 메시지도 알아듣기 쉽습니다.

컴파일러 오류 C7500 '%$I': 제약 조건을 충족하는 함수가 없습니다.

음 그렇군요.

C++20에서 추가된 기능이기 때문에, 컴파일러 옵션이 c++20 이상인지 확인해줘야 됩니다.

C++ standard

Visaul Studio 기준으로 C++20인지 아닌지 매크로 옵션으로 확인하려면 __cplusplus의 버전을 확인해야 됩니다.

그런데 기본 설정으로는 __cplusplus가 C++98로 고정되니, C++의 명령줄 옵션에 /Zc:__cplusplus를 추가해줘야 됩니다.

__cplusplus option

그 후에 다음과 같이 매크로를 작성해주면 됩니다.

#if __cplusplus >= 202002L
//C++20 features here
class Event;

template <typename T>
concept IsLambda = !std::is_same<T, std::function<void()>>::value && !std::is_same<T, std::function<void(const std::vector<std::any>&)>>::value && !std::is_convertible<T, Event>::value &&
(std::is_convertible<T, std::function<void()>>::value || std::is_convertible<T, std::function<void(const std::vector<std::any>&)>>::value);

#endif

class Event
{
#if __cplusplus >= 202002L
//C++20
    template <typename T> requires IsLambda<T>
#else
//not C++20
    template <typename T, typename std::enable_if<!std::is_same<T, std::function<void()>>::value && !std::is_same<T, std::function<void(const std::vector<std::any>&)>>::value && !std::is_convertible<T, Event>::value &&
        (std::is_convertible<T, std::function<void()>>::value || std::is_convertible<T, std::function<void(const std::vector<std::any>&)>>::value), T>::type* = nullptr>
#endif
    Event(T lambda)
    {
        setListener(lambda);
    }
}

 

Event의 첫 번째 구현이 끝났군요.

template을 이용해서 어느 타입의 function이든 (혹은 lambda)든 받을 수 있는 방법은 다음 포스팅에서 다루겠습니다.