본문 바로가기

템플릿 특수화

일반 템플릿이 맞지 않는 타입에 명시적·부분 특수화를 제공하고 중복과 유지보수 비용을 고려해 적용합니다.

템플릿은 모든 타입에 같은 코드를 적용하지만, 어떤 타입에는 그 코드가 맞지 않거나 더 나은 방법이 있을 수 있습니다. 예를 들어 const char*는 포인터를 바꾸는 것과 가리키는 글자를 바꾸는 것이 다르고, bool은 다른 방식으로 저장하고 싶을 수 있습니다. 템플릿 특수화(template specialization)는 특정 타입이나 타입의 형태에 대해서만 템플릿의 정의를 따로 제공하는 방법입니다.

이 글은 모든 템플릿 인수를 정하는 명시적 특수화(explicit specialization)와 일부만 정하는 부분 특수화(partial specialization)를 함수 템플릿과 클래스 템플릿에서 차례로 보고, 특수화와 오버로딩 가운데 무엇을 고를지 정리합니다.

C++ · SPECIALIZATION SELECTION

정확한 특수화 → 맞는 부분 패턴 → 기본 템플릿

본문 MyValue와 Pair 예제의 인수 조합을 비교합니다. 타입 이름뿐 아니라 어떤 정의를 선택하는지와 그 근거까지 함께 읽습니다.

정확한 완전 특수화와 부분 패턴 및 기본 정의를 조건으로 선택한다8노드7간선. 인수확정→도달가능한정확한완전특수화조건. 예는완전정의 terminal;아니요는옆우회로부분패턴조건. 일치0개는기본정의 terminal;1개이상은우회로유일한최적부분패턴조건. 예는부분정의 terminal,아니요는모호성오류 terminal. 완전·기본·부분·오류결과사이에화살표가없고모바일에서도예/없음/0개/1개이상/아니요를보존한다. 이그림은클래스템플릿선택모델이며함수오버로드후보선택모델이아니다.클래스 인수타입·값 조합 확정완전 특수화 있음?정확한 조합이 도달 가능완전 특수화 사용그 조합의 별도 정의맞는 부분 패턴?전체 인수 관계로 검사기본 템플릿 사용일치하는 부분 패턴 없음하나로 정해짐?1개 또는 유일한 최적부분 특수화 사용선택한 패턴의 정의모호성 오류우열을 정할 수 없음예없음0개1개 이상예아니요최종 노드들 사이 간선 없음선언 순서로 승자를 고르지 않음
정확한 완전 특수화와 부분 패턴 및 기본 정의를 조건으로 선택한다8노드7간선. 인수확정→도달가능한정확한완전특수화조건. 예는완전정의 terminal;아니요는옆우회로부분패턴조건. 일치0개는기본정의 terminal;1개이상은우회로유일한최적부분패턴조건. 예는부분정의 terminal,아니요는모호성오류 terminal. 완전·기본·부분·오류결과사이에화살표가없고모바일에서도예/없음/0개/1개이상/아니요를보존한다. 이그림은클래스템플릿선택모델이며함수오버로드후보선택모델이아니다.클래스 인수타입·값 조합 확정완전 특수화 있음?정확한 조합이 도달 가능완전 특수화 사용그 조합의 별도 정의맞는 부분 패턴?전체 인수 관계로 검사기본 템플릿 사용일치하는 부분 패턴 없음하나로 정해짐?1개 또는 유일한 최적부분 특수화 사용선택한 패턴의 정의모호성 오류우열을 정할 수 없음예없음0개1개 이상예아니요최종 노드들 사이 간선 없음선언 순서로 승자를 고르지 않음
본문에 정의된 템플릿으로 선택 결과 확인
사용 타입선택하는 정의선택 근거
MyValue<int>기본 MyValue<T>일치하는 명시적 특수화가 없습니다.
MyValue<bool>MyValue<bool> 명시적 특수화정확한 bool 조합의 정의가 있습니다.
MyValue<const char*>MyValue<const char*> 명시적 특수화문자열을 std::string 멤버로 관리하는 별도 정의입니다.
Pair<double, char>기본 Pair<T1, T2>본문의 두 부분 특수화 패턴에 모두 맞지 않습니다.
Pair<std::string, int>Pair<T, int> 부분 특수화두 번째 인수가 int입니다.
Pair<int, int*>Pair<T, T*> 부분 특수화두 번째 인수는 첫 번째 타입의 포인터입니다.

