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

[C++] 템플릿 특수화(Specialization)

by 별준 2023. 1. 15.

References

  • Ch16, C++ Templates The Complete Guide

Contents

  • Template Specialization
  • Overloading Function Templates
  • Explicit Specialization
  • Partial Class Template Specialization
  • Partial Variable Template Specialization

C++ 템플릿을 사용하여 제너릭한 정의를 사용하여 관련된 클래스, 함수, 또는 변수로 확장할 수 있습니다. 이것만으로도 강력하지만 특정 템플릿 파라미터에서는 제너릭 형태의 연산이 최적이 아닐 수 있습니다. 제너릭 프로그래밍을 지원하는 다른 프로그래밍 언어와 달리 C++에서는 제너릭 정의를 좀 더 특수화된 기능으로 바꾸는 기능들을 가지고 있습니다. 이번 포스팅에서는 순수 제너릭에서 벗어나는 템플릿 특수화(specialization)와 템플릿 오버로딩에 대해서 살펴보겠습니다.

 


제너릭 코드가 잘 맞지 않을 때

아래 예제 코드를 살펴보겠습니다.

template<typename T>
class Array {
private:
    T* data;
    ...
public:
    Array(Array<T> const&);
    Array<T>& operator=(Array<T> const&);

    void exchangeWith(Array<T>* b) {
        T* tmp = data;
        data = b->data;
        b->data = tmp;
    }
    T& operator[](std::size_t k) {
        return data[k];
    }
    ...
};

template<typename T> inline
void exchange(T* a, T* b)
{
    T tmp(*a);
    *a = *b;
    *b = tmp;
}

간단한 타입에 대해서는 exchange()의 제너릭 구현이 잘 동작합니다. 하지만 복사 연산의 비용이 큰 타입이라면 이러한 구현을 사용할 때 새로이 해당 타입에 맞춰 구현하는 것보다 동작 시간뿐만 아니라 메모리 사용에서의 비용이 훨씬 클 것입니다. 위 예제 코드에서 제너릭 구현은 Array<T>의 복사 생성자를 한 번 호출하고, 복사 할당 연산자를 두 번 호출합니다. 만약 아주 큰 데이터 구조라면 상당히 많은 양의 메모리를 복사하게 됩니다. 그러나 exchange()의 기능은 대개 내부 data 포인터를 바꿔주는 것만으로 대체하는 exchangeWith()라는 멤버 함수로 충분히 구현할 수 있습니다.

 

Transparent Customization

위 예제 코드에서 exchange() 대신 exchangeWith() 함수를 쓰면 더 효율적이지만, 이처럼 다른 함수를 사용하면 아래와 같은 불편함이 발생합니다.

  • Array 클래스를 사용하는 유저는 부가적인 인터페이스를 기억해야 하고, 가능한 이를 사용하도록 주의를 기울여야 합니다.
  • 제너릭 알고리즘은 일반적으로 다양한 가능성들을 분간할 수 없습니다 (아래 코드 참조).
template<typename T>
void genericAlgorithm(T* x, T* y)
{
    ...
    exchange(x, y); // How do we select the right algorithm ?
    ...
}

위와 같은 이유 때문에 C++ 템플릿은 함수 템플릿과 클래스 템플릿을 보이지 않게 커스터마이즈할 수 있습니다. 함수 템플릿에서는 오버로딩 메커니즘을 사용하여 이를 달성할 수 있습니다. 예를 들어, quickExchange() 함수 템플릿의 오버로딩 함수들을 다음과 같이 작성할 수 있습니다.

template<typename T>
void quickExchange(T* a, T* b) // #1
{
    T tmp(*a);
    *a = *b;
    *b = tmp;
}

template<typename T>
void quickExchange(Array<T>* a, Array<T>* b) // #2
{
    a->exchangeWith(b);
}

void demo(Array<int>* a, Array<int>* b)
{
    int x = 42, y = -7;
    quickExchange(&x, &y); // uses #1
    quickExchange(p1, p2); // uses #2
}

demo() 함수에서 첫 번째 quickExchange() 호출은 int* 타입의 두 인자를 가지고 있기 때문에 #1에서 선언된 첫 번째 템플릿으로 추론됩니다. T는 int로 치환됩니다. 이와 달리 두 번째 호출은 두 템플릿 모두에서 일치합니다. 첫 번째 템플릿에서 T를 Array<int>로 치환함 함수나 두 번째 템플릿에서 T를 int로 치환한 함수 모두 호출 가능합니다. 뿐만 아니라 두 치환 모두 두 번째 호출의 인자 타입과 정확히 일치하는 파라미터 타입을 갖는 함수를 인스턴스화합니다. 일반적이라면 모호한 호출이라고 하겠지만, C++ 언어에서는 두 번째 템플릿이 '좀 더 특수화'되었다고 간주합니다. 다른 조건이 모두 동일하다면 오버로딩은 좀 더 특수화된 템플릿을 선호하기 때문에 #2에서 선언된 템플릿을 선택하게 됩니다.

 

Semantic Transparency

위에서 본 오버로딩의 사용은 인스턴스화 과정에서 transparent customization을 달성하기에 유용하지만, 이는 구현에 매우 종속되어 있다는 것을 알고 있어야 합니다. 이를 설명하기 위해서 quickExchange()의 솔루션을 생각해보겠습니다. 제너릭 알고리즘과 Array<T> 타입에 커스터마이즈된 것은 모두 가리키고 있는 값을 서로 바꾸는 것뿐이지만 연산의 사이드 이펙트는 매우 다릅니다. Array<T>의 교환과 구조체 객체의 교환을 비교해보면 확연히 드러납니다.

struct S {
    int x;
} s1, s2;

void distinguish(Array<int> a1, Array<int> a2)
{
    int* p = &a1[0];
    int* q = &s1.x;
    a1[0] = s1.x = 1;
    a2[0] = s2.x = 2;
    quickExchange(&a1, &a2); // *p == 1 after this (still)
    quickExchange(&s1, &s2); // *q == 2 after this
}

