C++/Game

[게임 프레임워크 개발 일지] #8 편식 안 하고 뭐든 잘 먹는 Event

Kareus 2022. 7. 24. 01:28

지난 포스팅에 이어서, return type / parameter type이 무엇이든 상관 없이 함수를 할당할 수 있는 Event를 설계할 겁니다.

정확히는, 지난 포스팅에서 std::vector<std::any>를 parameter로 받는 void 함수만을 받도록 해서 구현했고

이번에는 정말 그 type이 뭐가 되든 상관 없는 Event를 구현해볼 겁니다.

 

미리 결론부터 봅시다. 그래서 구현 가능한가요?

일부는 , 일부는 아니오 입니다.

다시 말하면 나름 구색은 갖출 정도로 구현은 가능하지만, 제약이 있습니다.

 

그럼 천천히 살펴봅시다. 제 기준이지만, 내용이 꽤 어렵습니다.

구현하고 싶은 결과물을 코드로 나타내면 이렇습니다.

Event e([](int a, int b) { cout << a + b << endl;});
e.call(3, 4);

e.setListener([](int a, int b, int c) { cout << a * b * c << endl;});
e.call(3, 4, 5);

//result
7
60

1. parameter의 type도, 개수도 정해지지 않은 함수를 구현할 수 있을까?

- . 이건 variadic template을 사용하면 됩니다.

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

좋네요. 그럼 저 func는 어떻게 가져올지, 그 이전에 저장은 어떻게 할 지도 따져야겠네요.

 

2. 타입이 정해지지 않은 함수를 (std::function 혹은 lambda 식을) 변수에 저장할 수 있을까?

- . 저장이라면 std::any가 있습니다.

std::any func;

template <typename T>
void setListener(T f)
{
    func = f;
}

여기서 T가 호출이 가능한 함수인지를 따져야 되는데, 이건 지난 포스팅에서 다뤘습니다.

근데 여기선 return type과 parameter type을 모르니 좀 더 복잡합니다.

 

모든 함수의 공통점은 operator()가 있다는 점입니다. 그리고 operator()의 parameter는 함수의 parameter와 동일하죠.

operator()의 type을 가져와봅시다.

std::function<void(int, int)> f = [](int a, int b) {a++; b++; };

cout << typeid(decltype(f)).name() << endl;
cout << typeid(decltype(&decltype(f)::operator())).name() << endl;

std::function의 decltype

std::function 객체면 그 클래스가 함수의 타입이 되니 그대로 가져오는 게 편하겠네요.

 

template <typename T, typename... Args>
Event(const std::function<T(Args...)>& func)
{
    setListener(func);
}

 

그런데 lambda 식이 문제입니다.

auto f = [](int a, int b) {a++; b++; };

cout << typeid(decltype(f)).name() << endl;
cout << typeid(decltype(&decltype(f)::operator())).name() << endl;

lambda의 decltype

lambda 식은 이름이 없는 struct입니다. 컴파일 시간에 무작위로 정해집니다.

따라서 변수로 선언할 때도 auto로만 타입을 지정할 수 있고, 같은 타입 심지어 같은 함수로 보일지라도 두 lambda 식은 type이 다른 것으로 취급되고 변환이 불가능합니다.

 

그러니, 저 operator()에서 가져온 type에서 parameter와 return type을 가져와야 합니다.

그리고 성지 stackoverflow에서 관련된 function_traits 코드가 있다는 이야기를 듣고 낼름 코드를 가져왔습니다.

template <typename T>
struct function_traits
{
    using type = void;
};

template <typename Ret, typename F, typename... Args>
struct function_traits<Ret(F::*)(Args...) const>
{
    using type = std::function<Ret(Args...)>;
};

 

이러면 return type이 Ret, parameter type이 Args...인 함수라고 특정할 수가 있겠네요.

이걸 이용해서 제약 조건을 정의하면 다음과 같습니다.

template <typename T, typename std::enable_if<!std::is_same<T, typename function_traits<decltype(&T::operator())>::type_with_void>::value &&
        !std::is_convertible<T, Event>::value &&
        std::is_convertible<T, typename function_traits<decltype(&T::operator())>::type>::value, T>::type* = nullptr>
Event(T lambda)
{
    setListener(lambda_to_function(lambda));
}