MyValue<int>

선택: 기본 MyValue<T>

근거: 일치하는 명시적 특수화가 없습니다.

MyValue<bool>

선택: MyValue<bool> 명시적 특수화

근거: 정확한 bool 조합의 정의가 있습니다.

MyValue<const char*>

선택: MyValue<const char*> 명시적 특수화

근거: 문자열을 std::string 멤버로 관리하는 별도 정의입니다.

Pair<double, char>

선택: 기본 Pair<T1, T2>

근거: 본문의 두 부분 특수화 패턴에 모두 맞지 않습니다.

Pair<std::string, int>

선택: Pair<T, int> 부분 특수화

근거: 두 번째 인수가 int입니다.

Pair<int, int*>

선택: Pair<T, T*> 부분 특수화

근거: 두 번째 인수는 첫 번째 타입의 포인터입니다.

패턴이 여러 개 일치하면: 부분 순서화로 유일한 최적 특수화를 정할 수 있어야 합니다. 우열이 없는 여러 패턴이 맞으면 모호성 오류가 납니다.

함수 템플릿의 명시적 특수화

template <> 뒤에 특정 타입으로 정한 함수를 정의하면, 그 타입에 대해서는 일반 템플릿 대신 이 정의가 쓰입니다. 꺾쇠괄호가 비어 있는 것은 남은 타입 매개변수가 하나도 없다는 뜻이고, 함수 이름 뒤의 <const char*>가 어느 타입의 특수화인지를 정합니다. 아래 코드는 두 버전이 출력 앞에 서로 다른 머리말을 붙이므로, 세 호출 가운데 어느 것이 특수화를 쓰는지 출력에서 바로 알 수 있습니다.

swap_specialization.cpp
#include <iostream>
#include <string>

template <typename T>
void mySwap(T& a, T& b) {
    std::cout << "generic: ";
    T temp = a;
    a = b;
    b = temp;
}

template <>
void mySwap<const char*>(const char*& a, const char*& b) {
    std::cout << "pointer: ";
    const char* temp = a;
    a = b;
    b = temp;
}

int main() {
    int i1 = 10, i2 = 20;
    mySwap(i1, i2);
    std::cout << i1 << ' ' << i2 << '\n';

    const char* s1 = "Hello";
    const char* s2 = "World";
    mySwap(s1, s2);
    std::cout << s1 << ' ' << s2 << '\n';

    std::string str1 = "Apple";
    std::string str2 = "Banana";
    mySwap(str1, str2);
    std::cout << str1 << ' ' << str2 << '\n';
}
generic: 20 10
pointer: World Hello
generic: Banana Apple

mySwap(s1, s2)는 T가 const char*로 추론되고, 그 조합에 명시적 특수화가 있으므로 특수화의 본문이 쓰입니다. 이 특수화는 두 포인터의 값만 맞바꾸며, 문자열 리터럴은 이 함수가 소유하지 않으므로 해제하거나 고치지 않습니다. 사실 이 특수화의 본문은 일반 템플릿과 같은 일을 하므로, 여기서는 문법을 보여 주는 예일 뿐 특수화해서 얻는 이득은 없습니다.

함수 템플릿의 명시적 특수화는 오버로딩과 다르게 동작합니다. 특수화는 별도의 후보가 아니어서, 먼저 함수 템플릿에서 본 규칙으로 기본 템플릿이 선택된 뒤에 그 조합의 특수화가 있으면 본문만 바뀝니다. 그래서 특정 타입에 다른 동작이 필요할 때는 void mySwap(const char*& a, const char*& b) 같은 일반 함수를 오버로드로 추가하는 편이 선택 규칙을 예상하기 쉽습니다.

C++ · OVERLOAD OR SPECIALIZATION

후보 추가, 본문 분기, 특정 구현 대체를 구분한다

함수 템플릿에는 partial specialization이 없다. 호출 후보를 추가할지 primary의 한 specialization을 대체할지 구분한다.