위 예제 코드에서 첫 번째 quickExchange()가 호출된 후, a1에 대한 포인터 p가 a2에 대한 포인터로 바뀌는 것을 보여줍니다. 하지만 s1에 대한 포인터는 교환 연산을 수행한 뒤에도 여전히 s1을 가리키며, 가리키고 있는 값들만 교환되었습니다. 이 둘의 차이는 템플릿 구현을 사용하는 이들에 혼란을 줄 만큼 어마어마합니다. quick_이라는 접두사룰 붙여서 원하는 연산을 위한 shortcut이라는 사실을 알릴 수도 있지만, 원래 일반 exchange() 템플릿도 Array<T>에 대한 최적화를 제공할 수 있습니다.

template<typename T>
void exchange(Array<T>* a, Array<T>* b)
{
    T* p = &(*a)[0];
    T* q = &(*b)[0];
    for (std::size_t k = a->size(); k-- != 0; ) {
        exchange(p++, q++);
    }
}

위 exchange의 버전에서 얻을 수 있는 큰 장점은 큰 사이즈의 임시 객체 Array<T>가 필요하지 않다는 것 입니다. exchange() 템플릿은 재귀적으로 호출되어 Array<Array<char>>과 같은 타입에서도 좋은 성능을 보여줄 수 있습니다.

 


Overloading Function Templates

위에서 살펴봤듯이 이름이 같은 두 함수 템플릿이 공존할 수 있으며, 이들이 인스턴스화되었을 때 동일한 파라미터 타입을 가질 수도 있습니다. 이를 보여주는 간단한 예제 하나를 더 살펴보겠습니다.

template<typename T>
int f(T)
{
    return 1;
}

template<typename T>
int f(T*)
{
    return 2;
}

첫 번째 템플릿에서 T를 int*로 치환하면 두 번째 템플릿에서 T를 int로 치환했을 때 얻을 수 있는 함수와 완전히 동일한 함수를 얻습니다. 이러한 템플릿들도 공존할 수 있을 뿐만 아니라, 이들이 동일한 파라미터와 리턴 타입을 갖는다고 하더라도 각각의 인스턴스도 공존할 수 있습니다.

 

아래는 위의 두 템플릿 함수가 명시적 템플릿 인자 문법을 이용하여 어떻게 호출되는지 보여줍니다.

#include <iostream>

int main()
{
    std::cout << f<int*>((int*)nullptr) << "\n"; // call f<T>(T)
    std::cout << f<int>((int*)nullptr) << "\n";  // call f<T>(T*)
}

 

Signatures

두 함수가 서로 다른 시그니처(파라미터 리스트)를 가진다면, 한 프로그램 내에서 서로 공존할 수 있습니다. 함수의 시그니처는 아래의 정보를 통합하여 정의됩니다.

  • 함수의 한정되지 않은 이름 (또는 함수를 생성하는 함수 템플릿의 이름)
  • 해당 이름의 클래스 또는 네임스페이스 영역과 만약 그 이름이 internal linkage를 갖는다면, 그 이름이 선언된 번역 단위(TC)
  • 함수의 const, volatile, 또는 const volatile 한정자
  • 함수의 & 또는 && 한정
  • 함수 파라미터의 타입 (함수 템플릿에서 생성된 함수라면 템플릿 파라미터가 치환되기 전)
  • 만약 함수가 함수 템플릿으로부터 생성되었다면 리턴 타입
  • 만약 함수가 함수 템플릿으로부터 생성되었다면 템플릿 파라미터와 템플릿 인자

따라서, 아래의 템플릿과 이들의 인스턴스화는 원칙상 한 프로그램 내에서 공존할 수 있습니다.

template<typename T1, typename T2>
void f1(T1, T2);

template<typename T1, typename T2>
void f1(T2, T1);

template<typename T>
long f2(T);

template<typename T>
char f2(T);

그러나 위 템플릿들을 인스턴스화하면 오버로딩이 모호해지기 때문에 같은 스코프에서 선언되었을 때는 항상 사용할 수는 없습니다. 예를 들어, 위와 같이 템플릿이 선언되어 있을 때 f2(42)를 호출하면 어떤 함수를 사용할 지 모호합니다.

f(42) 호출 시 발생하는 경고/에러

아래의 또 다른 예제를 살펴보겠습니다.

#include <iostream>
template<typename T1, typename T2>
void f1(T1, T2) {
    std::cout << "f1(T1, T2)\n";
}

template<typename T1, typename T2>
void f1(T2, T1) {
    std::cout << "f1(T2, T1)\n";
}
// fine so far

int main()
{
    f1<char, char>('a', 'b'); // ERROR: ambiguous
}

위 코드에서 아래 함수는

f1<T1 = char, T2 = char>(T1, T2)

아래의 함수와 공존할 수 있지만, 오버로딩을 해석할 때 둘 중 어느 것을 더 선호할 수는 없습니다.

f1<T1 = char, T2 = char>(T2, T1)

만약 템플릿이 서로 다른 번역 단위에 있었다면 두 인스턴스화는 같은 프로그램 내에서 공존할 수 있습니다. 또한, 링커는 이들 인스턴스의 시그니처가 다르기 때문에 중복된 정의라고 에러를 발생시키지도 않습니다.

// translation unit 1:
#include <iostream>
template<typename T1, typename T2>
void f1(T1, T2) {
    std::cout << "f1(T1, T2)\n";
}

void g() {
    f1<char, char>('a', 'b');
}

// translation unit 2
#include <iostream>
template<typename T1, typename T2>
void f1(T2, T1) {
    std::cout << "f1(T2, T1)\n";
}

extern void g();

int main()
{
    f1<char, char>('a', 'b');
    g();
}

