C++ 템플릿 특수화: 모든 타입에 같은 로직을 쓸 수 없을 때 완전·부분 특수화로 푸는 법

들어가며: 모든 타입에 같은 로직을 쓸 수 없을 때

템플릿으로 print<T>를 만들어 모든 타입을 처리한다고 해 봅시다. 그런데 bool을 출력하면 true 대신 1이 나오고, 포인터를 넘기면 가리키는 값 대신 주소가 찍힙니다.

#include <iostream>
template <typename T>
void print(const T& value) {
    std::cout << value << "\n";
}
int main() {
    print(42);       // 42 ✓
    print(true);     // 1  ✗ (true로 나오길 원함)
    int x = 10;
    print(&x);       // 0x7fff... ✗ (10이 나오길 원함)
}

일반 템플릿은 모든 타입에 같은 operator<<를 적용합니다. 스트림은 std::boolalpha를 켜지 않으면 bool을 정수로 출력하고, 포인터는 주소로 출력합니다. 특정 타입만 다르게 동작시키려면 템플릿 특수화나 오버로드가 필요합니다.

실무에서도 이런 요구는 자주 나옵니다. JSON 직렬화에서는 std::string은 따옴표로 감싸고 bool은 true/false로 써야 합니다. 설정 파일을 읽을 때는 bool이 "true", "yes", "1"처럼 여러 표기를 받아야 하고, std::vector<int>는 "1,2,3"을 쪼개야 합니다. 표준 라이브러리 안에도 예가 있습니다. std::vector<bool>은 원소를 비트 단위로 묶어 저장하도록 클래스 템플릿을 특수화한 것입니다.

같은 문제를 특수화와 오버로드로 풀면 다음과 같습니다.

#include <iostream>
template <typename T>
void print(const T& value) {
    std::cout << "Generic: " << value << "\n";
}
template <>
void print<bool>(const bool& value) {
    std::cout << "Bool: " << (value ? "true" : "false") << "\n";
}
template <typename T>
void print(T* ptr) {
    if (ptr) std::cout << "Pointer: " << *ptr << "\n";
    else std::cout << "Pointer: null\n";
}
int main() {
    print(42);       // Generic: 42
    print(true);     // Bool: true
    int x = 10;
    print(&x);       // Pointer: 10
}