동작 차이를 표현할 세 설계 방향은 서로 독립이며 함께 쓸 수 있다원desktop4노드3간선fanout과같다. 바꾸려는타입계약에서함수오버로드,후보조건/본문분기,템플릿특수화세방향으로향한다. 선택지사이에시간간선이나우선순위간선은없고함께사용할수있다. SFINAE는후보조건관련규칙,if constexpr는선택된본문의분기라는서로다른단계를같은설계선택지안에서도구분한다.바꾸려는 계약특정 타입의 동작함수 오버로드별도 호출 후보 추가조건과 본문 분기SFINAE / if constexpr서로 다른 적용 단계템플릿 특수화선택 조합의 정의 교체세 선택은 함께 사용할 수 있음화살표는 설계 조건의 분기
함수 템플릿 예외는 overload를 먼저 검토한다 함수 동작만 다른 경우 overload나 if constexpr를 검토하고 정확한 primary specialization 대체에만 명시적 특수화를 사용한다. 차이가 어디에 있는가?호출 계약과 구조를 구분overload인자 형태·변환 규칙이 다름if constexpr작은 compile-time 분기명시 특수화정확한 한 타입만 대체
  1. 호출 형태가 다름

    overload로 후보를 명시한다.

  2. 작은 타입 분기

    if constexpr가 더 가까이 읽히는지 본다.

  3. 정확한 한 타입

    primary specialization 대체가 필요할 때만 명시 특수화한다.

명시적 함수 특수화는 독립 overload 후보가 아니라 선택된 primary specialization의 구현 대체다.

위 그림처럼 함수 템플릿의 동작을 바꾸는 방법은 세 가지이고, 함께 쓸 수도 있습니다. 인자의 형태가 다르면 오버로드를 추가하고, 본문의 일부만 타입에 따라 다르면 C++17의 if constexpr로 컴파일할 때 갈래를 고르며, 기본 템플릿의 특정 조합을 통째로 바꿔야 할 때만 명시적 특수화를 씁니다. 특정 타입에서 아예 후보가 되지 않게 하려면 C++20의 콘셉트나 그 이전부터 쓰던 std::enable_if를 쓰며, 콘셉트는 C++ 20 기능 소개에서 다룹니다.

클래스 템플릿의 명시적 특수화

클래스 템플릿도 특정 타입에 대해 전혀 다른 정의를 줄 수 있습니다. 특수화한 클래스는 일반 템플릿과 멤버 구성이 달라도 됩니다.

value_specialization.cpp
#include <iostream>
#include <string>

template <typename T>
class MyValue {
private:
    T value;

public:
    explicit MyValue(T v) : value(v) {}
    void display() const { std::cout << "value " << value << '\n'; }
};

template <>
class MyValue<bool> {
private:
    int stored;

public:
    explicit MyValue(bool v) : stored(v ? 1 : 0) {}
    void setValue(bool v) { stored = v ? 1 : 0; }
    void display() const { std::cout << "bool " << (stored ? "True" : "False") << '\n'; }
};

template <>
class MyValue<const char*> {
private:
    std::string text;

public:
    explicit MyValue(const char* s) : text(s ? s : "") {}
    void setValue(const char* s) { text = s ? s : ""; }
    const char* getValue() const { return text.c_str(); }
    void display() const { std::cout << "text " << text << '\n'; }
};

int main() {
    MyValue<int> intVal(123);
    intVal.display();

    MyValue<bool> boolVal(true);
    boolVal.display();
    boolVal.setValue(false);
    boolVal.display();

    MyValue<const char*> word("Hello C++");
    word.display();
    word.setValue("World");
    word.display();
}
value 123
bool True
bool False
text Hello C++
text World

MyValue<bool>은 값을 int로 저장해 True와 False로 보여 주고, MyValue<const char*>는 받은 문자열을 std::string으로 복사해 둡니다. 이 bool 특수화는 저장 방식을 바꾸는 예일 뿐 메모리를 아끼지는 않으며, 원소를 비트 하나로 압축하는 std::vector<bool>과는 다릅니다.

const char* 특수화는 포인터를 그대로 저장하지 않고 글자를 복사하므로, 넘긴 배열을 나중에 바꾸거나 없애도 객체의 값은 그대로입니다. 반면 getValue()가 돌려주는 포인터는 객체 안의 문자열을 빌려 보는 것이어서, setValue로 값을 바꾸거나 객체가 사라진 뒤에는 쓸 수 없습니다. std::string 클래스에서 본 c_str()의 규칙과 같습니다.

더 깊이: 표준 라이브러리의 특수화 std::vector<bool>

