C++ template - 4. Parameter Pack

JunTak Lee·2023년 5월 20일
0

C++ Template

목록 보기
4/8

시작하기에 앞서 뭐하는 놈인지는 알아야할꺼 같았다
그래서 CppReference에서 또 가져왔다
https://en.cppreference.com/w/cpp/language/parameter_pack

Template parameter pack은 template parameter의 일종으로 0개 이상의 arguement들을 사용할 수 있다

CppReference

개념 자체는 참 심플하다
대충 뒤에 점 세개 찍으면 Template Argument로 몇개를 넘기든 다 생성해주겠다는 뜻이다
말보다 코드가 훨씬 나으니 가장 간단한 예제부터 보자

#include <stdio.h>

template <typename... Args>
void foo(Args&&... args) {
    printf(args...);
}

int main() {
    foo<const char*, int, int, int>
    	("%d %d %d\n", 1, 2, 3);
        
    foo<const char*, int, int, int, int, int>
    	("%d %d %d %d %d\n", 1, 2, 3, 4, 5);
}

C언어 함수를 사용한 이유는, 이것보다 더 간단한 예제가 떠오르지 않아서이다
여튼 코드를 살펴보자
첫번째 foo함수 호출에서는 Argument가 4개가 넘어간다
두번째 foo함수 호출에서는 Argument가 6개가 넘어간다
그런데 둘다 Compile이 잘되고 실행도 잘된다
점 3개 넣었다고 Compiler가 그에 상응하는 코드를 모두 만들어주는 것이다..!
역시 전지전능하신 Compiler님..

실제로 Compile Result를 보면 함수가 두개 생성되었다
(Clang16 -O0)

void foo<char const*, int, int, int>(char const*&&, int&&, int&&, int&&):
        push    rbp
        mov     rbp, rsp
        sub     rsp, 32
		...
void foo<char const*, int, int, int, int, int>(char const*&&, int&&, int&&, int&&, int&&, int&&):
        push    rbp
        mov     rbp, rsp
        sub     rsp, 32
		...

이처럼 Argument의 개수를 특정짓기 힘들경우 사용하는 것이 바로 Parameter Pack이다


Usage

CppReference에 보면 뭐가 많이 주저리주저리 써있다
이걸 한번 다 읽어보는 것도 굉장히 좋은 방법이다
읽다보면 "이걸 이렇게도 써..?"하는 부분도 분명 있으니까
하지만 나는 내 방식대로 풀어볼려고 한다

우선 위에서 대충 어떤 놈인지 알았으니 어떻게 쓰는 놈인지를 한번보자
Function의 경우 위에서 한번 언급하긴했는데 그냥 다시 써봤다

template <typename... T>
struct bar {
    //...

    void some_mem_fn();
};

template <typename... T>
void bar<T...>::some_mem_fn() {
    //...
}

template <typename... T>
void foo(T... args) {
    //...
}

int main() {
    bar<int, char, double> a;
    foo<int, char, double>(0, 'a', 0.);
}

대충 Class나 Function이나 쓰는 방식은 비슷하다
Member function 같은 경우에도 그냥 뒤에 점 3개 붙이면 된다

CppReference에서도 그랬듯이 Parameter의 개수가 0개 이상이면 된다
따라서 아래와 같이 사용할 수 있다

template <typename... T>
struct bar {
    //...
};

template <typename... T>
void foo(T... args) {
    //...
}

int main() {
    bar<> a;
    foo<>();
}

만약 parameter pack에서 parameter 개수를 알고 싶다면 sizeof...()을 사용하면 된다

template <typename... Args>
int size_of_parameter_pack() {
    return sizeof...(Args);
}

int main() {
    return size_of_parameter_pack<int, double, float>();
}

// Result
3

이걸 본다면 이런생각이 들 수 있을 것이다
"뭔가 유용하게 쓰일 수 있을 것 같은데, 그래서 어디에써?"
우선 이전 글에서 다루었던 template template에서 유용하게 사용할 수 있다
그리고 또 재미있는게 Function Call할때 상당히 유용하다

Callback Example

예를 들어 일종의 Callback 구현중이라 하자
그리고 이 Callback Function을 하나만 사용하는게 아니라 여러개 사용하고 싶은 경우다
이때 Parameter Pack을 사용하면 아래와 같이 Generic Class를 만들 수 있다