각각의 TU가 되도록 다른 cpp 파일에 정의하고 두 파일을 모두 빌드하면 에러가 발생하지 않고, 아래와 같이 실행도 잘 됩니다.

 

Partial Ordering of Overloaded Function Templates

template<typename T>
int f(T)
{
    return 1;
}

template<typename T>
int f(T*)
{
    return 2;
}

위 예제 코드를 한 번 더 살펴보겠습니다. 아래와 같이 <int*>이나 <int>로 주어진 인자 리스트를 치환하여 적절한 오버로딩 선택으로 알맞은 함수를 호출할 수 있었습니다.

std::cout << f<int*>((int*)nullptr); // calls f<T>(T)
std::cout << f<int>((int*)nullptr);  // calls f<T>(T*)

 

하지만, 명시적으로 템플릿 인자가 주어지지 않을 때에서 함수는 잘 선택되어야 합니다. 이 경우, 템플릿 인자 추론이 발생합니다. 아래와 같이 main() 함수를 작성하여 이 메커니즘에 대해서 살펴보겠습니다.

#include <iostream>
template<typename T>
int f(T)
{
    return 1;
}

template<typename T>
int f(T*)
{
    return 2;
}

int main()
{
    std::cout << f(0) << "\n";             // calls f<T>(T)
    std::cout << f(nullptr) << "\n";       // calls f<T>(T)
    std::cout << f((int*)nullptr) << "\n"; // calls f<T>(T*)
}

첫 번째 호출 f(0)에서는 인자 타입이 int이므로 첫 번째 템플릿의 T를 int로 치환하면 됩니다. 하지만, 두 번째 템플릿의 파라미터 타입은 언제나 포인터이므로 추론 후에는 첫 번째 템플릿만이 호출에 맞는 인스턴스화를 생성할 수 있습니다. 이런 경우는 추론은 매우 쉽습니다.

 

두 번째 호출 f(nullptr) 또한 첫 번째의 경우와 동일하게 적용됩니다. 인자의 타입이 std::nullptr_t 이므로 이는 오직 첫 번째 템플릿에만 일치합니다.

 

세 번재 호출 f((int*)nullptr)은 조금 흥미롭습니다. 이 경우, 두 템플릿에서의 인자 추론이 모두 성공하고, f<int*>(int*)와 f<int>(int*)를 모두 생성합니다. 전통적인 오버로딩 해석 관점에서는 두 경우 모두 int* 인자로 호출할 수 있는 함수이므로, 이 호출은 선택이 모호해집니다. 하지만 이런 경우에는 부가적인 오버로딩 해석 기준이 사용됩니다. 즉, 이와 같은 상황에서는 '조금 더 특수화된 것'으로 선택되는데, 여기서는 두 번째 템플릿이 이에 해당합니다. 따라서, 위 프로그램의 출력은 다음과 같습니다.

 

Formal Ordering Rules

방금 본 예제 코드에서 두 번째 템플릿이 첫 번째 템플릿보다 '더 특수화 된' 템플릿인 이유는 첫 번째 템플릿은 어떠한 타입이라도 받을 수 있는 반면, 두 번째 템플릿은 오직 포인터 타입만을 허용하기 때문이라는 것을 직관적으로 알 수 있습니다. 하지만 이렇게 항상 직관적이지는 않습니다. 따라서 어떤 함수 템플릿이 다른 것보다 더 특수화되었는지 결정하기 위한 정확한 방식에 대해 살펴보겠습니다. 그전에 이 방식은 부분적인 정렬 규칙이라는 것에 유의합시다. 따라서 두 템플릿 중에 누가 더 특수화되었는지 결정할 수 없을 때도 있으며, 따라서, 모호함 에러를 발생시킬 수도 있습니다.

 

주어진 함수 호출에서 가시화되어 있는 두 개의 이름이 같은 함수 템플릿이 있다고 가정해봅시다. 이때, 오버로딩은 다음의 법칙에 따라 결정됩니다.

  • 기본 인자로 커버되는 함수 호출 파라미터와 사용되지 않는 '...' 파라미터는 무시됩니다
  • 모든 템플릿 파라미터는 아래와 같이 치환하여 두 인자 타입 리스트(여기서는 두 개의 템플릿 함수에 대해 치환하니까 두 개의 인자 타입 리스트를 생성)를 합성합니다
    1. 각 템플릿 타입 파라미터를 고유한 invented type으로 변경
    2. 각 템플릿 템플릿 파라미터(template template parameter)를 고유한 invented class template으로 변경
    3. 각 nontype template parameter를 적절한 타입의 고유한 invented value로 변경
  • 만약 두 번째 템플릿의 템플릿 인자 추론이 첫 번째 합성 목록에 대해 정확히 일치하지만, 첫 번째 템플릿의 템플릿 인자 추론은 두 번째 합성 목록에 대해 정확히 일치하지 않는다면, 첫 번째 템플릿이 두 번째 템플릿보다 조금 더 특수화되었다고 간주됩니다. 역으로, 첫 번째 템플릿의 템플릿 인자 추론을 두 번째 합성 목록과 비교했을 때 정확히 일치하지만, 반대의 경우는 아니라면 두 번째 템플릿이 첫 번째 템플릿보다 더 특수화되었다고 간주합니다. 이외의 경우, 즉, 어떠한 추론도 성공하지 않거나, 둘 다 성공하는 경우에는 두 템플릿의 순서를 매길 수 없습니다.
template<typename T>
int f(T)
{
    return 1;
}

template<typename T>
int f(T*)
{
    return 2;
}

그럼 위의 함수 템플릿에 대해서 규칙을 적용해보겠습니다. 두 개의 템플릿에 대허 설명한대로 템플릿 파라미터를 치환하여 인자 타입에 대한 두 개의 목록을 생성합니다. 여기서 첫 번째 합성 목록을 (A1), 두 번째 합성 목록을 (A2*)라고 부르겠습니다. T를 A2*로 치환한다면 첫 번째 템플릿은 A2*를 사용하도록 추론할 수 있습니다. 하지만 두 번째 템플릿의 T*를 첫 번째 합성 목록의 A1이라는 타입으로 일치시킬 수 있는 방법이 없습니다. 따라서, 두 번째 템플릿이 첫 번째 템플릿보다 더 특수화되었다고 결론지을 수 있습니다.

 