표준 라이브러리에도 클래스 템플릿의 특수화가 있으며, 가장 잘 알려진 것이 std::vector<bool>입니다. 정확히는 할당자 타입을 매개변수로 남겨 둔 vector<bool, Allocator>의 부분 특수화입니다. 표준은 이 특수화가 공간을 아낄 수 있도록 허용하고 방법은 구현에 맡기는데, 흔한 방법은 원소 하나를 sizeof(bool) 바이트 대신 비트 하나에 담는 것입니다. 그 대가로 이 특수화는 원소를 연속된 bool 배열로 저장한다는 보장이 없고, operator[]는 bool&가 아니라 비트 하나를 대신 다루는 프록시(proxy) 객체인 std::vector<bool>::reference를 값으로 돌려줍니다.

그래서 다른 std::vector<T>에서 당연한 코드가 bool에서는 컴파일되지 않거나 다르게 동작합니다. bool& r = v[0];은 프록시를 bool&에 묶을 수 없어 오류이고, auto x = v[0];의 x는 bool 사본이 아니라 프록시여서 x에 대입하면 원래 원소가 바뀝니다. 또 같은 컨테이너의 서로 다른 원소를 여러 스레드가 동시에 바꾸는 것도 다른 vector와 달리 안전하지 않습니다. 크기가 컴파일할 때 정해지는 비트 집합이라면 std::bitset을 쓸 수 있고, 진짜 bool 원소가 필요하면 std::vector<char> 같은 다른 타입을 쓰는 방법이 있습니다. 이 차이는 cppreference의 std::vector<bool> 문서에 정리되어 있습니다.

클래스 템플릿의 부분 특수화

클래스 템플릿은 템플릿 인수의 일부만 정하거나, T*처럼 인수의 형태만 정하는 부분 특수화를 할 수 있습니다. 함수 템플릿은 부분 특수화를 할 수 없으며, 그런 경우에는 오버로딩을 씁니다.

pair_partial.cpp
#include <iostream>
#include <string>

template <typename T1, typename T2>
class Pair {
    T1 first;
    T2 second;

public:
    Pair(T1 f, T2 s) : first(f), second(s) {}
    void display() const { std::cout << "generic: " << first << ", " << second << '\n'; }
};

template <typename T>
class Pair<T, int> {
    T first;
    int second;

public:
    Pair(T f, int s) : first(f), second(s) {}
    void display() const { std::cout << "second is int: " << first << ", " << second << '\n'; }
};

template <typename T>
class Pair<T, T*> {
    T value;
    T* ptr;

public:
    Pair(T v, T* p) : value(v), ptr(p) {}
    void display() const {
        std::cout << "pointer pair: " << value << ", " << (ptr ? *ptr : T{}) << '\n';
    }
};

int main() {
    Pair<double, char> p1(3.14, 'A');
    p1.display();

    Pair<std::string, int> p2("Test", 123);
    p2.display();

    int val = 100;
    Pair<int, int*> p3(50, &val);
    p3.display();
}
generic: 3.14, A
second is int: Test, 123
pointer pair: 50, 100

Pair<T, int>는 두 번째 인수가 int인 모든 경우에, Pair<T, T*>는 두 번째 인수가 첫 번째 인수의 포인터인 경우에 쓰입니다. 맨 위 그림처럼 컴파일러는 먼저 인수 조합과 정확히 맞는 명시적 특수화가 있는지 보고, 없으면 맞는 부분 특수화를 찾으며, 그것도 없으면 기본 템플릿을 씁니다. 맞는 부분 특수화가 여럿이면 더 구체적인 것을 고르고, 우열을 정할 수 없으면 컴파일 오류입니다.

ambiguous_partial.cpp
template <typename A, typename B>
struct Choice {};

template <typename T>
struct Choice<T, int> {};

template <typename U>
struct Choice<int, U> {};

int main() {
    Choice<int, int> ambiguous; // 컴파일 오류: 두 부분 특수화가 모두 맞음
}

Choice<int, int>는 두 부분 특수화에 모두 맞는데, 하나는 두 번째 인수를, 다른 하나는 첫 번째 인수를 정했을 뿐이어서 어느 쪽도 더 구체적이지 않습니다. Choice<double, int>로 바꾸면 첫 번째만 맞아 컴파일되고, template <> struct Choice<int, int> {};처럼 그 조합의 명시적 특수화를 추가해도 해결됩니다. 나중에 선언한 특수화가 이긴다는 규칙은 없습니다.

특수화를 쓸 때의 주의점