template <> void print<bool>은 첫 번째 템플릿을 bool에 대해 완전 특수화한 것이고, print(T* ptr)는 특수화가 아니라 별도의 함수 템플릿 오버로드입니다. print(&x)에서는 두 템플릿이 모두 정확히 맞지만, T*가 const T&보다 더 특수한(more specialized) 템플릿이라 포인터 버전이 선택됩니다. 이 글은 C++ 템플릿 입문(#9-1)과 클래스 템플릿(#9-2)의 내용을 안다고 가정합니다.


특수화의 종류와 컴파일러가 고르는 순서

특수화란?

템플릿 특수화는 일반 템플릿이 적용되는 타입 중 특정 타입(또는 타입 패턴)에 대해 별도 구현을 제공하는 기능입니다.

flowchart TB
    subgraph call[호출]
        C1["print(42)"]
        C2["print(true)"]
        C3["print(ptr)"]
    end
    subgraph select[선택]
        S1["일반 템플릿 print T"]
        S2["완전 특수화 print bool"]
        S3["오버로드 print T*"]
    end
    C1 --> S1
    C2 --> S2
    C3 --> S3

print(42)는 일반 템플릿, print(true)는 bool 특수화, print(ptr)는 포인터 오버로드로 갑니다.

특수화 종류

종류함수 템플릿클래스 템플릿예시
완전 특수화OOprint<bool>, Vector<bool>
부분 특수화X (오버로드로 대체)OVector<T*>, Pair<T,T>

함수 템플릿은 부분 특수화를 지원하지 않으므로, 더 구체적인 함수 템플릿 오버로드를 추가해 같은 효과를 냅니다. 클래스 템플릿은 완전·부분 특수화를 모두 지원합니다. 변수 템플릿(C++14)도 클래스 템플릿처럼 완전·부분 특수화가 가능합니다.

선택 순서: 특수화는 오버로드 해석에 참여하지 않는다

함수 템플릿에서 가장 헷갈리는 부분은 선택이 두 단계로 일어난다는 점입니다. 먼저 컴파일러는 기본 템플릿(primary template)과 일반 함수만 놓고 오버로드 해석을 합니다. 기본 템플릿이 하나 고른 뒤에야, 그 템플릿에 완전 특수화가 있는지 확인합니다. 특수화는 후보 목록에 따로 올라가지 않습니다.

이 때문에 특수화가 어느 기본 템플릿에 속하는지가 결과를 바꿉니다.

#include <iostream>
template <typename T> void f(T)  { std::cout << "f(T)\n"; }   // (1)
template <typename T> void f(T*) { std::cout << "f(T*)\n"; }  // (2)
// (1)의 T = int*에 대한 특수화: f(int*)는 (1)에서만 만들 수 있다
template <> void f<int*>(int*) { std::cout << "f<int*> 특수화\n"; }  // (3)

int main() {
    int x = 0;
    f(&x);   // 출력: f(T*)
}

f(&x)에서 오버로드 해석의 후보는 (1)과 (2)뿐이고, 둘 다 정확히 맞으므로 더 특수한 (2)가 선택됩니다. (3)은 (1)의 특수화라서 (1)이 선택되지 않는 한 호출되지 않습니다. 만약 (3)을 (2)보다 먼저 선언하면 그때는 (1)만 보이므로 여전히 (1)의 특수화가 되고, 결과는 같습니다. int*에 대한 특별한 동작을 원했다면 template <> void f<int>(int*)로 (2)를 특수화하거나, 더 간단하게 템플릿이 아닌 일반 함수 void f(int*)를 오버로드로 추가하는 편이 의도대로 동작합니다. 일반 함수는 템플릿과 변환 순위가 같으면 우선 선택되기 때문입니다. 함수 템플릿 특수화보다 오버로드를 권하는 이유가 이것입니다.


완전 특수화 (Full Specialization)

함수 템플릿 완전 특수화

template <> 다음에 구체적인 타입을 지정하며, 시그니처는 기본 템플릿에 그 타입을 대입한 모양과 같아야 합니다.

#include <iostream>
#include <string>
// 일반 템플릿
template <typename T>
T add(T a, T b) {
    return a + b;
}
// const char*는 +가 포인터 덧셈이 되므로 문자열 연결로 바꾼 특수화
template <>
const char* add<const char*>(const char* a, const char* b) = delete;

int main() {
    std::cout << add(3, 5) << "\n";                                  // 8
    std::cout << add(std::string("a"), std::string("b")) << "\n";    // ab
    // add("a", "b");   // 오류: 삭제된 특수화 (C 문자열끼리는 더할 수 없음)
}

std::string은 일반 템플릿의 a + b가 이미 문자열 연결이므로 특수화가 필요 없습니다. 반면 const char* 두 개를 +로 더하는 것은 포인터 덧셈이라 원래도 컴파일되지 않지만, 위처럼 특수화를 = delete로 선언하면 “이 타입은 지원하지 않는다”는 의도를 오류 메시지에 직접 드러낼 수 있습니다(함수 특수화에 = delete를 쓰는 것은 C++11부터 가능합니다). 일반 템플릿과 같은 일을 하는 특수화는 아무 이득 없이 코드만 늘리므로 만들지 않는 것이 좋습니다.

클래스 템플릿 완전 특수화

#include <iostream>
// 일반 템플릿
template <typename T>
class Wrapper {
    T value;
public:
    Wrapper(const T& v) : value(v) {}
    void print() const { std::cout << value << "\n"; }
};
// bool 완전 특수화: 출력 형식만 다르게
template <>
class Wrapper<bool> {
    bool value;
public:
    Wrapper(bool v) : value(v) {}
    void print() const {
        std::cout << (value ? "true" : "false") << "\n";
    }
};
int main() {
    Wrapper<int> w1(42);
    w1.print();  // 42
    Wrapper<bool> w2(true);
    w2.print();  // true
}

클래스 템플릿의 완전 특수화는 기본 템플릿과 아무 멤버도 공유하지 않는 별개의 클래스입니다. 멤버 구성과 메서드를 완전히 다르게 할 수 있는 대신, 기본 템플릿에 있던 멤버 함수를 특수화에서도 쓰려면 다시 정의해야 합니다. std::vector<bool>이 이 방식으로 비트 패킹 저장을 구현합니다.

void* 처리

void*는 역참조할 수 없으므로 포인터용 템플릿과 다르게 처리해야 합니다.

#include <iostream>
template <typename T>
void process(T* ptr) {
    if (ptr) std::cout << *ptr << "\n";
}
template <>
void process<void>(void* ptr) {
    if (ptr) std::cout << "void* at " << ptr << "\n";
}
int main() {
    int x = 42;
    process(&x);  // 42
    void* vp = &x;
    process(vp);  // void* at 0x...  (T = void로 추론되어 특수화가 쓰임)
}

process(vp)에서 T는 void로 추론되고, 기본 템플릿이 선택된 뒤 process<void> 특수화가 쓰입니다. 특수화가 없었다면 *ptr이 void를 역참조하려 해 인스턴스화 단계에서 컴파일 에러가 납니다. 같은 효과는 템플릿이 아닌 void process(void* ptr) 오버로드로도 낼 수 있습니다.


부분 특수화 (Partial Specialization)

클래스 템플릿 부분 특수화

부분 특수화는 “한 타입”이 아니라 T*나 T[N]처럼 특정 패턴에 맞는 타입 가족 전체에 다른 구현을 제공합니다. 함수 템플릿은 부분 특수화를 지원하지 않으므로, 이 절은 클래스 템플릿만 다룹니다.

포인터 타입 특수화

#include <iostream>
// 일반 템플릿
template <typename T>
class Container {
    T value;
public:
    Container(const T& v) : value(v) {}
    T get() const { return value; }
    void print() const { std::cout << value << "\n"; }
};
// T*에 대한 부분 특수화
template <typename T>
class Container<T*> {
    T* ptr;
public:
    Container(T* p) : ptr(p) {}
    T& get() const { return *ptr; }
    void print() const {
        if (ptr) std::cout << *ptr << "\n";
        else std::cout << "null\n";
    }
};
int main() {
    int x = 42;
    Container<int> c1(x);
    c1.print();  // 42
    Container<int*> c2(&x);
    c2.print();  // 42 (역참조해서 출력)
}

Container<int*>는 T* 패턴에 T = int로 맞아 포인터 특수화가 쓰이고, Container<int>는 일반 버전이 쓰입니다. 포인터 버전은 값을 복사하지 않고 가리키기만 하므로 get()이 참조를 돌려주도록 인터페이스도 바꿨습니다. 부분 특수화는 같은 이름 아래 인터페이스가 달라질 수 있어, 사용하는 쪽이 어느 버전이 선택될지 알아야 한다는 점은 주의할 부분입니다.

두 타입이 같을 때

#include <iostream>
template <typename T, typename U>
class Pair {
public:
    static constexpr bool same_type = false;
};
template <typename T>
class Pair<T, T> {
public:
    static constexpr bool same_type = true;
};
int main() {
    std::cout << Pair<int, double>::same_type << "\n";  // 0
    std::cout << Pair<int, int>::same_type << "\n";     // 1
}

Pair<T, T>는 두 타입 인자가 같을 때만 매칭됩니다. 표준 std::is_same이 바로 이 패턴으로 구현됩니다.

배열 타입 특수화

#include <iostream>
template <typename T>
class ArrayInfo {
public:
    static constexpr bool is_array = false;
    static constexpr size_t extent = 0;
};
template <typename T, size_t N>
class ArrayInfo<T[N]> {
public:
    static constexpr bool is_array = true;
    static constexpr size_t extent = N;
};
int main() {
    std::cout << ArrayInfo<int>::is_array << "\n";        // 0
    std::cout << ArrayInfo<int[5]>::is_array << "\n";     // 1
    std::cout << ArrayInfo<int[5]>::extent << "\n";       // 5
}

ArrayInfo<T[N]>은 “T 타입 N개짜리 배열”에 대해 특수화되며, 템플릿 인자 N이 패턴에서 추론됩니다. std::extent가 같은 방식으로 구현됩니다. 크기를 모르는 배열 int[]는 T[N]에 맞지 않으므로, 표준 구현은 T[]용 특수화를 하나 더 둡니다.

표준 타입 트레잇은 이렇게 만들어진다

<type_traits>의 상당수는 “기본 템플릿은 false/원래 타입, 부분 특수화는 true/변형된 타입”이라는 이 패턴 하나로 구현됩니다. 직접 만들어 보면 cv 한정자가 어디에 붙는지가 결과를 가른다는 점이 드러납니다.

template <typename T> struct my_is_pointer      : std::false_type {};
template <typename T> struct my_is_pointer<T*>  : std::true_type  {};
// int* const는 T*에 매칭되지 않음 → 표준 구현은 cv를 먼저 벗긴 뒤(remove_cv) 검사

template <typename T> struct my_remove_const          { using type = T; };
template <typename T> struct my_remove_const<const T> { using type = T; };

static_assert(my_is_pointer<const int*>::value);                   // 가리키는 대상이 const: 포인터 맞음
static_assert(std::is_same_v<my_remove_const<const int*>::type,
                             const int*>);                          // 포인터 자체는 const가 아님 → 그대로

const int*는 “const int를 가리키는 포인터”라서 최상위 const가 없고, remove_const가 아무것도 벗기지 않습니다. 반대로 int* const는 포인터 자체가 const라 T* 특수화에 바로 매칭되지 않습니다. 표준 std::is_pointer가 내부적으로 remove_cv_t<T>를 먼저 적용하는 이유가 이것입니다.

함수 템플릿: 오버로드로 부분 특수화 대체

함수 템플릿은 부분 특수화가 없으므로 더 구체적인 오버로드로 같은 효과를 냅니다. 특정 타입 하나를 다르게 처리할 때도, 앞 절에서 본 이유로 특수화보다 일반 함수 오버로드가 안전합니다.

#include <iostream>
// 일반
template <typename T>
void serialize(const T& value) {
    std::cout << "Generic serialize\n";
}
// 포인터 "부분 특수화" 대체
template <typename T>
void serialize(T* ptr) {
    if (ptr) serialize(*ptr);  // 역참조 후 재귀
    else std::cout << "null\n";
}
// C 문자열: 템플릿이 아닌 일반 함수 오버로드
inline void serialize(const char* str) {
    std::cout << "String: " << str << "\n";
}
int main() {
    int x = 42;
    serialize(x);    // Generic serialize
    serialize(&x);   // 포인터 → 역참조 → Generic serialize
    serialize("hi"); // String: hi
}

serialize("hi")의 인자는 const char[3]입니다. 만약 C 문자열 처리를 template <> void serialize<const char*>(const char* const&)처럼 일반 템플릿의 특수화로 만들었다면, 오버로드 해석에서 serialize(T*)(T = const char)가 const T& 버전보다 더 특수하다고 판정되어 포인터 버전이 선택되고, 문자열 대신 첫 글자 'h'를 역참조해 “Generic serialize”가 출력됩니다. 특수화는 후보에 들지 않기 때문입니다. 일반 함수 serialize(const char*)는 배열에서 포인터로의 변환이 정확한 일치로 취급되어 템플릿과 순위가 같고, 같으면 일반 함수가 우선하므로 의도대로 선택됩니다.


직렬화, bool 파싱, 포인터 구분에 특수화 적용하기

시나리오 1: 타입별 직렬화 포맷

JSON으로 직렬화할 때 int는 42, std::string은 "hello", bool은 true/false로 써야 합니다.

#include <iostream>
#include <string>
#include <sstream>
template <typename T>
std::string toJson(const T& value) {
    std::ostringstream oss;
    oss << value;
    return oss.str();
}
template <>
std::string toJson<bool>(const bool& value) {
    return value ? "true" : "false";
}
template <>
std::string toJson<std::string>(const std::string& value) {
    return "\"" + value + "\"";
}
int main() {
    std::cout << toJson(42) << "\n";        // 42
    std::cout << toJson(true) << "\n";      // true
    std::cout << toJson(std::string("hi")) << "\n";  // "hi"
}

기본 템플릿은 operator<<로 출력하고, bool과 std::string은 JSON 규격에 맞게 특수화했습니다. 다만 toJson("hi")처럼 문자열 리터럴을 넘기면 T가 char[3]으로 추론되어 std::string 특수화가 아니라 기본 템플릿이 쓰이고 따옴표 없이 출력됩니다. 특수화는 정확히 그 타입일 때만 쓰이고 변환을 거쳐 매칭되지 않기 때문입니다. 실제 JSON 출력이라면 문자열 안의 ", \, 제어 문자를 이스케이프하는 처리도 필요합니다.

시나리오 2: 설정 파싱 (bool 예외 처리)

"true", "yes", "1"을 bool로, "1,2,3"을 std::vector<int>로 파싱해야 합니다.

#include <string>
#include <sstream>
#include <stdexcept>
#include <vector>
template <typename T>
T parse(const std::string& str) {
    T result{};
    std::istringstream iss(str);
    if (!(iss >> result)) {
        throw std::invalid_argument("Invalid value: " + str);
    }
    return result;
}
template <>
bool parse<bool>(const std::string& str) {
    if (str == "true" || str == "yes" || str == "1") return true;
    if (str == "false" || str == "no" || str == "0") return false;
    throw std::invalid_argument("Invalid bool: " + str);
}
template <>
std::vector<int> parse<std::vector<int>>(const std::string& str) {
    std::vector<int> result;
    std::istringstream iss(str);
    std::string token;
    while (std::getline(iss, token, ',')) {
        result.push_back(std::stoi(token));
    }
    return result;
}
int main() {
    auto port = parse<int>("8080");             // 8080
    auto b = parse<bool>("yes");                // true
    auto v = parse<std::vector<int>>("1,2,3");  // {1,2,3}
}

스트림의 operator>>는 bool에 대해 기본적으로 0과 1만 받고(std::boolalpha를 켜면 true/false), "yes"나 콤마로 구분된 목록은 처리하지 못하므로 특수화가 필요합니다. 기본 템플릿에서 스트림 실패를 검사하지 않으면 parse<int>("abc")가 조용히 0을 돌려주므로, 설정 값처럼 잘못되면 안 되는 입력에는 위처럼 실패 시 예외를 던지는 편이 안전합니다. 또 operator>>는 공백에서 멈추므로 parse<std::string>("hello world")는 "hello"만 돌려준다는 점도 알아 두세요.

시나리오 3: 포인터 vs 값 구분

포인터일 때는 null 검사 후 역참조하고, 값일 때는 그대로 처리합니다.

#include <iostream>
template <typename T>
void logValue(const T& value) {
    std::cout << "[LOG] " << value << "\n";
}
template <typename T>
void logValue(T* ptr) {
    if (ptr) {
        std::cout << "[LOG] *ptr = " << *ptr << "\n";
    } else {
        std::cout << "[LOG] null pointer\n";
    }
}
int main() {
    int x = 42;
    logValue(x);    // [LOG] 42
    logValue(&x);   // [LOG] *ptr = 42
    logValue<int>(nullptr);  // [LOG] null pointer
}

logValue(const T&)와 logValue(T*)는 서로 다른 오버로드이고, 포인터를 넘기면 더 특수한 logValue(T*)가 선택됩니다. nullptr만 넘기면 T를 추론할 수 없으므로 logValue<int>(nullptr)처럼 타입을 명시해야 합니다. 함수 이름을 log로 짓지 않은 것은 <cmath>의 log와 겹쳐 엉뚱한 함수가 선택되는 것을 피하기 위해서입니다.

시나리오 4: 컨테이너 타입 감지

std::vector, std::list 같은 컨테이너인지에 따라 다른 처리를 하고 싶을 때는 부분 특수화로 타입 트레잇을 만들고 if constexpr로 분기합니다.

#include <iostream>
#include <vector>
#include <list>
#include <type_traits>
template <typename T, typename = void>
struct is_container : std::false_type {};
template <typename T>
struct is_container<T, std::void_t<
    typename T::value_type,
    typename T::iterator,
    decltype(std::declval<T>().begin()),
    decltype(std::declval<T>().end())
>> : std::true_type {};
template <typename T>
void process(const T& value) {
    if constexpr (is_container<T>::value) {
        std::cout << "Container with " << value.size() << " elements\n";
    } else {
        std::cout << "Single value: " << value << "\n";
    }
}
int main() {
    process(42);                    // Single value: 42
    process(std::vector<int>{1,2,3});  // Container with 3 elements
}

is_container는 void_t를 이용한 SFINAE로, value_type, iterator, begin(), end()가 모두 유효한 타입일 때만 부분 특수화가 선택되게 합니다. 하나라도 없으면 치환이 실패해 특수화가 조용히 후보에서 빠지고 기본 템플릿(false_type)이 쓰입니다. std::string도 이 조건을 만족하므로 컨테이너로 판정된다는 점은 의도에 따라 따로 처리해야 합니다.


TypeId, 빈 버퍼, 삭제자, JSON 직렬화 예제

예제 1: 읽기 쉬운 타입 이름

#include <iostream>
#include <string>
#include <typeinfo>
#include <vector>
template <typename T>
struct TypeId {
    static std::string name() {
        return typeid(T).name();
    }
};
// 자주 쓰는 타입 특수화 (가독성)
template <>
struct TypeId<int> {
    static std::string name() { return "int"; }
};
template <>
struct TypeId<double> {
    static std::string name() { return "double"; }
};
template <>
struct TypeId<std::string> {
    static std::string name() { return "std::string"; }
};
int main() {
    std::cout << TypeId<int>::name() << "\n";           // int
    std::cout << TypeId<std::vector<int>>::name() << "\n";  // 컴파일러 의존
}

typeid(T).name()이 돌려주는 문자열은 구현마다 다르고, GCC와 Clang은 i, St6vectorIiSaIiEE 같은 맹글링된 이름을 돌려줍니다. 자주 쓰는 타입에 대해서만 특수화로 사람이 읽기 쉬운 이름을 돌려주게 한 예입니다.

예제 2: 크기 0을 위한 특수화

#include <iostream>
template <typename T, size_t N>
struct Buffer {
    T data[N];
    static constexpr size_t size = N;
};
// N=0인 경우 특수화 (빈 버퍼)
template <typename T>
struct Buffer<T, 0> {
    static constexpr size_t size = 0;
    // data 멤버 없음
};
int main() {
    Buffer<int, 10> b1;
    Buffer<int, 0> b0;
    std::cout << b1.size << " " << b0.size << "\n";  // 10 0
}

이 특수화는 메모리 절약보다 정확성 문제를 풉니다. 표준 C++에서 크기가 0인 배열 T data[0]은 허용되지 않으므로(GCC 확장으로만 허용), 특수화 없이 Buffer<int, 0>을 만들면 컴파일 에러가 나거나 이식성이 없는 코드가 됩니다. 특수화된 빈 구조체도 sizeof는 0이 아니라 1이지만, 다른 클래스의 멤버로 둘 때 [[no_unique_address]](C++20)를 붙이면 공간을 차지하지 않게 할 수 있습니다. std::array<T, 0>도 같은 이유로 크기 0을 따로 처리합니다.

예제 3: 배열용 삭제자

#include <type_traits>
template <typename T>
struct Deleter {
    void operator()(T* ptr) const {
        delete ptr;
    }
};
template <typename T>
struct Deleter<T[]> {
    void operator()(T* ptr) const {
        delete[] ptr;
    }
};
template <typename T, typename D = Deleter<T>>
class UniquePtr {
    using element_type = std::remove_extent_t<T>;   // int[] → int
    element_type* ptr;
    D deleter;
public:
    explicit UniquePtr(element_type* p) : ptr(p) {}
    UniquePtr(const UniquePtr&) = delete;              // 복사하면 두 번 해제되므로 금지
    UniquePtr& operator=(const UniquePtr&) = delete;
    ~UniquePtr() { if (ptr) deleter(ptr); }
    element_type* get() const { return ptr; }
};
int main() {
    UniquePtr<int> p1(new int(42));
    UniquePtr<int[]> p2(new int[5]);
    // p1 소멸 시 delete, p2 소멸 시 delete[]
}

Deleter<T[]>는 배열 타입에 대한 부분 특수화로 delete[]를 씁니다. new[]로 할당한 메모리를 delete로 해제하면 정의되지 않은 동작이므로 이 구분은 필수입니다. 주의할 점은 UniquePtr<int[]>에서 T가 int[]라 멤버를 T*로 선언하면 “int 배열을 가리키는 포인터” int(*)[]가 되어 new int[5]의 결과(int*)를 받을 수 없다는 것입니다. 그래서 std::remove_extent_t로 원소 타입을 꺼내 포인터 타입을 만들었습니다. 표준 std::unique_ptr와 std::default_delete도 같은 구조입니다. 실제로는 이동 생성자와 이동 대입도 구현해야 하지만 예제에서는 생략했습니다.

예제 4: JSON 직렬화 전체 예제

#include <iostream>
#include <string>
#include <vector>
#include <sstream>
template <typename T>
struct JsonSerializer {
    static std::string serialize(const T& value) {
        std::ostringstream oss;
        oss << value;
        return oss.str();
    }
};
template <>
struct JsonSerializer<bool> {
    static std::string serialize(bool value) {
        return value ? "true" : "false";
    }
};
template <>
struct JsonSerializer<std::string> {
    static std::string serialize(const std::string& value) {
        return "\"" + value + "\"";
    }
};
template <typename T>
struct JsonSerializer<std::vector<T>> {
    static std::string serialize(const std::vector<T>& vec) {
        std::string result = "[";
        for (size_t i = 0; i < vec.size(); ++i) {
            if (i > 0) result += ",";
            result += JsonSerializer<T>::serialize(vec[i]);
        }
        result += "]";
        return result;
    }
};
int main() {
    std::cout << JsonSerializer<int>::serialize(42) << "\n";
    std::cout << JsonSerializer<bool>::serialize(true) << "\n";
    std::cout << JsonSerializer<std::string>::serialize("hi") << "\n";
    std::vector<int> v = {1, 2, 3};
    std::cout << JsonSerializer<std::vector<int>>::serialize(v) << "\n";
}

실행 결과:

42
true
"hi"
[1,2,3]

함수 템플릿 대신 클래스 템플릿의 정적 멤버 함수로 만들면 std::vector<T> 같은 패턴에 부분 특수화를 쓸 수 있습니다. JsonSerializer<std::vector<T>>는 원소 타입의 JsonSerializer<T>를 다시 호출하므로 std::vector<std::vector<int>> 같은 중첩도 처리됩니다. 또 타입을 명시해 호출하므로 JsonSerializer<std::string>::serialize("hi")처럼 리터럴을 넘겨도 std::string으로 변환되어 문자열 버전이 쓰입니다. 앞의 toJson("hi")와 다른 점입니다.


시그니처 불일치, 함수 부분 특수화, 모호한 매칭 에러

에러 1: 특수화 선언과 기본 템플릿 불일치

GCC 기준 증상은 error: template-id 'foo<int>' for 'void foo(int, int)' does not match any template declaration 같은 메시지입니다.

// 선언
template <typename T>
void foo(T);
// ❌ 잘못된 특수화: 매개변수 개수가 다름
template <>
void foo<int>(int x, int y);

특수화는 “같은 템플릿의 다른 구현”이므로 기본 템플릿의 T에 해당 타입을 대입한 시그니처와 정확히 같아야 합니다. 위 경우 template <> void foo<int>(int x);가 올바른 형태입니다. 매개변수 개수가 다른 함수가 필요하다면 특수화가 아니라 오버로드를 선언해야 합니다.

에러 2: 아무것도 특수화하지 않은 부분 특수화

부분 특수화의 인자 목록이 기본 템플릿과 똑같으면 partial specialization ... does not specialize any template arguments 에러가 납니다.

template <typename T, typename U>
class Pair { };
// ❌ 기본 템플릿과 같은 모양: 특수화한 것이 없음
template <typename T, typename U>
class Pair<T, U> { };
// ✅ 두 인자가 같다는 제약을 추가
template <typename X>
class Pair<X, X> { };
// ✅ 두 번째 인자를 int로 고정
template <typename T>
class Pair<T, int> { };

부분 특수화는 기본 템플릿보다 더 구체적이어야 합니다. 다만 아래 에러 6에서 보듯 Pair<X, X>와 Pair<T, int>를 함께 두면 Pair<int, int>에서 새로운 문제가 생깁니다.

에러 3: 함수 템플릿 부분 특수화 시도

C++에서 함수 템플릿의 부분 특수화는 문법상 허용되지 않습니다.

template <typename T>
void bar(T value) { }
// ❌ 에러: 함수 템플릿 부분 특수화 불가
template <typename T>
void bar<T*>(T* ptr) { }

template <> 없이 함수 이름 뒤에 <T*>를 붙이는 것이 바로 부분 특수화 문법이라 거부됩니다. 꺾쇠를 떼고 template <typename T> void bar(T* ptr)로 쓰면 새 함수 템플릿 오버로드가 되어 같은 효과를 냅니다.

에러 4: 특수화 중복 정의와 가시성

// a.cpp
template <>
void foo<int>(int x) { /* 구현 A */ }
// b.cpp
template <>
void foo<int>(int x) { /* 구현 B */ }  // ❌ 중복 정의

함수 템플릿의 완전 특수화는 템플릿이 아니라 일반 함수처럼 취급됩니다. 그래서 두 .cpp에 정의하면 링크 단계에서 multiple definition 에러가 나고, 헤더에 inline 없이 정의해 여러 파일에서 포함해도 같은 에러가 납니다. 특수화는 한 .cpp에만 정의하고 헤더에는 선언만 두거나, 헤더에 inline으로 정의합니다.

// foo.h
template <typename T>
void foo(T x);
template <>
inline void foo<int>(int x) { /* 구현 */ }  // ✅ 헤더에 inline

반대로 특수화를 헤더에서 선언하지 않고 한 .cpp에만 두면, 다른 번역 단위는 특수화가 있는지 모른 채 기본 템플릿을 인스턴스화합니다. 이것은 진단이 필요 없는 오류(ill-formed, no diagnostic required)라서 빌드는 되지만 파일마다 다른 함수가 호출될 수 있습니다. 특수화 선언은 기본 템플릿을 쓰는 모든 곳에서 보이도록 헤더에 두어야 합니다.

에러 5: 재귀 특수화 종료 조건 누락

template <int N>
struct Factorial {
    static constexpr long long value = N * Factorial<N - 1>::value;
};
// ❌ 종료 조건이 없으면 N이 음수로 내려가며 끝없이 인스턴스화됨
// GCC: error: template instantiation depth exceeds maximum of 900

template <>
struct Factorial<0> {   // ✅ 종료 조건
    static constexpr long long value = 1;
};
static_assert(Factorial<5>::value == 120);

재귀적으로 자기 자신을 인스턴스화하는 템플릿은 반드시 재귀가 도달하는 지점에 완전 특수화(또는 부분 특수화)를 두어야 합니다. 종료 조건이 재귀 경로 위에 있는지도 확인해야 합니다. 예를 들어 Foo<T*>처럼 포인터를 계속 붙여 가는 재귀에 Foo<void*>를 종료 조건으로 두면, Foo<int>에서 시작한 재귀는 int*, int**로만 진행해 void*에 영영 닿지 않습니다. C++17 이후에는 이런 계산을 constexpr 함수로 쓰는 편이 읽기 쉽습니다.

에러 6: 두 부분 특수화가 동시에 매칭되는 모호성

GCC의 ambiguous template instantiation, Clang의 ambiguous partial specializations 에러입니다. 부분 특수화끼리는 한쪽이 다른 쪽보다 확실히 더 특수할 때만 하나가 선택됩니다. Pair<T, T>(두 타입이 같음)와 Pair<T, int>(두 번째가 int)는 어느 쪽도 다른 쪽을 포함하지 않으므로, 둘 다 만족하는 Pair<int, int>에서 컴파일러가 고르지 못합니다.

template <typename T, typename U> struct Pair        { /* 일반 */ };
template <typename T>             struct Pair<T, T>   { /* 같은 타입 */ };
template <typename T>             struct Pair<T, int> { /* 두 번째가 int */ };
// Pair<int, int> → 모호함
template <> struct Pair<int, int> { /* ✅ 겹치는 지점을 완전 특수화로 명시 */ };

겹치는 조합을 완전 특수화로 따로 정의하거나, 두 특수화 중 하나가 다른 쪽보다 확실히 좁도록 조건을 다시 짭니다.


인스턴스화 비용 줄이기: extern template과 if constexpr

extern template로 중복 인스턴스화 방지

템플릿은 사용하는 번역 단위마다 인스턴스화됩니다. 여러 .cpp에서 heavyFunc<int>를 쓰면 각 파일이 같은 코드를 따로 컴파일하고, 링커가 중복을 하나로 합칩니다. 무거운 템플릿이라면 extern template으로 인스턴스화를 한 곳에 몰아 컴파일 시간을 줄일 수 있습니다.

// my_template.h
template <typename T>
void heavyFunc(T value) { /* 무거운 구현 */ }
extern template void heavyFunc<int>(int);       // 다른 곳에서 인스턴스화됨을 선언
extern template void heavyFunc<double>(double);
// my_template.cpp
#include "my_template.h"
template void heavyFunc<int>(int);   // 명시적 인스턴스화 정의 (여기서 한 번만)
template void heavyFunc<double>(double);
// main.cpp
#include "my_template.h"
int main() {
    heavyFunc(42);     // my_template.cpp의 인스턴스 사용
    heavyFunc(3.14);
}

extern template 선언은 각 .cpp에 흩어 두기보다 위처럼 헤더에 두는 것이 일반적입니다. 그래야 헤더를 포함하는 모든 파일이 빠짐없이 인스턴스화를 생략합니다. 명시적 인스턴스화 정의가 있는 .cpp에서는 extern 선언이 먼저 보여도 정의가 우선하므로 문제없습니다. 다만 컴파일러는 인라인 최적화를 위해 extern template이 붙은 함수도 본문을 볼 수 있으면 인라인할 수 있으므로, 이 기법은 주로 컴파일 시간과 오브젝트 파일 크기를 줄이는 용도입니다. 자세한 내용은 컴파일 타임 최적화에서 다룹니다.

if constexpr로 타입별 분기

C++17 if constexpr로 컴파일 시점에 분기하면, 템플릿 안에서 선택되지 않은 브랜치는 인스턴스화되지 않습니다.

template <typename T>
void process(const T& value) {
    if constexpr (std::is_pointer_v<T>) {
        std::cout << *value << "\n";  // 포인터일 때만
    } else {
        std::cout << value << "\n";
    }
}

T가 포인터가 아니면 *value가 들어 있는 브랜치는 인스턴스화되지 않으므로, int에 대해 역참조 에러가 나지 않습니다. 일반 if였다면 두 브랜치가 모두 컴파일되어야 하므로 process(42)에서 바로 에러가 납니다. 분기가 한두 개이고 구현의 대부분을 공유한다면 if constexpr가, 타입마다 구현 전체나 클래스 구조 자체가 달라진다면 특수화가 읽기 쉽습니다.

특수화와 if constexpr는 모두 컴파일 시점에 타입이 정해질 때 쓰는 도구이고, 실행 비용이 없습니다. 타입이 실행 중에야 정해진다면 std::variant와 std::visit, 가상 함수 같은 런타임 다형성이 맞습니다.


타입 트레잇과 태그 디스패치에 특수화 쓰기

패턴 1: 타입 트레잇 + if constexpr

template <typename T, typename = void>
struct has_to_string : std::false_type {};
template <typename T>
struct has_to_string<T, std::void_t<decltype(std::declval<T>().to_string())>>
    : std::true_type {};
template <typename T>
std::string toString(const T& value) {
    if constexpr (has_to_string<T>::value) {
        return value.to_string();
    } else {
        return std::to_string(value);  // 산술 타입 등
    }
}

has_to_string은 to_string() 멤버 함수가 있는 타입에서만 부분 특수화가 선택되도록 만든 트레잇이고, toString은 그 결과로 if constexpr 분기를 합니다. to_string()도 없고 std::to_string도 받지 않는 타입을 넘기면 else 브랜치에서 컴파일 에러가 나므로, 필요하면 static_assert로 더 읽기 쉬운 메시지를 넣을 수 있습니다. C++20에서는 같은 검사를 requires { value.to_string(); } 같은 requires 표현식으로 더 짧게 쓸 수 있습니다.

패턴 2: 태그 디스패치

struct contiguous_tag {};
struct node_based_tag {};
template <typename T>
struct container_tag {
    using type = contiguous_tag;   // 기본값: 연속 메모리 컨테이너로 가정
};
template <typename T, typename A>
struct container_tag<std::list<T, A>> {
    using type = node_based_tag;
};
template <typename C>
void algorithm_impl(const C& c, contiguous_tag) {
    std::cout << "contiguous-optimized\n";
}
template <typename C>
void algorithm_impl(const C& c, node_based_tag) {
    std::cout << "node-based\n";
}
template <typename C>
void algorithm(const C& c) {
    algorithm_impl(c, typename container_tag<C>::type{});
}

container_tag의 특수화로 컨테이너 종류를 빈 태그 타입으로 바꾸고, algorithm_impl은 그 태그 타입으로 오버로드합니다. 태그 객체는 크기가 의미 없는 빈 구조체라 최적화 후에는 비용이 남지 않습니다. 표준 라이브러리는 이 기법을 반복자 범주에 씁니다. 예를 들어 std::advance는 std::iterator_traits<It>::iterator_category가 random_access_iterator_tag이면 it += n 한 번으로, bidirectional_iterator_tag이면 한 칸씩 이동하는 구현을 고릅니다. C++17 이후에는 같은 분기를 if constexpr로, C++20에서는 콘셉트로 표현하는 경우가 많아졌습니다.


같이 보면 좋은 글


참고 자료