복잡하네요.

간단하게 이야기해서, std::function이나 Event는 아니면서, 동일한 return type, parameter type의 std::function으로 변환은 가능한 타입이라면 이 생성자를 실행하라는 조건입니다.

그렇기 때문에, lambda 식은 이 조건에 부합해서 이 생성자를 실행하게 되고 std::function으로 변환해줄 필요가 있습니다.

 

lambda와 동일한 타입의 std::function은 function_traits::type을 이용하면 구할 수 있습니다.

template <typename F>
typename function_traits<decltype(&F::operator())>::type lambda_to_function(F const& func)
{
    return func;
}

 

이렇게 하면 setListener에는 std::function만이 오게 됩니다.

template <typename T, typename ...Args>
void setListener(const std::function<T(Args...)>& f)
{
    func = f;
}

이제 2번이 끝났습니다.

이렇게 저장한 함수를 호출하려면 std::any_cast를 이용해 다시 원래 타입으로 변환할 필요가 있습니다.

 

3. any에 저장된 함수를 필요한 순간에 원래 타입으로 다시 불러올 수 있을까?

- 아니오. 가장 골치 아픈 구간입니다. 함수가 필요한 순간은, 그 함수의 argument를 보내는 call 함수를 호출했을 때입니다.

template <typename T, typename... Args>
T call(Args&&... args)
{
    //...
}


//how we use
e.call(3, 4);

가장 큰 문제는 return type을 알 수가 없다는 점입니다. 우리가 직접 지정해서 불러오는 경우라면 몰라도,

단순히 argument만 지정해서 호출하면 return type이 void인지 아니면 뭔가 반환하는 게 있는지

call 함수 내에서는 알 수가 없습니다. 그래서 any_cast가 불가능하고, 당연히 함수 호출 자체도 불가능합니다.

 

그러면... return type이 없으면 가능할까요?

return type은 void로 생각하고, return할 값이 필요하다면, 그걸 reference parameter로 넣어도 기능은 할 수 있습니다.

그런 경우엔 코드가 이렇게 됩니다.

template <typename Ret, typename F, typename... Args>
struct function_traits<Ret(F::*)(Args...) const>
{
    using type = std::function<void(Args...)>;
};

template <typename ...Args>
void setListener(const std::function<void(Args...)>& f)
{
    func = f;
}

templae <typename... Args>
bool match_arguments()
{
    using T = std::function<void(Args...)>;
    if (const T* v = std::any_cast<T>(&f))
        return true;
    else
        return false;
}

template <typename... Args>
void call(Args&&... args)
{
    if (match_arguments<Args...>())
    	std::any_cast<std::function<void(Args...)>)(f)(std::forward<Args>(args)...);\
    else
        return; //error: cast the function
}

type check를 위한 match_arguments 함수를 추가해줬습니다.

 

3-2. 흠 그럼 이제 다 끝난 걸까요?

아뇨!

지금 상태에서 코드를 작성하고 실행하면, cast가 실패했다는 에러가 자꾸 발생합니다.

 

variadic template의 type check를 해본 결과, args의 타입이 이상하다는 것을 알게 됐습니다.

알기 쉽게, template 하나만 두고 실험해봅시다.

 

4. 그래서, argument만 지정해서 그 함수를 호출할 수 있을까? (함수의 타입을 추론할 수 있을까?)

template <typename T>
void printType1(T t)
{
    if (std::is_const<T>::value) cout << "const ";
    if (std::is_volatile<T>::value) cout << "volatile ";

    cout << typeid(T).name();
    if (std::is_lvalue_reference<T>::value) cout << "&";
    if (std::is_rvalue_reference<T>::value) cout << "&&";
    cout << endl;
}

template <typename T>
void printType2(T& t)
{
    if (std::is_const<T>::value) cout << "const ";
    if (std::is_volatile<T>::value) cout << "volatile ";

    cout << typeid(T).name();
    if (std::is_lvalue_reference<T>::value) cout << "&";
    if (std::is_rvalue_reference<T>::value) cout << "&&";
    cout << endl;
}