특수화는 일반 템플릿의 예외를 만드는 일이므로, 늘어날수록 어떤 타입에서 어느 정의가 쓰이는지 따라가기 어려워집니다. 먼저 일반 템플릿 하나로 해결할 수 있는지 보고, 함수라면 오버로딩을 먼저 생각합니다.

특수화는 그것이 쓰이는 곳보다 앞에서 보여야 합니다. 특수화를 선언하기 전에 같은 조합이 이미 쓰였다면 일반 템플릿으로 만들어진 뒤여서 오류가 나므로, 위 예제들은 특수화를 main보다 앞에 두었습니다. 함수 템플릿의 명시적 특수화는 더 이상 템플릿이 아닌 보통 함수이므로, 헤더에 정의하려면 inline을 붙여야 여러 소스가 포함해도 중복 정의가 되지 않습니다.

정리

  • 템플릿 특수화는 특정 타입이나 타입의 형태에 대해서만 템플릿의 정의를 따로 제공합니다.
  • 명시적 특수화는 template <>로 모든 인수를 정하며, 함수 템플릿에서는 후보가 아니라 선택된 기본 템플릿의 본문만 바꿉니다.
  • 함수 템플릿의 동작을 타입마다 바꿀 때는 오버로드, if constexpr, 명시적 특수화 순서로 먼저 검토합니다.
  • 클래스 템플릿은 일부 인수나 T* 같은 형태만 정하는 부분 특수화를 할 수 있지만, 함수 템플릿은 할 수 없습니다.
  • 컴파일러는 명시적 특수화, 가장 구체적인 부분 특수화, 기본 템플릿 순서로 고르며, 우열을 정할 수 없으면 오류입니다.
  • 특수화는 쓰이는 곳보다 앞에서 보여야 하고, 헤더에 정의한 함수 특수화에는 inline이 필요합니다.

연습 문제

1. 어느 정의가 선택되는가

pair_partial.cpp의 세 정의가 있을 때 Pair<int, int>, Pair<double, double*>, Pair<int, double*>는 각각 어느 정의를 쓰나요? main에서 val을 200으로 바꾼 뒤 p3.display()를 다시 부르면 무엇이 출력되나요?

해설 보기

순서대로 Pair<T, int>, Pair<T, T*>, 기본 템플릿입니다. Pair<int, double*>는 T가 첫 인수에서 int, 두 번째에서 double이어야 해서 T, T* 형태에 맞지 않습니다. p3는 value에 50을 복사해 두었고 ptr은 val을 가리키므로, val을 200으로 바꾸면 pointer pair: 50, 200이 출력됩니다.

2. 복사해 둔 문자열

value_specialization.cpp의 정의 아래에 다음 main을 두면 두 display()는 무엇을 출력하나요? MyValue<const char*>가 받은 문자열을 어떤 멤버에 어떻게 저장하는지, setValue가 nullptr을 받으면 무엇을 넣는지를 먼저 확인해 보세요.

copied_text.cpp
int main() {
    char input[] = "Sun";
    MyValue<const char*> word(input);
    input[0] = 'F';
    word.display();
    word.setValue(nullptr);
    word.display();
}
해설 보기

word는 만들 때 Sun을 복사해 두었으므로, input이 Fun으로 바뀌어도 첫 출력은 text Sun입니다. setValue(nullptr)는 조건식이 빈 문자열을 넣으므로 둘째 출력은 text 로, 값 부분이 비어 있습니다. 포인터를 그대로 저장하는 구현이었다면 첫 출력이 Fun이 되었을 것입니다.

3. 함수 템플릿의 부분 특수화

template <typename T> void show(T value)가 있을 때, 포인터에만 다른 동작을 주려고 template <typename T> void show<T*>(T* value)를 쓰면 어떻게 되나요? 같은 목적을 이루려면 어떻게 쓰나요?

해설 보기

함수 템플릿은 부분 특수화를 할 수 없으므로 컴파일 오류가 납니다. 대신 template <typename T> void show(T* value)를 별도의 함수 템플릿으로 오버로드하면, 포인터 인자에 대해 이 템플릿이 더 구체적이어서 선택됩니다.

이것으로 11장의 템플릿 프로그래밍을 마칩니다. 다음 장의 컨테이너 개요에서는 템플릿으로 만들어진 표준 라이브러리의 컨테이너들을 살펴보고, 데이터에 맞는 컨테이너를 고르는 기준을 다룹니다.