조금 더 많은 함수 파라미터를 갖는 복잡한 예제에 대해 생각해보겠습니다.

template<typename T>
void t(T*, T const* = nullptr, ...);

template<typename T>
void t(T const*, T*, T* = nullptr);

void example(int* p)
{
    t(p, p);
}

먼저, 실제 t 함수 호출에서는 첫 번째 템플릿의 '...' 파라미터를 사용하지 않았고, 두 번째 템플릿의 마지막 파라미터는 기본 인자로 커버되므로 이들은 부분 정렬에서 무시됩니다. 여기서 첫 번째 템플릿의 기본 인자는 사용되지 않으므로 정렬에 포함됩니다.

 

인자 타입의 합성 리스트는 (A1*, A1 const*)과 (A2 const*, A2*) 입니다. 두 번째 템플릿을 (A1* A1 const*)로 추론하려면 T를 A1 const로 치환하면 성공합니다. 하지만 결과는 t<A1 const>(A1 const*, A1 const*, A1 const* = 0)을 (A1*, A1 const*) 타입의 인자로 호출하려면 한정자를 조정해야 하므로 정확히 일치하는 것은 아닙니다.

이와 유사하게 첫 번째 템플릿을 위한 추론으로 (A2 const*, A2)라는 인자 타입 리스트을 정확하게 일치시킬 수 없습니다.

따라서, 두 템플리 사이에는 정확한 우열 관계가 존재하지 않으므로 모호한 호출이 됩니다.

 

Templates and Nontemplates

함수 템플릿은 템플릿이 아닌 함수로 오버로딩될 수 있습니다. 모든 것들이 동일하다면, non-template 함수가 실제 호출에서 선호됩니다.

#include <string>
#include <iostream>

template<typename T>
std::string f(T)
{
    return "Template";
}

std::string f(int&)
{
    return "Nontemplate";
}

int main()
{
    int x = 7;
    std::cout << f(x) << "\n"; // prints: Nontemplate
}

위 코드의 출력은 "Nontemplate" 입니다.

 

하지만, const와 reference 한정자가 다르면 오버로딩 해석의 순서가 변경될 수 있습니다.

#include <string>
#include <iostream>

template<typename T>
std::string f(T&)
{
    return "Template";
}

std::string f(int const&)
{
    return "Nontemplate";
}

int main()
{
    int x = 7;
    std::cout << f(x) << "\n"; // prints: Template
    int const c = 7;
    std::cout << f(c) << "\n"; // prints: Nontemplate
}

위 코드에서 출력은 다음과 같습니다.

이 코드에서 인스턴스화된 f<>(int&)가 f(int const&)보다 int에 더 정확히 일치하기 때문입니다. 한 함수가 템플릿이고 다른 함수는 템플릿이 아니기 때문만은 아닙니다. 이와 같은 경우, 오버로딩의 일반적인 해석이 적용되는데, int const에 대해 f()를 호출할 때에만 두 시그니처가 같은 타입인 int const&가 될 것이고, 이때는 템플릿이 아닌 쪽이 더 선호됩니다.

 

따라서, 멤버 함수 템플릿은 아래와 같이 선언하는 것이 좋습니다.

template<typename T>
st::string f(T const&)
{
    return "Template";
}

 

그럼에도 멤버 함수가 복사나 이동 생성자와 같은 인자를 받도록 정의되었다면 놀라운 일이 발생할 수 있습니다. 아래 예제 코드를 살펴보겠습니다.

#include <string>
#include <iostream>

class C {
public:
    C() = default;
    C(C const&) {
        std::cout << "copy constructor\n";
    }
    C(C&&) {
        std::cout << "move constructor\n";
    }
    template<typename T>
    C(T&&) {
        std::cout << "template constructor\n";
    }
};

int main()
{
    C x;
    C x2{x};            // prints: template constructor
    C x3{std::move(x)}; // prints: move constructor

    C const c;
    C x4{c};            // prints: copy constructor
    C x5{std::move(c)}; // prints: template constructor
}

C를 복사할 때 복사 연산자보다 멤버 함수 템플릿이 더 잘 일치한다는 것을 볼 수 있습니다. 그리고 C const&& 타입(가능하지만 move semantic에 대한 의미는 없는)을 도출하는 std::move(c)에서는 멤버 함수 템플릿이 이동 생성자보다 더 잘 일치합니다.

 

이러한 이유로 멤버 함수 템플릿이 복사 또는 이동 생성자를 가리게 되는 경우, 이 멤버 함수 템플릿을 부분적으로 비활성화시켜야 합니다. 이는 enable_if<>를 사용하여 가능합니다.

 

Variadic Function Templates

가변 함수 템플릿은 정렬 규칙을 적용하는 동안 몇 가지 특별한 처리가 필요합니다. 파라미터 팩에 대한 추론에서는 하나의 파라미터가 여러 인자에 일치하기 때문입니다. 이러한 동작 때문에 함수 템플릿 정렬에는 여러가지 재밋는 상황들이 발생하는데, 아래 예제 코드를 살펴보겠습니다.

#include <iostream>

template<typename T>
int f(T*)
{
    return 1;
}

template<typename... Ts>
int f(Ts...)
{
    return 2;
}

template<typename... Ts>
int f(Ts*...)
{
    return 3;
}

int main()
{
    std::cout << f(0, 0.0) << "\n";                          // calls f<>(Ts...)
    std::cout << f((int*)nullptr, (double*)nullptr) << "\n"; // calls f<>(Ts*...)
    std::cout << f((int*)nullptr) << "\n";                   // calls f<>(T*)
}