template <typename T>
void printType3(T&& t)
{
    if (std::is_const<T>::value) cout << "const ";
    if (std::is_volatile<T>::value) cout << "volatile ";

    cout << typeid(T).name();
    if (std::is_lvalue_reference<T>::value) cout << "&";
    if (std::is_rvalue_reference<T>::value) cout << "&&";
    cout << endl;
}

//
int x = 1;
	
printType1(x);
printType2(x);
printType3(x);

//result
int
int
int&

parameter의 type으로 가능한 경우는 3가지 입니다. T, T&, T&&.

variadic template이었으면 Args..., Args&..., Args&&... 였겠지만 얘는 직관적으로 알 수가 없으니 T로 비교합시다.

int 변수 하나를 선언해서, typeid를 통해 이름을 출력하도록 했습니다.

name은 말그대로 이름만 출력하기 때문에, const인지 reference인지는 type_traits를 통해 확인해줘야 됩니다.

 

그리고 그 결과가 각각 int, int, int&입니다.

printType1과 printType2 입장에서는 뭐... 그대로 int가 나왔네요.

문제는 printType3입니다. T&&로 받아오는 값들은 l-value이면 pass by reference로, r-value면 pass by copy로 취급됩니다.

printType3(3);

//result
int

type deduction이 어떻게 이루어지는지 생각해봅시다.

유의할 점은, T&& t로 선언된 parameter에서 t 역시 일단은 l-value라는 점입니다. 변수로 선언된 것이니까요.

그리고 이 t가 가리키는 값의 type에 따라 다음과 같이 처리됩니다.

- l-value면 pass by reference가 가능하니 (reference로 참조해서 값을 바꿀 수 있으니) int는 int&가 됩니다.

- r-value면 pass by copy만 가능하니 3은 int 그대로 넘어옵니다.

...인데 이거 맞나요? 대충 이런 식이겠거니 생각은 하는데 헷갈리네요.

 

아무튼, type이 원본 그대로 넘어오지는 않는다는 것을 알았습니다.

그러면 int, int&, int의 r-value가 있을 때 문제가 되는 것은 pass by copy와 pass by reference가 둘 다 되는 int 입니다.

이 녀석이 pass by copy인지, pass by reference인지는 함수를 모르면 알 수가 없습니다.

따라서 위의 질문, argument만 지정해서 함수를 호출할 수 있을까?의 대답은 아니오 입니다.

 

그 외의 선택지였던 Args...과 Args&...도 마찬가지입니다.

Args...는 모든 parameter를 pass by copy로 가져오고, Args&...는 pass by reference로 가져옵니다.

심지어 Args&...를 사용하게 되면 r-value는 argument로 사용하지도 못합니다.

 

4-2. 그럼 pass by value로만 취급하게라도 바꾸고 싶은데요.

대부분의 경우 pass by value로 parameter를 선언하기도 하고, 값을 바꾸고 싶다면 pointer라는 다른 선택지도 가능하기 때문입니다.

. 뭐 그건 가능하죠. 가장 쉬운 방법은 Args...를 사용하는 방법입니다.

그런데 이러면 모든 arguments를 copy해야 되니, 뭔가 좀 절약할 방법이 없을까 싶네요.

 

Args&&...는 참조할 수 있다면 가능한 모든 arguments를 참조로써 가져옵니다.

그러니 이 reference를 지워봅시다.

template <typename... Args>
std::function<void(Args...)> getListener()
{
    return std::any_cast<std::function<void(Args...)>>(func);
}

template <typename... Args>
void call(Args&&... args)
{
    auto f = getListener<std::remove_reference_t<Args>...>();
    f(std::forward<Args>(args)...);
}

단일 타입은 remove_reference를 쓰면 되지만, 타입이 여러 개이니 remove_reference_t를 사용했습니다.

이외에도 const, volatile 같은 cv-qualifer도 없애고 싶다면, std::decay 그리고 std::decay_t를 사용하면 됩니다.

 

 

그러면... reference는 취급을 못하나요? 4번의 전제는 call 함수에서 argument만 지정해주는 것이었습니다.

call이 template 함수이니, type도 지정해서 deduction이 필요하지 않게 하면 어떨까요?

 

5. type deduction이 없으면 함수의 타입을 지정할 수 있을까?

. 와 이건 가능한가보네요!

아뇨, 사실은 문제가 있습니다.

 

타입을 지정하면서 call 함수를 호출한다면, 코드는 다음과 같을 겁니다.

