본문 바로가기
프로그래밍/C & C++

[C++] Variadic Templates (가변 템플릿)

by 별준 2022. 12. 31.

References

  • ch4, C++ Template The Complete Guide

Contents

  • Variadic Templates
  • Fold Expressions
  • Application of Variadic Templates
  • Variadic Class Templates and Variadic Expressions

C++11부터 템플릿에서도 가변 템플릿 인자를 받을 수 있는 파라미터를 사용할 수 있습니다. 임의의 타입을 갖는 임의의 수의 인자를 전달할 때 사용할 수 있습니다.

 

Variadic Templates

정해지지 않은 수의 템플릿 인자를 템플릿 파라미터로 정의할 수 있는데, 이와 같은 기능의 템플릿을 가변 템플릿(variadic templates)라고 부릅니다.

 

Example

예를 들어, 서로 다른 타입의 가변 인자를 받아 호출되는 print()를 아래와 같이 정의하고 호출할 수 있습니다.

#include <iostream>

void print()
{
}

template<typename T, typename... Types>
void print(T firstArg, Types... args)
{
    std::cout << firstArg << "\n"; // print first argument
    print(args...);                // call print() for remaining arguments
}

하나 이상의 인자를 전달받으면 첫 번째 인자만 따로 명시하여 해당 인자만 출력한 뒤, 나머지 인자들은 재귀적으로 print() 함수를 호출합니다. 이때, 나머지 인자들을 나타내는 args는 함수 파라미터 팩(function parameter pack) 입니다.

 

재귀를 끝내기 위해서는 템플릿이 아닌 print()의 오버로딩이 필요합니다. 이는 함수 파라미터 팩이 비었을 때만 사용됩니다.

std::string s("world");
print(7.5, "hello", s);

위와 같은 코드를 실행하면 다음와 같이 출력됩니다.

 

위의 코드에서 print() 호출이 어떻게 확장되는지 조금 자세히 살펴보겠습니다.

처음에는 아래와 같이 확장됩니다.

print<double, char const*, std::string>(7.5, "hello", s);

첫 번째 인자인 firstArg가 7.5이므로 타입 T는 double입니다. 두 번째 인자, 즉, args는 char const* 타입인 "hello"와 std::string 타입인 "world"라는 값을 갖는 가변 템플릿 인자를 나타냅니다.

 

firstArg인 7.5를 출력한 후, 나머지 인자에 대해 print()를 다시 호출하면, 이번에는 아래와 같이 확장됩니다.

print<char const*, std::string>("hello", s);

이번에는 firstArg의 값은 "hello"이며, 타입 T는 char const* 입니다. args는 std::string 타입인 값을 갖는 가변 템플릿 인자입니다.

 

firstArg로 "hello"를 출력한 후, 다시 나머지 인자에 대해 print()를 호출합니다. 이번에는 다음과 같이 확장됩니다.

print<std::string>(s);

여기서 firstArg 값은 "world"이며, 타입 T는 std::string 입니다. args는 이제 아무 값도 갖지 않는 빈 가변 템플릿 인자입니다. 따라서, firstArgs로 "world"를 출력한 뒤, 인자 없이 print()를 호출하게 되는데, 템플릿이 아닌 오버로딩 버전인 print()가 호출되고 구현에 의해 아무 동작도 수행하지 않습니다.

 

Overloading Variadic and Nonvariadic Templates

위의 구현을 아래와 같이 구현할 수도 있습니다.

#include <iostream>

template<typename T>
void print(T arg)
{
    std::cout << arg << "\n";
}

template<typename T, typename... Types>
void print(T firstArg, Types... args)
{
    print(firstArg); // call print() for the first argument
    print(args...);  // call print() for remaining arguments
}

위에서 정의한 print()는 함수 파라미터 팩이 있느냐 없느냐만 다른데, 뒤에 파라미터 팩이 없는 함수 템플릿이 선호됩니다.

 

Operator sizeof...

C++11부터는 가변 인자 템플릿에 대한 새로운 형태의 sizeof 연산자인 sizeof...를 사용할 수 있습니다. 이 연산자는 파라미터 팩이 가진 요소의 수를 반환합니다. 따라서, 아래의 print()는 전달된 첫 번째 인자 뒤에 있는 나머지 인자들이 몇 개인지 출력합니다.

template<typename T, typename... Types>
void print(T firstArg, Types... args)
{
    std::cout << sizeof...(Types) << "\n"; // print number of remaining types
    std::cout << sizeof...(args) << "\n";  // print number of remaining args
    ...
}

여기서 볼 수 있듯이 템플릿 파라미터 팩이든 함수 파라미터 팩이든 어디에서나 sizeof...를 사용할 수 있습니다.

 