위 예제의 출력은 다음과 같습니다.

첫 번째 호출인 f(0, 0.0)에서는 f라는 이름의 모든 함수 템플릿을 고려합니다.

첫 번째 함수 템플릿 f(T*)에서는 템플릿 파라미터 T가 추론될 수 없고, 함수 템플릿의 파라미터 수보다 더 많은 인자가 전달되었기 때문에 추론에 실패합니다.

두 번째 함수 템플릿 f(Ts...)의 경우, 함수 파라미터 팩의 패턴을 두 인자의 타입과 비교하여 Ts를 시퀀스(int, double)로 추론합니다.

세 번째 함수 템플릿 f(Ts*...)의 경우, 함수 파라미터 팩 Ts*의 패턴을 전달된 인자 타입들과 비교하는데, 첫 번째와 같이 추론에 실패하므로 두 번째 템플릿만 사용할 수 있으며, 따라서 함수 템플릿 정렬이 필요 없습니다.

 

두 번째 호출 f((int*)nullptr, (double*)nullptr)에서 첫 번째 함수 템플릿에 대해서는 파라미터보다 더 많은 인자가 들어왔으므로 추론에 실패합니다. 하지만 나머지 함수 템플릿에 대해서는 추론에 성공합니다. 정리하면, 추론 결과 만들어지는 호출은 다음과 같이 두 가지입니다.

f<int*,double*>((int*)nullptr, (double*)nullptr) // for second template
f<int, double>((int*)nullptr, (double*)nullptr)  // for thrid template

위에서 살펴본 정렬 규칙을 적용하면 세 번째 함수 템플릿은 포인터 타입만을 받아들이므로, 모든 인자를 받아들일 수 있는 두 번째 함수 템플릿보다 더 특수화되었다고 볼 수 있습니다. 따라서 세 번째 함수 템플릿이 선택됩니다.

 

세 번째 호출인 f((int*)nullptr)에 대해서는 조금 새롭습니다. 세 개의 함수 템플릿에 대해 모두 추론이 성공하므로 일반 함수 템플릿을 가변 함수 템플릿과 비교하기 위해 부분 정렬을 해야 합니다. 

먼저 첫 번째와 세 번째 함수 템플릿을 비교해보겠습니다. 두 템플릿으로부터 합성된 인자 타입은 각각 A1과 A2* 입니다. T를 A2로 치환하면 첫 번째 함수 템플릿을 추론할 수 있습니다. 반대로 세 번째 템플릿에 대해 Ts를 A1으로 치환할 수 있으므로 추론에 성공합니다. 따라서 첫 번째와 세 번째 함수 템플릿 사이에서는 순서를 결정할 수 없고, 호출에 대해 모호해집니다. 하지만 함수 파라미터 팩(Ts*... 등)으로부터 나온 인자를 파라미터 팩이 아닌 것에서 나온 파라미터에 일치시키지 못하게 하는 특별한 규칙이 있습니다. 따라서 A2*에 대해 첫 번째 템플릿을 추론하는 것은 사실 실패하게 됩니다. 따라서 첫 번째 템플릿이 세 번째보다 더 특수화되었다고 볼 수 있습니다. 이 특별한 규칙 때문에 사실상 가변이 아닌 템플릿(non-variadic)이 가변 템플릿보다 더 특수하다고 간주됩니다.

 

이 규칙은 함수 시그니처 내의 타입들에서 발생하는 팩 확장에서도 동일하게 적용됩니다.

 


Explicit Specialization

Full specialization(전체 특수화)은 연속된 3개의 토큰('template', '<', '>')으로 만들어집니다. 또한, 클래스 이름 뒤에 특수화를 선언할 템플릿 인자가 나옵니다. 아래 예제 코드를 살펴봅시다.

template<typename T>
class S {
public:
    void info() {
        std::cout << "generic (S<T>::info())\n";
    }
};

template<>
class S<void> {
public:
    void msg() {
        std::cout << "fully specialized (S<void>::msg())\n";
    }
};

전체 특수화의 구현은 제너릭 구현과 전혀 연관이 없다는 것에 유의합시다. info 대신 msg 함수를 갖는 것처럼 다른 이름의 멤버 함수를 가질 수도 있으며, 클래스 템플릿의 이름만 같을 뿐입니다.

 

명시된 템플릿 인자 리스트는 반드시 템플릿 파라미터 리스트와 대응되어야 합니다. 예를 들어, 템플릿 타입 파라미터에 대해 타입이 아닌 값을 지정하는 것은 유효하지 않습니다. 하지만 기본값을 갖는 템플릿 파라미터에 대해서는 템플릿 인자를 제공해도 되고 안해도 됩니다.

template<typename T>
class Types {
public:
    using I = int;
};

template<typename T, typename U = typename Types<T>::I>
class S;                        // #1

template<>
class S<void> {                 // #2
public:
    void f();
};

template<> class S<char, char>; // #3

template<> class S<char, 0>;    // ERROR: 0 cannot substitute U

int main()
{
    S<int>*         pi; // OK: uses #1, no definition needed
    S<int>          e1; // ERROR: uses #1, but no definition available
    S<void>*        pv; // OK: uses #2
    S<void,int>     sv; // OK: uses #2, definition available
    S<void,char>    e2; // ERROR: uses #1, but no definition available
    S<char,char>    e3; // ERROR: uses #3, but no definition available
}

위 예제에서 알 수 있듯이 전체 특수화의 선언이 꼭 정의일 필요는 없습니다. 이처럼 클래스 템플릿 특수화에서 타입을 전방 선언하여 완전히 종속적인 타입을 생성하는 것이 유용할 때가 있습니다. 전체 특수화 선언은 이와 같은 방식으로 일반 클래스 선언과 완전히 동일하며, 이 방식은 템플릿 선언이 아닙니다. 템플릿 선언이 아니기 때문에 일반적으로 적용할 수 있는 out-of-class 멤버 정의 문법을 사용하여 전체 클래스 템플릿 특수화의 멤버를 정의할 수 있습니다.