Event e;
//set a function

int a = 3, b = 4;

e.call<int, int>(a, b);

//set another function

e.call<int, int&>(a, b);

이렇게 type을 지정하는 경우, parameter의 type 선언에 문제가 발생하게 됩니다.

parameter를 Args&&...로 선언한 이유는, l-value와 r-value에 모두 대응하기 위함입니다.

그래서 l-value는 T&, r-value는 T로써 가져오게 됩니다.

이런 상황에서 a, b는 둘 다 l-value이므로 call은 int&로 가져오고 싶어합니다.

이 상황에서 강제로 int로 지정해도, 컴파일 에러만 발생할 뿐입니다.

 

그러면... Args&&...를 사용하는 이유가 없네요.

Args...를 사용하면 기본적으로 pass by copy이고, 

뭐, 제 시스템의 경우에는 EventManager에서 call을 하고 거기서 다시 Event에 call을 해야하기 때문에

경유하는 곳이 많아질 수록 &&를 사용하는 게 좀 더 이득이긴 합니다.

 

이 점에 대해서는 저한테 피드백해줄 지인을 찾기가 힘드네요.

사실 이런 걸로 대뜸 연락하고 싶지 않기도 합니다. 누가 일 얘기로 연락을 해

 

아무튼, 선택지는 두 개입니다.

그냥 Args...를 사용하고 reference 등의 문제는 타입을 지정하도록 일러준다.

또는, 둘 다 구현한다.

 

타입 지정이 필요한 이상, Args...는 구현할 수 밖에 없습니다.

Args&&는 복잡한 argument를 그나마 빠르게 넘길 수 있게 (라는 핑계로 지금까지 구현하려고 삽질한 게 아까워서) 기본 함수로 남겨둘 겁니다.

 

template <typename... Args>
void call_as(Args... args)
{
    auto f = getListener<Args...>();
    f(args...);
}

//...
int a = 3, b = 4;
e.call(a, b);
e.call_as<int, int&>(a, b);

 

argument type check가 없긴 하지만, 그걸 감안해도 훨씬 간결하네요.

 

그럼 이제 다 구현했군요!

 

이제 게살버거 만들어도 돼요?

아닙니다.

call 함수를 template으로 바꾸면서, 다른 곳에서 문제가 나타납니다.

 

6. template virtual function을 구현할 수 있을까?

아니오.

 

지난 포스팅에서 TimeEvent를 구현했듯이, 단순히 function을 call하는 것 이외에 추가적인 작업이 필요할 수 있습니다.

TimeEvent의 경우를 생각해봅시다. 주기적으로 어떤 함수를 반복 실행하는 Event가 필요합니다.

이를 위해 누적된 시간, 주기 값을 저장할 변수가 필요한데 마땅히 선언해 둘 곳이 없습니다.

아무래도 class object 내에서 관리하는 것이 편하겠죠. Event를 상속받아서 관련 변수와 함수를 구현했습니다.

 

문제는 call을 정작 호출하면 TimeEvent의 call이 호출되지 않는다는 점입니다.

template 함수는 virtual로 선언할 수 없습니다.

template과 virtual의 작동 원리를 생각하면 불가능하다는 것을 알 수 있습니다.

 

template은 컴파일 시간에 그 타입이 특정됩니다.

virtual은 반면 vtable에서 처음 선언한 클래스에서 override한 함수를 포인터로 가리키고 있는 구조이기 때문에

Base class로 upcast되더라도, 함수 자체는 기존의 함수를 가리키고 있어서 클래스에 상관없이 그 함수를 호출할 수 있습니다.

 

다시 생각하면, virtual function에 template을 적용하게 되면 컴파일 시기에 Base class와 Derived class의 함수의 타입은 달라질 수 있게 됩니다. 타입이 다른 두 함수를 포인터로 가라킬 수는 없죠.

 

그래서 이를 해결하기 위해서 지난 포스팅의 std::vector<std::any>를 다시 가져올 수 밖에 없었습니다.

돌고 돌아 결국 쓰게 되는군요.

 

call 함수는 template으로써 Base class에 그대로 정의할 겁니다.

함수 호출 자체는 Event를 상속받는 모든 클래스에서 이루어질 것이기 때문에, 그 파트만은 call에 남겨두는 겁니다.