이를 활용하면 인자가 남아 있지 않았을 때 재귀 호출을 중지하기 위한 함수를 호출하지 않고도 재귀를 끝낼 수 있지 않을까라고 생각할 수 있습니다. 예를 들면, 다음과 같이 구현하면 재귀가 알아서 끝날 것이라고 예상할 수 있습니다.

template<typename T, typename... Types>
void print(T firstArg, Types... args)
{
    std::cout << firstArg << "\n";
    if (sizeof...(args) > 0) { // error if sizeof...(args) == 0
        print(args...);        // and no print() for no arguments declared
    }
}

하지만 실제로 구현해서 컴파일해보면 에러가 발생합니다.

이는 일반적으로 함수 템플릿 내의 모든 if문의 양쪽 브랜치가 인스턴스화되기 때문입니다. 인스턴스화한 코드가 실제 실행 때 쓰이든 쓰이지 않든 호출에 대한 인스턴스화 자체는 컴파일 과정에 결정됩니다. 따라서, 인자가 하나 남았을 때 print()를 호출하더라도 여전히 print(args...)도 인자가 없이 인스턴스화됩니다. 그렇기 때문에 인자가 없는 경우에 대한 print() 함수를 제공하지 않으면 에러가 발생합니다.

 

하지만, C++17에서부터는 compile-time if를 사용할 수 있으므로, 약간 다른 문법을 사용하여 원하는대로 동작시킬 수 있습니다. 다른 포스팅에서 다루겠지만, 예를 들면, 아래와 같이 구현하면 에러없이 컴파일되고 정상적으로 동작합니다.

template<typename T, typename... Types>
void print(T firstArg, Types... args)
{
    std::cout << firstArg << "\n";
    if constexpr (sizeof...(args) > 0) { // error if sizeof...(args) == 0
        print(args...);        // and no print() for no arguments declared
    }
}

 


Fold Expressions

C++17에서부터는 파라미터 팩(parameter pack)의 모든 인자를 대상으로하는 이항 연산자(binary operator)를 지원합니다. 예를 들어, 아래의 함수는 전달된 인자들의 합을 반환합니다.

template<typename... T>
auto foldSum(T... s)
{
    return (... + s); // ((s1 + s2) + s3) ...
}

 

이때, 파라미터 팩이 비어있다면, 표현식은 보통 잘못 구성됩니다. 단, 빈 파라미터 팩에 대해 && 연산자를 적용하면 true로 계산되고, || 연산자를 적용하면 false로 계산되며, ',' 연산자를 적용하면 void()라는 것은 예외입니다.

 

아래 표는 가능한 폴드 표현식(fold expressions)을 보여줍니다.

fold expressions (since C++17)

 

거의 대부분의 이항 연산자는 폴드 표현식을 사용할 수 있습니다. 예를 들어, 이진 트리의 경로를 탐색하고 싶다면 폴드 표현식에 ->* 연산자를 사용하여 구현할 수 있습니다.

struct Node {
    int value;
    Node* left;
    Node* right;
    Node(int i = 0) : value(i), left(nullptr), right(nullptr) {}
    // ...
};
auto left = &Node::left;
auto right = &Node::right;

template<typename T, typename... TP>
Node* traverse(T np, TP... paths)
{
    return (np ->* ... ->* paths); // np ->* paths1 ->* paths2 ...
}

int main()
{
    // init binary tree structure
    Node* root = new Node{0};
    root->left = new Node{1};
    root->left->right = new Node{2};
    ...
    // traverse binary tree
    Node* node = travers(root, left, right);
}

위 코드에서 (np ->* ... ->* paths)는 np에서부터 시작해 paths의 가변 요소들을 탐색하는 폴드 표현식입니다.

 

초기화자(initializer)를 사용하는 폴드 표현식을 사용하면, 앞서 봤던 모든 요소를 출력하는 가변 인자 템플릿을 아래와 같이 더욱 간단하게 구현할 수 있습니다.

template<typename... Types>
void print(Types const&... args)
{
    (std::cout << ... << args) << "\n";
}

단, 위와 같이 구현하면 파라미터 팩의 각 요소를 분리하는 공백 문자를 넣을 수 없습니다. 공백을 넣고 싶다면 어떤 인자가 들어오든 공백을 넣는 새로운 클래스 템플릿이 필요합니다. 예를 들면, 다음과 같이 구현할 수 있습니다.

template<typename T>
class AddSpace
{
private:
    T const& ref;

public:
    AddSpace(T const& r) : ref(r) {}
    friend std::ostream& operator<<(std::ostream& os, AddSpace<T> s) {
        return os << s.ref << " "; // output passed argument and a space
    }
};

template<typename... Types>
void print(Types const&... args)
{
    (std::cout << ... << AddSpace(args)) << "\n";
}

 