#include <iostream>
#include <vector>

template <typename T>
class Callback { /* Undefined */ };

template <typename RetT, typename... ArgsT>
class Callback<RetT(ArgsT...)> {
private:
    typedef RetT (*func_type)(ArgsT...);
    typedef std::vector<func_type> container_type;

    container_type callback_container;

public:    
    void submit(const func_type& func) {
        callback_container.push_back(func);
    }

    template <typename... Args>
    std::vector<RetT> invoke(Args&&... args) {
        std::vector<RetT> ret_vec;
        for (auto& ele : callback_container)
            ret_vec.push_back(ele(std::forward<Args>(args)...));

        return ret_vec;
    }
};


int foo_1(int) { std::cout << "foo_1\n"; return 0; }
float foo_2(double) { std::cout << "foo_2\n"; return 0.f; }

int main() {
    Callback<int(int)> callback_ii;
    callback_ii.submit(foo_1);
    
    Callback<float(double)> callback_fd;
    callback_fd.submit(foo_2);

    callback_ii.invoke(1);
    callback_fd.invoke(1.);
}

// Output
foo_1
foo_2

cpp 문법이 그렇듯 뭐가 좀 많이 붙었다..
std::forward도 그렇고, 그 뒤에 fold expressions도 넣어버렸다
안넣을수도 있었겠지만 안넣으면 이게 좀 그래서 걍 넣었다
암튼, 여기서 중요한건 이렇게도 구현이 가능하다는 것이다
위 예시에서는 귀찮아서 Parameter를 하나만 넣었는데, 당연하겠지만 여러개 넣어도 된다

하지만 여기서 다시 문제가 발생한다
위 예시는 function pointer만 받는다
그렇다면 다른 callable 객체는 어떻게 받을 수 있을까
간단하다, std::function으로 바꿔주기만하면 된다

#include <iostream>
#include <vector>
#include <functional>

template <typename T>
class Callback { /* Undefined */ };

template <typename RetT, typename... ArgsT>
class Callback<RetT(ArgsT...)> {
private:
    typedef std::function<RetT(ArgsT...)> func_type;
    typedef std::vector<func_type> container_type;

    container_type  callback_container;

public:    
    void submit(func_type func) {
        callback_container.push_back(std::move(func));
    }

    template <typename... Args>
    std::vector<RetT> invoke(Args&&... args) {
        std::vector<RetT> ret_vec;
        for (auto& ele : callback_container)
            ret_vec.push_back(ele(std::forward<Args>(args)...));

        return ret_vec;
    }
};


int foo_1(const int&) { std::cout << "foo_1\n"; return 0; }
float foo_2(const double&) { std::cout << "foo_2\n"; return 0.f; }

int main() {
    Callback<int(int)> callback_ii;
    callback_ii.submit(foo_1);
    
    Callback<float(double)> callback_fd;
    callback_fd.submit(foo_2);

    callback_ii.invoke(1);
    callback_fd.invoke(1.);
}

std::function도 상당히 재미난 놈이라 시간이 난다면 정리해보고 싶다


Expansion

그러면 이놈은 위 예제처럼 묶인 상태에서만 써야하는 것일까
그렇지않다, 우리는 이놈을 풀 수 있다
약간의 두뇌회전을 한다면 아래와 같이 풀어낼 수 있다

#include <iostream>

template <typename... Ts>
struct pack_expansion {
    template <typename _1, typename... _Ts>
    struct pack_expansion_impl : pack_expansion_impl<_Ts...> {
        pack_expansion_impl() {
            std::cout << typeid(_1).name() << " ";
        }
    };

    template <typename T>
    struct pack_expansion_impl<T> {
        pack_expansion_impl() {
            std::cout << typeid(T).name() << " ";
        }
    };

    pack_expansion_impl<Ts...> expansion;
};

int main() {
    pack_expansion<int, float, double> p;
}

// Output
d f i

Recursive하게 상속을 받음으로써 하나씩 type을 가져오는 방식이다
이때 Base class의 Constructor가 먼저 호출되기에 역순으로 type이 출력되었다