template<> class S<char**> {
public:
    void print() const;
};

// the following definition cannot be preceded by template<>
void S<char**>::print() const{
    std::cout << "pointer to pointer to char\n";
}

 

아래는 조금 더 복잡한 예제인데 위의 개념을 효과적으로 보여줍니다.

template<typename T>
class Outside {
public:
    template<typename U>
    class Inside {
    };
};

template<>
class Outside<void> {
    // there is no special connection between the following nested class
    // and the one defined in the generic template
    template<typename U>
    class Inside {
    private:
        static int count;
    };
};
//the following definition cannot be preceded by template<>
template<typename U>
int Outside<void>::Inside<U>::count = 1;

 

전체 특수화는 특정 제너릭 템플릿의 인스턴스화에 대한 치환이며, 한 프로그램 내에서 명시적으로 특수화된 템플릿과 자동 생성되는 템플

릿이 공존할 수 없습니다. 같은 파일 내에서 둘 다 사용하려면 컴파일 에러가 발생합니다.

template<typename T>
class Invalid {};

Invalid<double> x1; // causes the instantiation of Invalid<double>

template<>
class Invalid<double>; // ERROR: Invalid<double> already instantiated

 

하지만 만약 이러한 문제가 다른 번역 단위에서 발생한다면, 문제를 알아차리기가 어렵습니다. 아래 코드는 잘못 짜여진 코드인데, 컴파일/링크는 잘 되지만 위험합니다.

// translation unit 1:
#include <iostream>
template<typename T>
class Danger {
public:
    enum { max = 10 };
};
char buffer[Danger<void>::max]; // uses generic value

extern void clear(char*);

int main()
{
    clear(buffer);
}

// translation unit 2:
#include <iostream>
template<typename T>
class Danger;

template<>
class Danger<void> {
public:
    enum { max = 100 };
};

void clear(char* buf)
{
    // mismatch in array bound
    for (int k = 0; k < Danger<void>::max; ++k) {
        buf[k] = '\0';
    }
}

위와 같은 코드가 실제로 사용되지는 않겠지만, 중요한 것은 특수화의 선언이 제너릭 템플릿을 사용하는 모든 곳에서 가시화되어야 한다는 것을 보여줍니다. 즉, 헤더 파일 내에서 템플릿 선언 다음에 특수화 선언이 이어져야 합니다. 외부 소스 파일에 제너릭 구현이 있다면, 제너릭 템플릿을 포함하는 헤더 뒤에 특수화 선언이 뒤따르게 하여 이렇게 찾기 어려운 에러를 피해야 합니다. 일반적으로 외부 소스에 있는 특수화 템플릿은 피하는 것이 좋습니다.

 

Full Function Template Specialization

명시적인 전체 함수 템플릿 특수화(full function template specialization)는 전체 클래스 템플릿 특수화와 문법과 원칙은 거의 유사하지만, 오버로딩과 인자 추론이 추가됩니다.

 

특수화할 템플릿을 인자 추론과 부분 정렬을 통해 결정할 수 있다면 전체 특수화를 선언할 때 명시적인 템플릿 인자를 생략할 수 있습니다. 예를 통해 살펴보겠습니다.

template<typename T>
int f(T)    // #1
{
    return 1;
}

template<typename T>
int f(T*)   // #2
{
    return 2;
}

template<>
int f(int)  // OK: specialization of #1
{
    return 3;
}

template<>
int f(int*) // OK: specialization of #2
{
    return 4;
}

 

전체 함수 템플릿 특수화에서 기본 인자 값을 포함할 수 없습니다. 하지만 템플릿에 기본 인자값이 있다면 명시적으로 특수화된 이후에도 계속 사용할 수 있습니다.

template<typename T>
int f(T, T x = 42)
{
    return x;
}

template<>
int f(int, int = 35) // ERROR
{
    return 0;
}

전체 특수화는 여러 방면에서 일반 선언(엄밀히 말하면 재선언; redeclaration)과 비슷합니다. 특히 이는 템플릿을 선언하지 않기 때문에 인라인이 아닌 전체 함수 템플릿 특수화는 프로그램 내에서 딱 한 번만 나타나야 합니다. 하지만, 위에서도 언급했듯이 전체 특수화 선언은 템플릿에서 자동 생성된 함수를 사용하지 않도록 하기 위해 항상 템플릿 뒤에 나와야 한다는 것을 명심해야 합니다. 따라서 만약 템플릿 g()에 대한 선언과 하나의 전체 특수화는 전형적으로 다음과 같이 두 개의 파일로 구성됩니다.

  • 기본 템플릿 정의와 전체 특수화 선언을 포함하는 인터페이스 파일
#ifndef TEMPLATE_G_HPP
#define TEMPLATE_G_HPP

// template definition should appear in hearder file:
template<typename T>
int g(T, T x = 42)
{
    return x;
}

// specialization declaration inhibits instantiations of the template;
// definition should not appear here to avoid multiple definition errors
template<> int g(int, int y);

#endif // TEMPLATE_G_HPP
  • 전체 특수화를 정의하는 구현 파일
#include "template_g.hpp"

template<>
int g(int, int y)
{
    return y/2;
}

 

다른 방법으로, 특수화를 인라인으로 만들 수 있는데, 이 경우에는 정의를 헤더 파일에 위치시켜야 합니다.

 

Full Variable Template Specialization

변수 템플릿 역시 전체 특수화할 수 있습니다. 문법은 매우 직관적입니다.

template<typename T> constexpr std::size_t SZ = sizeof(T);
template<> constexpr std::size_t SZ<void> = 0;

 

재밌는 부분은 특수화되는 템플릿과 일치하는 타입을 갖도록 특수화할 필요는 없습니다.

template<typename T> typename T::iterator null_iterator;
template<> BitIterator null_iterator<std::bitset<100>>;
              // BitIterator doesn't match T::iterator, and that is fine

 