폴드 표현식 또한 ch14에서 조금 더 자세히 설명하고 있습니다.

 


Application of Variadic Templates

가변 템플릿은 C++ 표준 라이브러리와 같은 제너릭 라이브러리를 구현할 때 아주 중요한 역할을 합니다. 전형적으로 임의의 타입을 갖는 가변 인자를 전달하는데 많이 사용되는데, 예를 들어, 아래와 같은 상황에서 이를 적용할 수 있습니다.

  • 공유 포인터가 소유하는 새로운 힙 객체의 생성자에 인자를 전달
auto sp = std::make_shared<std::complex<float>>(4.2, 7.7);
  • 스레드의 인자를 전달
std::thread t(foo, 42, "hello"); // call foo(42, "hello") in a seprate thread
  • 벡터로 푸쉬할 새로운 요소의 생성자의 인자를 전달
std::vector<Customer> v;
...
v.emplace_back("Tim", "Jovi", 1962); // insert a Customer initialized by three arguments

 

보통, 인자들은 move semantics를 사용하여 "perfect forwarding" 됩니다. 따라서, 아래 예제 코드처럼 선언해주는 것이 좋습니다.

namespace std {
    template<typename T, typename... Args>
    shared_ptr<T> make_shared(Args&&... args);
    
    class thread {
    public:
        template<typename F, typename... Args>
        explicit thread(F&& f, Args&&... args);
        ...
    };
    
    template<typename T, typename Allocator = allocator<T>>
    class Vector {
    public:
        template<typename... Args>
        reference emplace_back(Args&&... args);
        ...
    };
};

 

덧붙여 템플릿 파라미터에 대해서도 일반 파라미터와 동일한 규칙이 적용됩니다. 예를 들어, 값으로 전달했다면 인자를 복사하며 형 소실(decay)도 일어납니다. 예를 들어, 배열로 전달되면 포인터로 변환됩니다. 반대로 참조자로 전달했다면 파라미터는 원래 파라미터 자체를 가리키며 형 소실도 발생하지 않습니다.

// args are copies with decayed types
template<typename... Args> void foo(Args... args);
// args are nondecayed references to passed objects
template<typename... Args> void bar(Args const&... args);

 


Variadic Class Templates and Variadic Expressions

다양한 상황에서 파라미터 팩을 사용할 수 있는데, 예를 들어, 표현식, 클래스 템플릿, using, 심지어 추론 가이드(deduction guide)에도 사용할 수 있습니다.

 

Variadic Expressions

파라미터를 전달하는 것 이외에도 다양한 것들을 할 수 있는데, 파라미터 팩에 있는 모든 파라미터를 대상으로 연산을 할 수도 있습니다. 예를 들어, 아래의 함수는 파라미터 팩 args의 각 파라미터를 두 배하여 print()로 전달합니다.

template<typename... T>
void printDoubled(T const&... args)
{
    print(args + args...);
}

따라서 아래와 같이 printDoubled를 호출하면,

printDoubled(7.5, std::string("hello"), std::complex<float>(4, 2));

이 함수의 효과는 아래와 같습니다.

print(7.5 + 7.5,
      std::string("hello") + std::string("hello"),
      std::complex<float>(4, 2) + std::complex<float>(4, 2));

 

참고로 각 요소에 1을 더하고 싶다면, 숫자 바로 다음에 '...'이 오지 않도록 주의해야 합니다.

template<typename... T>
void addOne(T const&... args)
{
    print(args + 1...);   // ERROR: 1... is a literal with too many decimal points
    print(args + 1 ...);  // OK
    print((args + 1)...); // OK
}

 

컴파일 시간 표현식에도 템플릿 파라미터 팩을 포함할 수 있습니다. 예를 들어, 아래 함수 템플릿은 모든 인자의 타입이 같은지 여부를 리턴합니다.

template<typename T1, typename... TN>
constexpr bool isHomogeneous(T1, TN...)
{
    return (std::is_same<T1, TN>::value && ...); // since C++17
}

위 코드는 폴드 표현식(Fold Expressions)를 응용한 것 입니다.

만약 아래와 같이 호출한다면,

isHomogeneous(43, -1, "hello");

이 함수는 아래와 같이 확장됩니다.

std::is_same<int,int>::value && std::is_same<int,char const*>::value

따라서 false가 리턴됩니다.

 

Variadic Indices

또 다른 예로 인덱스들로 이루어진 가변 목록을 받아 전달받은 첫 번째 인자의 요소에 액세스하는 함수를 구현할 수 있습니다.

template<typename C, typename... Idx>
void printElems(C const& coll, Idx... idx)
{
    print(coll[idx]...);
}

만약, 아래와 같이 호출하면,