위 Code에서 한가지 이상한점을 느낄수도 있다
분명 impl부분에서 parameter pack 앞에 parameter가 하나더 존재한다
그런데 우리는 parameter pack만 넘겨주었다
이 부분에 대한 해답을 찾기 위해서는 CppReference를 봐야한다

생략부호(...)가 포함된 pattern의 경우 0 이상의 comma로 구분된 인스턴스화들로 확장되며, parameter pack의 이름은 순서가 지켜진 pack의 element들로 대체된다

이게 맞는 해석인건가..

굉장히 어렵게 기술되어있는데 이걸 쪼개면 다음과 같은 특성이 보인다

  • 생략부호(...)가 포함된 pattern은 comma로 구분되어 확장된다
  • parameter pack의 이름은 pack의 element들로 대체된다
  • 순서가 지켜진다

쉽게 말해 parameter pack은 우리가 입력했던 내용으로 대체된다는 소리다
이게 무슨 소리냐, 아래 예제를 보도록하자
만약 3개의 type을 parameter pack에 넣는다고 생각해보자

template <typename... Ts>
struct foo {
    //...
};

int main() {
    foo<int, float, double> f;
}

여기서 typename... Ts 부분이 int, float, double로 대체된다는 소리다
다시 돌아와 처음 예제를 생각해보도록 하자

  1. typename... Ts 부분이 int, float, double로 대체된다
  2. int, float, double 부분이 typename _1, typename... _Ts에 들어간다
  3. template argument _1int가 들어간다
  4. typename... _Tsfloat, double이 들어간다

벌써 첫번째 template argument를 얻었다
이러한 방식을 template argument가 하나 남을때까지 반복한다
하나만 남은 경우에는 더 이상 상속을 받으면 안되기에 template specialization을 통해 구분해준다
이렇게 모든 type을 얻을 수 있다

그렇다면 function에도 동일한 방법을 사용할 수 있지 않을까
물론 가능하다

#include <iostream>

template <typename T>
void foo(T arg) {
    std::cout << typeid(T).name() << std::endl;
}

template <typename _1, typename... Ts>
void foo(_1 arg, Ts... args) {
    std::cout << typeid(arg).name() << " ";
    foo<Ts...>(args...);
}

int main() {
    foo<int, float, double>(0, 0.f, 0.);
}

// Output
i f d

이번에는 출력을 먼저하고 다음 재귀문을 호출함으로써 정방향으로 출력할 수 있었다

Expansion Example

지금까지 열심히 예시를 들다가 안들면 좀 어색하니 이놈도 예시를 하나 생각해봤다

#include <iostream>

template <int _1, int... Args>
struct add {
    static const int value = _1 + add<Args...>::value;
};

template <int _1>
struct add<_1> {
    static const int value = _1;
};

int main() {
    std::cout <<
        add<1>::value << ' ' <<
        add<1, 2>::value << ' ' <<
        add<1, 2, 3>::value << std::endl;
}
1 3 6

Template Parameter의 개수에 상관없이 다 더해주는 함수이다
어쩌면 가장 쉬운 예시하록 할 수 있을 것이다
그런데 저게 과연 효율적인 코드라고 생각하는가
무엇인가 좀더 간지나는 일을 할 수 있지 않을까

Checking Types

Template의 근본을 다시 상기해보도록하자
원래 Template은 Type과 관련된 일을 하던 놈이다
그러면 여러 Type들을 받고 그 Type들을 검사하는 역할을 할 수 있지 않을까
그래서 또 다른 예시를 만들어봤다

#include <type_traits>

template <typename _1, typename... Args>
struct is_all_integral {
    static const bool value = 
        std::is_integral<_1>::value &&
        is_all_integral<Args...>::value;
};

template <typename _1>
struct is_all_integral<_1> : std::is_integral<_1> {};

넘겨받은 Type이 모두 Integral인가를 판단하는 간단한 TMP 함수다
뭐가 많이 붙어서 그렇지 원리 자체는 위와 동일하다
그냥 expansion으로 하나씩 꺼내보면서 Integral인지 checking하는 것이다
그래서 이걸 어떻게 써먹으란 것일까

template <
    typename... Args,
    typename = typename std::enable_if_t
        <is_all_integral<Args...>::value>