중요한 건 call 이전과 call 이후가 되겠죠.

type check가 끝난 뒤에 before_call, call 이후에 after_call을 호출하게 만들 겁니다.

이 두 함수는 std::vector<std::any>를 사용해서 virtual function이 될 수 있도록 정의하겠습니다.

Event를 상속받는 클래스에서는 이 두 함수를 override하면 됩니다. any_cast를 직접 활용해야 된다는 건 좀 귀찮긴 하네요.

 

return type은 bool입니다. before_call에서 false가 나오면 조건 미충족으로 중단할 수 있어야하기 때문입니다.

after_call은 bool일 필요는 없지만, 혹시라는 게 있지 않겠습니까. 다만 무슨 생각으로 false를 반환할 지는 저도 모르기 때문에, 에러 발생은 그 함수 안에서 구현해야 합니다.

 

이러면 bool로 두는 의미가 없나? 뭐... 그냥 보기 이쁘니까 라고 하죠 그럼.

virtual bool before_call(const std::vector<std::any>& args)
{
    return true;
}

virtual bool after_call(const std::vector<std::any>& args)
{
    return true;
}

void call(Args... args)
{
    std::vector<std::any> v = { args... };
    if (match_arguments<Args...>())
    {
        auto f = getListener<Args...>();
        if (before_call(v))
            f(args...);
        else
            return;
    }
    else return;
    
    if (!after_call(v))
       return; //warn something is wrong
}

Base class인 Event에서는 굳이 다른 일을 할 필요가 없습니다.

 

TimeEvent를 봅시다.

bool TimeEvent::before_call(const std::vector<std::any>& args)
{
    double delta = std::any_cast<double>(args.at(0));
    
    if (!play) return false;

    time += delta;

    if (time >= interval)
        time -= interval;
    else
        return false;

    return true;
}

type check는 이 함수 이전에 해주니, 굳이 any_cast 검사는 안 해줬습니다.

 

TimeEvent의 함수에 delta 값을 출력하도록 추가하고 돌려봤습니다.

delta

잘 되는군요. 이제야 끝났습니다.


외전1. 여러 개의 listener

여기서는 함수를 하나만 다뤘습니다. 여러 개인 경우도 생각해봐야겠죠.

각 함수의 타입에 대응해야하니, map을 사용합시다. 그러면, identifier는 어떻게 마련하죠?

 

C++에서 type과 관련한 정보를 얻을 수 있는 struct는 type_index와 type_info입니다.

type_index를 사용해보면 되겠네요.

단일 타입 T에 대해서는 std::type_index(typeid(T))로 구할 수 있습니다.

따라서 각 타입에 대해 type_index를 구할 수 있도록 재귀 구현을 해줘야 합니다.

 

#include <typeindex>

//...

template <typename...>
struct TypeIDHelper;

template <typename T, typename... TS>
struct TypeIDHelper<T, TS...>
{
    static void getTypeIDList(std::vector<std::type_index>& ret)
    {
        ret.push_back(std::type_index(typeid(T)));
        TypeIDHelper<TS...>::getTypeIDList(ret);
    }

    static std::string getTypeNames()
    {
        std::string ret = "[";
        if (std::is_const<T>::value) ret += "const ";
        if (std::is_volatile<T>::value) ret += "volatile ";

        ret += std::string(typeid(T).name());

        if (std::is_lvalue_reference<T>::value) ret += "&";
        else if (std::is_rvalue_reference<T>::value) ret += "&&";

        ret += "] " + TypeIDHelper<TS...>::getTypeNames();
        return ret;
    }
};

template <>
struct TypeIDHelper<>
{
    static void getTypeIDList(std::vector<std::type_index>& ret)
    {

    }

    static std::string getTypeNames()
    {
        return "";
    }
};

아직 map에 사용할 수는 없습니다. hash가 지정되어 있지 않기 때문입니다.

 

struct TypeListHasher
{
    size_t operator()(const std::vector<std::type_index>& vec) const
    {
        size_t seed = vec.size();
        for (auto idx : vec)
        {
            size_t x = idx.hash_code();
            x = ((x >> 16) ^ x) * 0x45d9f3b;
            x = ((x >> 16) ^ x) * 0x45d9f3b;
            x = (x >> 16) ^ x;
            seed ^= x + 0x9e3779b9 + (seed << 6) + (seed >> 2);
        }

        return seed;
    }
};