Full Member Specialization

멤버 템플릿뿐만 아니라 일반 정적 데이터 멤버와 클래스 템플릿의 멤버 함수도 전체를 특수화할 수 있습니다. 이때, 이를 둘러싸는 모든 클래스 템플릿에 template<>를 붙여야 합니다. 멤버 템플릿이 특수화되면 template<>을 추가하여 특수화되었음을 알려주어야 합니다.

우선 다음과 같이 선언되었다고 가정해봅시다.

template<typename T>
class Outer {                       // #1
public:
    template<typename U>
    class Inner {                   // #2
    private:
        static int count;           // #3
    };
    static int code;                // #4
    void print() const {            // #5
        std::cout << "generic";
    }
};
template<typename T>
int Outer<T>::code = 6;             // #6

template<typename T> template<typename U>
int Outer<T>::Inner<U>::count = 7;  // #7

template<>
class Outer<bool> {                 // #8
public:
    template<typename U>
    class Inner {                   // #9
    private:
        static int count;           // #10
    };
    void print() const {            // #11
    }
};

제너릭 Outer 템플릿(#1)의 일반 멤버인 code(#4)와 print()(#5)를 둘러싼 클래스 템플릿은 하나이며, 특정 템플릿 인자로 이들을 특수화할 때는 아래와 같이 template<>를 하나만 사용하면 됩니다.

template<>
int Outer<void>::code = 12;

template<>
void Outer<void>::print() const
{
    std::cout << "Outer<void>";
}

위의 정의는 Outer<void> 클래스를 위한 #4, #5의 일반 멤버 대신 사용되는데, class Outer<void>의 다른 멤버는 #1에서 정의된 템플릿에서 생성됩니다. 이와 같은 선언을 한 뒤에는 Outer<void>에 대한 명시적 특수화를 제공할 수 없다는 점을 기억합시다.

 

전체 함수 템플릿 특수화와 함께, 중복 정의를 방지하기 위해 정의를 명시하지 않고 클래스 템플릿의 일반 멤버를 특수화를 선언할 방법이 필요합니다. 일반 클래스에서는 정의하지 않은 클래스 선언 밖에서 멤버 함수나 정적 멤버 선언은 C++에서 허용되지 않지만, 클래스 템플릿의 멤버를 특수화할 때는 괜찮습니다. 따라서, 위의 정의는 다음과 같이 선언할 수도 있습니다.

template<>
int Outer<void>::code;

template<>
void Outer<void>::print() const;

다만, Outer<void>::code의 전체 특수화에 대한 정의되지 않은 선언은 기본 생성자로 초깃값을 정의하는 문법과 똑같습니다. 실제로 그렇지만, 이러한 선언은 항상 정의되지 않은 선언(nondefining declaration)으로 해석됩니다. 따라서 기본 생성자를 사용해서만 초기화할 수 있는 타입의 정적 데이터 멤버를 전체 특수화하려면 초기화자 리스트(initializer list) 문법을 활용해야 합니다.

class DefaultInitOnly {
public:
    DefaultInitOnly() = default;
    DefaultInitOnly(DefaultInitOnly const&) = delete;
};

template<typename T>
class Statics {
private:
    static T sm;
};

일반적인 선언은 다음과 같습니다.

template<>
DefaultInitOnly Statics<DefaultInitOnly>::sm;

아래는 기본 생성자를 호출하는 정의입니다.

template<>
DefaultInitOnly Statics<DefaultInitOnly>::sm{};

C++11 이전에는 위와 같은 방법을 불가능했으며, 이러한 특수화에서 기본 초기화를 사용할 수 없었습니다. 일반적으로는 기본값을 복사하는 초기화자를 사용했습니다.

template<>
DefaultInitOnly Statics<DefaultInitOnly>::sm = DefaultInitOnly();

이번 예제에서는 복사 생성자가 삭제되었으므로 위와 같은 방법이 불가합니다. 하지만 C++17에서는 필수적인 copy-elision 규칙이 도입되어 복사 생성자 호출이 관여되지 않아 위의 코드가 가능합니다.

 

멤버 템플릿 Outer<T>::Inner는 멤버 템플릿을 특수화하고자 하는 Outer<T>에 대한 특정 인스턴스의 다른 멤버에 영향을 미치지 않으면서 주어진 템플릿 인자로 특수화할 수 있습니다. 여기서도 둘러싸는 템플릿은 하나이므로, 하나의 template<>만 붙으면 됩니다. 결과 코드는 다음과 같습니다.

template<>
template<typename X>
class Outer<wchar_t>::Inner {
public:
    static long count; // member type changed
};

template<>
template<typename X>
long Outer<wchar_t>::Inner<X>::count;

 

Outer<T>::Inner 템플릿은 전체 특수화가 가능하지만, 오직 주어진 Outer<T> 인스턴스에 대해서만 가능합니다. 따라서 두 개의 template<>이 붙어야 하며, 하나는 둘러싸는 클래스 때문이고, 다른 하나는 내부 템플릿 전체 특수화 때문입니다.

template<>
template<>
class Outer<char>::Inner<wchar_t> {
public:
   enum { count = 1 };
};

// the following is not valid C++:
// temlate<> cannot follow a template parameter list
template<typename X>
template<> class Outer<X>::Inner<void>; // ERROR

 

Outer<bool>의 멤버 템플릿 특수화를 비교해봅시다. Outer<bool>은 이미 전체가 특수화되어 있기 때문에 둘러싸는 템플릿이 없으므로 template<>는 하나면 충분합니다.

template<>
class Outer<bool>::Inner<wchar_t> {
public:
    enum { count = 2 };
};

 


Partial Class Template Specialization

Full 템플릿 특수화가 유용하지만, 하나의 특정 템플릿 인자에 대해 특수화하는 것보다 여러 템플릿 인자들에 대해 특수화하길 원할 수도 있습니다. 예를 들어, 연결 리스트(linked list)를 구현하는 클래스 템플릿이 있다고 가정해봅시다.

template<typename T>
class List {        // #1
public:
    ...
    void append(T const&);
    inline std::size_t length() const;
    ...
};

대형 프로젝트에서는 이 템플릿을 여러 가지 타입에 대해 인스턴스화될 수 있습니다. 이러한 경우, 인라인으로 확장되지 않는 멤버 함수(ex, List<T>::append())의 경우, 오브젝트 코드가 엄청 증가합니다. 하지만 low-level 수준에서 List<int*>::append()와 List<void*>::append()의 코드는 동일하다는 것을 알고 있습니다. 따라서 포인터에 대한 모든 List가 구현을 공유하면 좋습니다. C++로 표현될 수는 없더라도, 이는 포인터에 대한 모든 List가 다른 템플릿 정의로부터 인스턴스화되어야 한다고 명시하는 것과 유사한 효과는 얻을 수 있습니다.

template<typename T>
class List<T*> {    // #2
private:
    List<void*> impl;
    ...
public:
    ...
    inline void append(T* p) {
        impl.append(p);
    }
    inline std::size_t length() const {
        return impl.length();
    }
    ...
};

위와 같은 문맥에서 #1의 원본 템플릿은 primary template라고 부르며, 바로 위의 정의는 partial specialization 이라고 부릅니다.

 

위 코드에서는 List<void*>가 List<void*> 타입의 멤버를 재귀적으로 갖는다는 문제가 있습니다. 이러한 순환을 끊기 위해서 부분 특수화를 전체 특수화로 진행할 수도 있습니다.

template<>
class List<void*> {    // #3
    ...
    void append(void* p);
    inline std::size_t length() const;
    ...
};

전체 특수화에 일치하는 것이 부분 특수화보다 선호되기 때문에 위 코드는 잘 동작합니다.

 

부분 특수화 선언의 파라미터와 인자 리스트에 대해서는 아래의 몇 가지 제약이 있습니다.

  1. 부분 특수화의 인자는 기본 템플릿의 대응되는 파라미터와 같은 타입이어야 합니다.
  2. 부분 특수화의 파라미터 리스트는 기본 인자를 가질 수 없습니다. 대신 primary class template의 기본 인자를 사용할 수 있습니다.
  3. 부분 특수화의 nontype 인자는 비종속적인 값이거나 plan nontype 템플릿 파라미터이어야 합니다. 이들은 2*N(여기서 N은 템플릿 파라미터)와 같이 더 복잡한 종속 표현식일 수 없습니다.
  4. 부분 특수화의 템플릿 인자 리스트는 항상 기본(primary) 템플릿의 파라미터 리스트와 동일하지 않아야 합니다.
  5. 템플릿 인자 중 하나가 팩 확장이라면, 이는 템플릿 인자 리스트의 마지막에 나와야 합니다.

예제를 통해 자세히 살펴보겠습니다.

template<typename T, int I = 3>
class S;            // primary template

template<typename T>
class S<int, T>;    // ERROR: parameter kind mismatch

template<typename T = int>
class S<T, 10>;     // ERROR: no default arguments

template<int I>
class S<int, I*2>;  // ERROR: no nontype expressions

template<typename U, int K>
class S<U, K>;      // ERROR: no significant difference from primary template

template<typename... Ts>
class Tuple;

template<typename Tail, typename... Ts>
class Tuple<Ts..., Tail>;   // ERROR: pack expansion not at the end

template<typename Tail, typename... Ts>
class Tuple<Tuple<Ts...>, Tail>; // OK: pack expansion is at the end of a nested template argument list

모든 부분 특수화는 모든 전체 특수화와 같이 기본(primary) 템플릿과 연관되어 있습니다. 템플릿이 사용될 때, 기본 템플릿은 항상 룩업되며, 템플릿 특수화를 선택할 때 연관된 특수화들의 인자들을 비교하여 결정합니다 (템플릿 인자 추론에 따릅니다). SFINAE 원칙 또한 여기서 적용됩니다. 부분 특수화에 일치하는지 확인하는 동안 잘못 추론되는 것들은 버리고, 다음 후보들을 찾습니다. 일치하는 특수화가 여러 개라면 '가장 특수화 된' 것을 선택합니다. 선택할 수 없다면 ambiguity 에러가 발생합니다.

 


Partial Variable Template Specialization

표준에서 변수 템플릿을 부분 특수화할 수 있다고 했지만, 어떻게 선언되어야 하며, 이것이 어떤 의미인지 전혀 설명하고 있지 않습니다. 따라서 아래 설명은 실제 구현된 C++에 기반을 둔 것이며, C++ 표준에 기반한 것은 아닙니다.

 

변수 템플릿의 문법 자체는 전체 변수 템플릿 특수화와 유사합니다. 대신 template<>은 실제 템플릿 선언으로 바뀌며, 변수 템플릿 이름 뒤에 나오는 템플릿 인자 리스트는 템플릿 파라미터에 종속되어야 합니다. 아래 예제 코드를 살펴봅시다.

template<typename T> constexpr std::size_t SZ = sizeof(T);
template<typename T> constexpr std::size_t SZ<T&> = sizeof(void*);

 

변수 템플릿의 전체 특수화처럼 부분 특수화의 타입도 기본 템플릿과 일치할 필요는 없습니다.

template<typename T> typename T::iterator null_iterator;
template<typename T, std::size_t N> T* null_iterator<T[N]> = null_ptr;
// T* doesn't match T::iterator, and that is fine

 

변수 템플릿 부분 특수화에서 나열할 수 있는 템플릿 인자 리스트 종류에 대한 규칙은 클래스 템플릿 특수화와 같습니다. 또한 실제 템플릿 인자 리스트가 주어졌을 때 어떤 특수화를 선택하는지에 대한 규칙도 동일합니다.

댓글