>
int add_integral(Args... args) {
    //...
}

int main() {
    int _1 = add_integral<int, int, int>(1, 2, 3);
    //int _2 = add_integral<int, int, float>(1, 2, 3.f);    ERROR!
}

type checking으로 들어가게 되면 TMP가 어쩔수 없이 붙게 되어 코드가 좀 많이 이상해졌다
그래도 위 코드를 해석하는데 필요한건 단 하나다
std::enable_if만 알면 된다
이놈은 별건 아니고 그냥 스위치 같은 녀석이다
원래는 조건을 만족하면 특정 Type을 반환해주는 놈인데..
이걸 그냥 위처럼 쓰면 조건이 True일때만 활성해준다

그래서 이 스위치에 위에서 작성했던 is_all_integral을 넣으면 되는 것이다
그러면 _1은 모두 int이므로 성공하지만 _2는 float가 있어 실패한다
이렇게 사용할 경우 integral type만 parameter로 받을 수 있다
그리고 그렇지 않을 경우, compile time에 ERROR를 뱉어낸다


Constraints

그렇다면 점 세개만 붙이면 무슨 마법마냥 모든게 해결되는 것일까
아쉽지만 그렇지만은 않다
아래의 예를 생각해보자

template <typename... Ts, typename T>
struct foo {
    //...
};

int main() {
    foo<int, char, double> f;
}

Parameter pack 뒤에 다른 parameter가 붙는 경우다
이 경우 컴파일러는 어떻게 처리할까

<source>:19:23: error: template parameter pack must be the last template parameter
template <typename... Ts, typename T>
                      ^
1 error generated.
ASM generation compiler returned: 1
<source>:19:23: error: template parameter pack must be the last template parameter
template <typename... Ts, typename T>
                      ^
1 error generated.
Execution build compiler returned: 1

그냥 오류다
clang이 아주 친절하게 설명해주듯, parameter pack은 가장 뒤에 와야한다
그렇다면 type template parameter인 parameter pack뒤에 non-type template parameter가 오면 어떻게 될까

template <typename... Ts, int T>
struct foo {
    //...
};

int main() {
    foo<int, char, 1> f;
}

// Output
<source>:19:23: error: template parameter pack must be the last template parameter
template <typename... Ts, int T>
                      ^
<source>:25:20: error: template argument for template type parameter must be a type
    foo<int, char, 1> f;
                   ^
<source>:19:23: note: template parameter is declared here
template <typename... Ts, int T>
                      ^
2 errors generated.

왠지 다르니까 해줘도 될것 같지만 안된다
물론 그 반대도 마찬가지로 안된다
마지막으로 template template도 안된다

template <typename T>
struct bar {
    //...
};

template <typename... Ts, template <typename> typename T>
struct foo {
    //...
};

int main() {
    foo<int, char, bar> f;
}

// Output
<source>:24:23: error: template parameter pack must be the last template parameter
template <typename... Ts, template <typename> typename T>
                      ^
<source>:30:20: error: use of class template 'bar' requires template arguments
    foo<int, char, bar> f;
                   ^
<source>:20:8: note: template is declared here
struct bar {
       ^
2 errors generated.

parameter pack이 등장하기 전까지는 template parameter 개수가 다르면 다 다르게 적어야했다
뭐 대표적으로 std::functionstd::unary_functionstd::binary function만 존재했었다
그러니까 parameter의 개수를 한개 혹은 두개로 제한이 되어버린 것이다
그런데 c++11에서 이놈이 나오면서 이야기가 많이 달라진다

std::function, std::invoke, std::tuple 등등 정말 많은것들이 cpp에서 가능해졌다
물론 modern cpp에 어려움 한 스푼 추가한 범인이기도 하다
문제는 특정 상황에서 궁극의 generic programming을 위해서는 필수적으로 사용되어야만 한다
위에서 말했던 callback example도 그런 경우이지 않을까 싶다

이제 언어가 고일대로 고여버려서 이놈을 무시할수도 없게 되었다
생각보다 많은 곳에서 상당히 중요한 역할을 차지하고 있으니 말이다
그래도 쓰다보면 나름 재미난 놈이기도 하다

profile
하고싶은거 하는 사람

0개의 댓글