std::vector<std::string> coll = {"goold", "times", "say", "bye"};
printElems(coll, 2, 0, 3);

이는 아래의 코드를 호출한 것과 동일합니다.

print(coll[2]. coll[0], coll[3]);

 

타입이 아닌 템플릿 파라미터도 파라미터 팩으로 선언할 수 있습니다.

template<std::size_t... Idx, typename C>
void printIdx(C const& coll)
{
    print(coll[Idx]...);
}

위의 결과와 동일한 결과를 얻으려면 아래와 같이 사용하면 됩니다.

std::vector<std::string> coll = {"goold", "times", "say", "bye"};
printIdx<2,0,3>(coll);

 

Variadic Class Templates

가변 템플릿은 클래스 템플릿에도 사용할 수 있습니다. 예를 들면, 템플릿 파라미터로 멤버의 타입을 몇 개라도 명시할 수 있는 클래스가 있습니다 (std::tuple).

 

또 다른 예로는 가변 템플릿 파라미터로 자신이 가질 수 있는 객체의 타입을 명시하는 방법도 있습니다 (std::variant).

 

인덱스 리스트를 나타내는 타입으로서의 클래스를 정의할 수도 있습니다.

// type for arbitrary number of indices
template<std::size_t...>
struct Indices {
};

std::array나 std::tuple의 요소에 대해 print()를 호출하는 함수를 정의할 때 이와 같은 클래스를 사용할 수 있습니다. 이를 사용하면 컴파일 중에 주어진 인덱스를 get<>()을 사용하여 액세스할 수 있습니다.

template<typename T, std::size_t... Idx>
void printByIdx(T t, Indices<Idx...>)
{
    print(std::get<Idx>(t)...);
}

위 템플릿은 아래와 같이 사용할 수 있습니다.

std::array<std::string, 5> arr = {"Hello", "my", "new", "!", "World"};
printByIdx(arr, Indices<0, 4, 3>());

이런 식의 방법이 메타프로그래밍에 해당합니다.

 

Variadic Deduction Guides

추론 가이드 또한 가변적일 수 있습니다. 예를 들어, C++ 표준 라이브러리에서 std::array에 대한 추론 가이드를 다음과 같이 정의합니다.

따라서, 아래와 같이 초기화하는 경우

std::array a{ 42, 45, 77 };

요소의 타입에 대한 가이드에서 _Tp를 추론하고, 뒤에 따라오는 요소의 타입에 대해서는 가변적인 _Up... 타입을 추론합니다. 따라서 요소의 총 갯수는 1 + sizeof...(_Up) 가 됩니다. 가이드 내에 있는 is_same_v 표현식은 위에서 정의한 isHomogeneous()와 유사한 폴드 표현식 입니다.

 

Variadic Base Classes and using

마지막으로 아래 예제 코드를 살펴보겠습니다.

#include <string>
#include <unordered_set>

class Customer
{
private:
    std::string name;

public:
    Customer(std::string const& n) : name(n) {}
    std::string getName() const { return name; }
};

struct CustomerEq {
    bool operator()(Customer const& c1, Customer const& c2) const {
        return c1.getName() == c2.getName();
    }
};

struct CustomerHash {
    std::size_t operator()(Customer const& c) const {
        return std::hash<std::string>()(c.getName());
    }
};

// define class that combines operator() for variadic base classes
template<typename... Bases>
struct Overloader : Bases...
{
    using Bases::operator()...; // OK since C++17
};

int main()
{
    // combine hasher and equality for customers in one type
    using CustomerOP = Overloader<CustomerHash, CustomerEq>;

    std::unordered_set<Customer, CustomerHash, CustomerEq> coll1;
    std::unordered_set<Customer, CustomerOP, CustomerOP> coll2;
    ...
}

위 코드에서 임의의 갯수의 베이스 클래스로부터 상속받는 클래스를 정의할 수 있습니다. 그리고, 각 베이스 클래스의 operator() 연산을 사용하도록 using을 사용해줍니다. 이런 코드를 사용하면 CustomerHash와 CustomerEq에서 CustomerOP를 상속시킬 수 있으며, 상속받은 클래스 내에서 두 베이스 클래스의 operator() 구현을 사용할 수 있습니다.

이 기법에 대한 자세한 내용은 ch 26.4에서 예제를 통해 설명하고 있으니 참조바랍니다.

'프로그래밍 > C & C++' 카테고리의 다른 글

[C++] Value Categories (값 카테고리)  (0) 2023.01.03
[C++] 템플릿에서 이동의미론과 enable_if<>  (0) 2023.01.02
[C++] Nontype Template Parameters  (0) 2022.12.30
[C++] Class Templates  (0) 2022.12.29
[C++] Function Templates  (0) 2022.12.28

댓글