hasher는 그냥 stackoverflow를 보고 긁어왔습니다.

난stackoverflow가너무좋아stackoverflow에밥말아먹고국물도먹고

 

아무튼, 사용하려면 다음과 같이 사용하면 됩니다.

std::unordered_map<std::vector<std::type_index>, std::any, TypeListHasher> listeners;

template <typename... Args>
void setListener(const std::function<void(Args...)>& func)
{
    std::vector<std::type_index> v;
    TypeIDHelper<Args...>::getTypeIDList(v);
    
    std::any f = func;
    listeners.insert({v, f});
}

template <typename... Args>
std::function<void(Args...)> getListener()
{
    std::vector<std::type_index> v;
    TypeIDHelper<Args...>::getTypeIDList(v);
    
    auto f = listeners.find(v);
    if (f != listeners.end())
        return std::any_cast<std::function<void(Args...)>>(f->second);
    else
        return {};
}

 

외전2. 그래서 성능은 어느 정도일까?

이걸 만드려고 삽질하면서 느낀 것이 있습니다. 그래서 이러는 의미가 있을까?

그닥 효용성이 없어보였거든요. 근데 제가 고생한 의미가 없어지는 건 원하지 않기 때문에

퍼포먼스 비교를 해봤습니다.

 

Event에 무슨 함수든 집어넣기 위해서 any를 사용했고, any_cast가 필요합니다.

그래서 any_cast가 가장 두드러지는 차이점이 되겠네요.

그 외에 고려사항은 생각하기도 귀찮고, 실험해보기도 귀찮습니다.

 

1] int

#include <iostream>
#include <algorithm>
#include <chrono>

using namespace std;

void f(int x, int y)
{
    cout << x + y << endl;
}

int main()
{
    srand(1000);

    auto before = std::chrono::system_clock::now();

    for (int i = 0; i < 100000; i++)
        f(rand(), rand());

    auto after = std::chrono::system_clock::now();

    cout << std::chrono::duration_cast<std::chrono::duration<double>>(after - before).count() << endl;
    return 0;
}

Release 빌드에서 실험한 결과는 다음과 같습니다.

16.8596s 17.3133s 17.9764s 16.3244s 16.036s

 

2] any

#include <iostream>
#include <algorithm>
#include <chrono>
#include <any>

using namespace std;

void f(std::any x, std::any y)
{
    cout << std::any_cast<int>(x) + std::any_cast<int>(y) << endl;
}

int main()
{
    srand(1000);

    auto before = std::chrono::system_clock::now();

    for (int i = 0; i < 100000; i++)
        f(rand(), rand());

    auto after = std::chrono::system_clock::now();

    cout << std::chrono::duration_cast<std::chrono::duration<double>>(after - before).count() << endl;
    return 0;
}
17.3891s 16.7208s 16.2143s 18.1693s 17.3259s

 

큰 차이는 없네요. 사실 별 영향이 있을 것 같은 코드는 아닙니다.

 

외전3. 그래서 왜 이렇게 됐냐

간단히 말하면, C++이 강 타입 언어이기 때문이겠죠. Python 같은 약 타입 언어는 변수에 타입을 지정하지 않기 때문에,

지금 이렇게 삽질해놓은 기능을 구현하기가 굉장히 쉽습니다.

def f1(x, y):
    print(x + y)

def f2(x, y, z):
    print(x * y * z)


func = f1
func(3, 4)

func = f2
func(3, 4, 5)

이럴 때면 현타가 옵니다.

물론 머신 러닝이나 기타 여러 가지를 보면, 그렇다고 파이썬을 하고 싶지는 않습니다. C++에 너무 물든 탓도 있지만

 

얘는 성능을 체크해보려다 포기했습니다.

native를 비교하는 것도 뻔한 일이긴 하지만, 첫 시도가 6분 21초 (381.3908초)네요. 아 ㅋㅋ 빠른 포기.

 

 

외전3-2. 파이썬이 간단하면, 파이썬을 쓰면 되지 않을까요? 연동을 하든 포팅을 하든

나가.