본문으로 건너뛰기
뒤로가기

[C++] Templates: 기초부터 컴파일 타임 타입 검증까지

1. 템플릿 개념

템플릿은 타입이나 컴파일 타임 값을 매개변수로 받아 같은 규칙을 여러 타입과 값에 적용합니다. 컴파일 타임에 구체적인 타입이나 값을 결정하고 인스턴스화를 진행하기 때문에 일반적인 C++ 코드와 다른 지점이 많습니다. 이 글에서는 헷갈리기 쉬운 개념을 하나씩 살펴보겠습니다.

1.1 템플릿이 필요한 이유

타입만 다르고 동작이 같은 함수를 작성하면 다음과 같이 같은 본문을 타입별로 반복해서 작성해야 합니다. C++에서는 오버로딩을 지원하므로 컴파일러는 함수 인자를 보고 적절한 오버로드를 선택해 호출합니다.

#include <iostream>

int Max(const int lhs, const int rhs)
{
    return lhs > rhs ? lhs : rhs;
}

double Max(const double lhs, const double rhs)
{
    return lhs > rhs ? lhs : rhs;
}

int main()
{
    std::cout << Max(3, 7) << '\n';
    std::cout << Max(2.5, 1.5) << '\n';
}template_lab.cpp

두 함수는 매개변수와 반환 타입만 다르고 비교와 반환 규칙은 같습니다. 문제는 새로운 타입을 지원할 때마다 같은 본문을 다시 작성해야 하고 이에 따라 수정해야 하는 코드도 많아지게 됩니다.

이런 상황에서는 템플릿을 사용할 수 있습니다. 함수 템플릿은 달라지는 타입을 템플릿 매개변수로 분리합니다.

#include <iostream>

// 템플릿 매개변수 T
template <typename T>
T Max(const T lhs, const T rhs)
{
    return lhs > rhs ? lhs : rhs;
}

int main()
{
    // 컴파일러는 함수 템플릿을 호출하며 전달한 함수 인자의 타입을 보고
    // 템플릿 매개변수 T를 추론하여 필요한 특수화를 만들어 호출
    std::cout << Max(3, 7) << '\n';
    std::cout << Max(2.5, 1.5) << '\n';
}template_lab.cpp

첫 번째 호출에서는 Tint, 두 번째 호출에서는 double로 결정됩니다. 컴파일러는 하나의 템플릿 정의로부터 호출자가 전달한 인자의 타입을 보고 필요한 Max<int>Max<double>을 구체화합니다.

Max(3, 7)       → T = int    → Max<int>
Max(2.5, 1.5)   → T = double → Max<double>

두 호출의 실행 결과는 다음과 같습니다.

7
2.5

이렇게 하나의 템플릿 정의로부터 타입별로 Max<int>, Max<double>과 같이 서로 다른 타입이 적용된 별도의 함수가 만들어지는 것을 템플릿 특수화라고 합니다.

1.2 템플릿 정의와 인스턴스화

템플릿을 이해하려면 컴파일러가 템플릿 정의를 읽는 시점과 구체적인 타입을 적용하는 시점을 구분해야 합니다. 여기서 정의 시점은 개발자가 소스 코드를 작성하는 시간이 아니라, 컴파일러가 템플릿 정의를 읽고 분석하는 컴파일 절차에 포함된 단계를 의미합니다.

다음과 같은 함수 템플릿이 있습니다. 복잡한 동작은 없고 전달한 값에서 size()를 호출합니다.

#include <iostream>

template <typename T>
void PrintSize(const T &value)
{
    std::cout << value.size() << '\n';
}

int main()
{
    std::cout << "Template defined\n";
}template_lab.cpp

이 함수가 일반 함수라고 생각해보면, 인자로 전달된 값의 타입에 size()라는 함수가 없다면 컴파일 시점에 오류가 발생하고 빌드도 실패합니다. 하지만 함수 템플릿은 호출하기 전까지 구체적인 타입 특수화가 만들어지지 않기 때문에 이 코드는 정상적으로 빌드되고 실행됩니다.

여기에서 컴파일러가 하는 것이 정의 시점 검사입니다. 이 단계에서는 템플릿 자체의 문법과 템플릿 파라미터 T에 의존하지 않는 이름과 표현식을 검사합니다.

반면 value.size()T가 어떤 타입인지에 따라 유효성이 달라지는 표현식이므로 구체적인 타입이 정해질 때까지 검사가 미뤄집니다.

따라서 정의 시점에는 템플릿의 기본 문법과 템플릿 인자에 의존하지 않는 부분을 검사합니다.

이제 std::string 객체를 전달해 실제 함수를 호출하는 예시를 살펴보겠습니다.

#include <iostream>
#include <string>

template <typename T>
void PrintSize(const T &value)
{
    std::cout << value.size() << '\n';
}

int main()
{
    const std::string text = "template";
    PrintSize(text);
}template_lab.cpp

이번에는 std::string 타입의 변수 textPrintSize의 인자로 전달합니다.

컴파일러는 PrintSize(text)에 전달된 인자 text의 정적 타입을 기반으로 템플릿 매개변수 Tstd::string으로 추론합니다.

타입 추론이 완료되면 컴파일러는 추론한 타입을 템플릿 매개변수에 치환하여 PrintSize<std::string>이라는 구체적인 함수를 만듭니다. 이 함수가 전달된 함수 인자를 받을 수 있는지 확인한 뒤 함수 본문을 인스턴스화합니다.

함수 본문을 인스턴스화하는 과정에서 size() 호출이 유효한지 검사합니다.

PrintSize(text)
→ text의 정적 타입은 std::string
→ T = std::string으로 치환
→ PrintSize<std::string> 인스턴스화
→ value.size() 검사

이렇게 템플릿에 구체적인 인자가 적용된 결과를 템플릿 특수화라고 합니다. PrintSize<std::string>은 함수 템플릿에서 만들어진 구체적인 특수화이며, 인스턴스화는 이 특수화를 템플릿 정의로부터 만드는 과정입니다.

함수 템플릿               템플릿 특수화
PrintSize<T>  ─────────→  PrintSize<std::string>
              인스턴스화

특수화가 항상 별도 구현을 개발자가 직접 작성한다는 뜻은 아닙니다. 컴파일러가 일반 템플릿으로부터 만든 PrintSize<std::string>도 특수화입니다.

특정 인자에 대한 구현을 개발자가 직접 제공하는 명시적 특수화와 일부 인자 형태에 대한 부분 특수화는 1.4절에서 살펴봅니다.

이번에는 std::string이 아니라 int를 사용하는 인스턴스화 과정을 살펴보겠습니다.

int number = 10;
PrintSize(number);

PrintSize(number)를 작성하면 컴파일러는 타입 매개변수를 int로 추론하여 PrintSize<int>라는 함수 템플릿 특수화를 구성합니다.

치환된 함수 선언은 인자를 받을 수 있으므로 호출 후보가 됩니다. 이후 함수 본문을 인스턴스화하면 intsize()가 없다는 사실을 확인합니다. 이렇게 함수가 인스턴스화된 뒤 발생하는 오류는 컴파일 오류로 처리됩니다.

T = int 결정                    성공
PrintSize<int>   본문 인스턴스화   실패

1.3 템플릿 매개변수와 인자

앞서 템플릿 파라미터, 함수 인자를 설명하면서 나온 매개변수(parameter, 파라미터)와 인자(argument)에 대해 좀 더 알아보도록 하겠습니다.

일반 함수에서 매개변수는 함수가 입력을 받을 자리의 타입과 전달 방식을 정의합니다. 인자는 함수를 호출할 때 해당 매개변수에 전달하는 값, 객체 또는 표현식입니다.

void PrintNumber(double value);

int number = 10;
PrintNumber(number);

double value는 함수 매개변수이고 number는 함수 인자입니다. 이 호출에서는 int 값이 double로 변환된 뒤 별도의 값 매개변수 value를 초기화합니다.

참조 매개변수에는 객체가 복사되는 대신 참조가 바인딩됩니다.

void Increase(int &value)
{
    ++value;
}

int number = 10;
Increase(number);

int &valuenumber 객체에 바인딩됩니다. value는 새로운 int 객체가 아니라 number의 별칭이므로 참조 바인딩 자체는 복사 생성자나 이동 생성자를 호출하지 않습니다.

변환   : 인자를 매개변수 타입에 맞는 값으로 바꾸어 초기화
바인딩  : 참조 매개변수를 기존 객체나 임시 객체에 연결

템플릿에서도 매개변수와 인자의 관계는 같습니다. 매개변수는 받을 대상의 종류와 자리를 정의하고, 인자는 그 자리에 구체적인 대상을 전달합니다. 다만 템플릿 매개변수에는 런타임 객체가 아니라 타입이나 컴파일 타임 값을 전달합니다. (컴파일 타임이라는 것이 중요합니다.)

#include <cstddef>
#include <iostream>

template <typename T, std::size_t Count>
void PrintRepeated(const T &value)
{
    for (std::size_t index = 0; index < Count; ++index)
    {
        std::cout << value << ' ';
    }

    std::cout << '\n';
}

int main()
{
    PrintRepeated<int, 3>(7);
    PrintRepeated<double, 2>(1.5);
}template_lab.cpp

첫 번째 호출에는 두 종류의 템플릿 인자와 하나의 함수 인자가 있습니다.

PrintRepeated<int, 3>(7);
선언 또는 표현식분류의미
typename T타입 템플릿 매개변수타입을 받을 자리
std::size_t Count비타입 템플릿 매개변수컴파일 타임 값을 받을 자리
int타입 템플릿 인자T에 전달되는 타입
3비타입 템플릿 인자Count에 전달되는 값
const T &value함수 매개변수호출 인자를 받을 자리
7함수 인자value에 전달되는 값

템플릿 인자는 어떤 함수 특수화를 구성할지 컴파일 시점에 결정합니다. 템플릿 인자는 호출 시 명시하거나, 템플릿 매개변수가 함수 매개변수 타입에 나타나는 경우에는 함수 인자에서 추론할 수 있습니다.

템플릿 특수화가 만들어진 뒤 함수 인자가 함수 매개변수에 전달됩니다. 일반적인 실행에서는 7이 런타임에 전달되지만, 상수 표현식 문맥의 함수 호출은 컴파일 타임에 계산될 수도 있습니다.

템플릿 인자 int, 3
→ PrintRepeated<int, 3> 구성

함수 인자 7
→ 구성된 함수의 value에 전달

함수 템플릿 호출이 처리되는 순서

이제 템플릿이 특수화되어 호출되는 순서를 정리하겠습니다. 구체적인 함수 선언이 만들어진 뒤에는 일반 함수와 마찬가지로 함수 인자가 함수 매개변수로 전달됩니다.

1. 명시적으로 지정된 템플릿 인자 확인
2. 나머지 템플릿 인자를 함수 인자의 정적 타입에서 추론
3. 템플릿 매개변수에 결정된 템플릿 인자 치환
4. 치환된 함수 선언으로 오버로드 후보 형성
5. 함수 인자의 변환과 참조 바인딩 가능 여부 확인
6. 호출 가능한 후보 중 최적 후보 선택
7. 선택된 함수 템플릿 특수화의 본문 인스턴스화
8. 일반적인 실행에서는 함수 인자를 전달하여 호출

여기서 템플릿 인자 추론 과정에서 함수 인자가 여러 개 있는 경우, 임의의 공통 타입으로 변환하지 않는다는 것을 봐야 합니다.

template <typename T>
void Compare(T lhs, T rhs);

Compare(1, 2.5); // T를 하나로 추론할 수 없음

첫 번째 인자로부터는 T = int, 두 번째 인자로부터는 T = double이라는 결과를 얻는다고 생각할 수 있겠지만, 템플릿 매개변수 T는 하나만 존재하므로 이 추론은 실패합니다.

첫 번째 인자 1   → T = int
두 번째 인자 2.5 → T = double

하나의 T를 결정할 수 없음

여러 함수 인자를 받는다면 다음과 같이 템플릿 인자를 명시하여 템플릿 인자 추론을 생략하고, 함수 인자의 호출 과정에서 발생하는 변환을 이용할 수 있습니다.

Compare<double>(1, 2.5);

템플릿 인자 추론을 할 필요 없이 바로 T = double로 치환하면 Compare<double>(double, double)이라는 함수 후보가 형성됩니다. 그다음 일반 함수의 규칙에 따라 int 인자 1double로 변환할 수 있는지 검사합니다. 변환할 수 있으므로 호출 가능한 후보가 됩니다.

T = double 명시
→ 함수 선언에 T를 double로 치환
→ Compare<double>(double, double) 후보 형성
→ int 인자 1을 double로 변환 가능
→ 호출 가능한 후보

템플릿 인자를 일부만 명시했다면 나머지 인자는 계속 추론하거나 기본 템플릿 인자에서 얻습니다.

template <typename T, typename U>
void PrintPair(T lhs, U rhs);

PrintPair<int>(10, 2.5);

이 호출에서는 T = int를 명시했고 U = double은 두 번째 함수 인자의 정적 타입에서 추론합니다.

추론/치환 실패와 본문 오류

함수 템플릿의 후보(오버로딩 함수)를 만드는 과정에서 실패하면 해당 후보를 선택 대상에서 제외할 수 있습니다. 하지만 오버로드된 다른 후보 함수가 하나도 없다면 그 호출 표현식은 컴파일 오류로 처리될 수 있습니다.

또한 치환 실패가 컴파일 오류 대신 후보 제외로 처리되는 범위는 SFINAE가 적용되는 문맥으로 제한됩니다. 예를 들어 함수 선언의 반환 타입처럼 치환의 즉시 문맥에서 유효하지 않은 타입이나 표현식이 확인되면 해당 함수의 본문을 만들 필요가 없고, 이 과정에서 해당 후보를 제외할 수 있습니다.

// decltype은 변수나 표현식의 타입을 컴파일 시점에 알아내는 키워드
// int에 value.size()가 없으면 이 템플릿 특수화는 호출 후보에서 제외함
template <typename T>
auto GetSize(const T &value) -> decltype(value.size())
{
    return value.size();
}

std::size_t GetSize(...)
{
    return 0;
}

GetSize(10)에서 T = int를 치환하면 decltype(value.size())를 형성할 수 없습니다. 이 실패가 함수 선언의 치환 과정에서 발생하므로 함수 템플릿 후보를 제외하고, 남아 있는 GetSize(...)를 선택할 수 있습니다.

T = int 추론
→ 함수 선언에 int 치환
→ decltype(value.size()) 형성 실패
→ 함수 템플릿 후보 제외
→ 다른 GetSize(...) 후보 선택

반면 유효하지 않은 표현식이 선택된 함수의 본문에만 있다면 후보 선택이 끝난 뒤 본문을 인스턴스화하는 과정에서 발견됩니다.

template <typename T>
void PrintSize(const T &value)
{
    std::cout << value.size();
}

PrintSize(10);

이 경우 PrintSize<int>(const int &)라는 함수 선언은 유효하고 10const int &에 바인딩할 수 있습니다. 따라서 후보 선택까지 통과한 뒤 PrintSize<int>의 본문을 인스턴스화하며 intsize()가 없다는 컴파일 오류가 발생합니다.

실패 위치결과
템플릿 인자 추론해당 함수 템플릿 후보를 형성할 수 없음
SFINAE가 적용되는 즉시 문맥의 치환해당 후보를 선택 대상에서 제외
선택된 특수화의 본문 인스턴스화컴파일 오류

이 차이는 이후 살펴볼 SFINAE와 std::void_t의 출발점입니다. 두 기법은 검사하려는 타입이나 표현식을 치환이 일어나는 문맥에 배치하고, 유효하지 않은 경우를 전체 컴파일 실패가 아닌 다른 후보 선택으로 연결합니다.

1.4 기본 템플릿과 특수화

이제 특수화를 자세히 살펴보겠습니다. C++ 표준에서 특수화는 개발자가 별도로 작성한 템플릿 코드에만 적용되는 단어가 아닙니다. 기본 템플릿에서 인자를 추론해 만들어진 결과도 특수화 범주에 포함됩니다. 따라서 특수화는 크게 다음과 같이 두 가지로 분류할 수 있습니다.

  1. 컴파일러가 템플릿을 암시적으로 인스턴스화하여 구체적인 특수화를 만듭니다.
  2. 개발자가 특정 인자나 인자 패턴에 대응하는 정의를 명시적 특수화 또는 부분 특수화로 작성합니다.

일반적인 출발점이 되는 템플릿을 primary template이라고 하며, 이 글에서는 기본 템플릿이라고 부르겠습니다.

template <typename T>
struct TypeCategory
{
    static constexpr int value = 0;
};

TypeCategory<int> category;template_lab.cpp

기본 템플릿 TypeCategory가 있고, 타입 인자로 int를 사용한 경우입니다.

TypeCategory<int>를 사용하는 데 필요한 클래스 정의를 개발자가 별도로 작성하지 않았으므로 컴파일러는 기본 템플릿에서 T = int인 특수화를 암시적으로 인스턴스화합니다.

기본 템플릿 TypeCategory<T>
→ 템플릿 인자 int 적용
→ TypeCategory<int> 특수화 인스턴스화

이와 달리 특정 경우에 사용할 정의를 개발자가 직접 제공할 수도 있습니다. 모든 템플릿 인자를 확정하면 명시적 특수화이고, 일부 정보를 열어둔 채 특정 패턴을 지정하면 부분 특수화입니다.

템플릿 매개변수의 종류와 문법

특수화 문법을 이해하려면 <...>가 나타나는 두 위치를 먼저 구분해야 합니다.

template <typename T>  // 템플릿 매개변수를 선언하는 자리
struct TypeCategory;   // 기본 템플릿의 이름

template <...>에는 템플릿이 받을 매개변수를 선언합니다. C++17에서 자주 사용하는 매개변수는 타입 템플릿 매개변수와 비타입 템플릿 매개변수입니다.

#include <cstddef>

template <typename T, std::size_t Count>
struct Buffer
{
};

Buffer<int, 4> buffer;template_lab.cpp
선언 또는 표현식분류의미
typename T타입 템플릿 매개변수타입을 받을 자리
std::size_t Count비타입 템플릿 매개변수컴파일 타임 값을 받을 자리
int타입 템플릿 인자T에 전달되는 타입
4비타입 템플릿 인자Count에 전달되는 컴파일 타임 값

타입 템플릿 매개변수는 typename T 또는 class T로 선언합니다. 이때 Tint 같은 타입뿐만 아니라 int*, int& 같은 포인터와 참조 타입도 받을 수 있습니다.

TypeCategory<int*> pointerCategory;   // T = int*
TypeCategory<int&> referenceCategory; // T = int&

template<typename T*>template<typename T&>처럼 타입 매개변수 선언 자체에 포인터나 참조를 붙일 수 없습니다.

반면 다음 선언은 문법적으로 가능하지만 의미가 다르므로 구분해야 합니다.

template <int* Address>
struct AddressTag
{
};

여기서 Address는 포인터 타입을 받는 타입 매개변수가 아닙니다. int* 타입의 컴파일 타임 포인터 값을 받는 비타입 템플릿 매개변수입니다.

또 하나 주의해야 하는 부분은 기본 템플릿을 선언할 때 이름 뒤에 <T>를 붙이지 않는다는 점입니다.

template <typename T>
struct TypeCategory;     // 기본 템플릿 선언

template <typename T>
struct TypeCategory<T>;  // 오류: 기본 템플릿보다 더 구체적이지 않음

template <...>가 매개변수를 선언하는 자리라면, 템플릿 이름 뒤의 <...>는 기존 템플릿에 적용할 인자 또는 인자 패턴을 작성하는 자리입니다. 따라서 이름 뒤의 <...>는 특수화 선언뿐만 아니라 템플릿 사용에서도 나타납니다.

TypeCategory<int> category; // 템플릿 인자 int를 적용하여 사용

명시적 특수화

먼저 명시적 특수화를 살펴보겠습니다.

명시적 특수화의 규칙은 간단합니다. 기본 템플릿의 모든 템플릿 인자를 구체적으로 확정하고, 정확히 그 인자 조합에서 사용할 수 있는 정의를 작성하면 됩니다.

// 기본 템플릿
template <typename T>
struct TypeCategory
{
    static constexpr int value = 0;
};

// double에 대한 명시적 특수화
template <>
struct TypeCategory<double>
{
    static constexpr int value = 1;
};template_lab.cpp

template <>에는 새로 선언할 템플릿 매개변수가 없고, TypeCategory<double>에는 사용할 템플릿 인자 double이 완전히 정해져 있습니다. 따라서 사용자가 템플릿 인자로 double을 전달하면 명시적 특수화로 정의된 TypeCategory<double>를 사용합니다.

TypeCategory<int>::value;    // 기본 템플릿 사용
TypeCategory<double>::value; // double 명시적 특수화 사용

함수 템플릿도 명시적 특수화를 지원합니다.

template <typename T>
int Category(T)
{
    return 0;
}

template <>
int Category<double>(double)
{
    return 1;
}template_lab.cpp

함수 인자에서 T = double을 추론할 수 있다면 특수화 인자 목록을 생략하고 template <> int Category(double)로 작성할 수도 있습니다. 두 문법 모두 모든 템플릿 인자가 결정된 명시적 특수화입니다.

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

명시적 특수화가 모든 템플릿 인자를 구체화하는 방식이라면, 일부 템플릿 인자만 결정하고 나머지는 템플릿 매개변수로 남겨두는 방식도 생각할 수 있습니다.

그 방식이 바로 부분 특수화입니다. 부분 특수화는 템플릿 매개변수의 일부는 구체화하지 않은 상태로 두지만, 기본 템플릿보다는 구체적인 인자 패턴을 정의해 제공합니다.

// 기본 템플릿
template <typename T>
struct TypeCategory
{
    static constexpr int value = 0;
};

// 부분 특수화
// T* 패턴에 대응
template <typename T>
struct TypeCategory<T*>
{
    static constexpr int value = 1;
};

// 명시적 특수화
// double에 대하여 구체화됨
template <>
struct TypeCategory<double>
{
    static constexpr int value = 2;
};template_lab.cpp

각 정의가 처리하는 범위는 다음과 같습니다.

정의결정된 정보처리 범위
TypeCategory<T>없음모든 타입
TypeCategory<T*>포인터라는 패턴int*, double*
TypeCategory<double>정확한 타입 doubledouble 하나

부분 특수화된 템플릿의 패턴이 T*이므로 클래스 템플릿을 사용하는 사용자도 이 패턴과 일치한 형태로 클래스 템플릿을 사용해야 합니다.

TypeCategory<int>::value;     // 0: 기본 템플릿
TypeCategory<int*>::value;    // 1: 포인터 부분 특수화, T = int
TypeCategory<double*>::value; // 1: 포인터 부분 특수화, T = double
TypeCategory<double>::value;  // 2: double 명시적 특수화

예시의 부분 특수화는 포인터만 다뤘지만, 참조(&)나 const, 다른 클래스 템플릿을 포함하는 타입, 특정 비타입 값처럼 기본 템플릿보다 좁은 범위를 나타내는 패턴도 표현할 수 있습니다.

template <typename T, std::size_t Count>
struct Buffer
{
};

// Count가 정확히 0인 모든 Buffer에 대응
// 부분 특수화 내부에는 T만 결정하면 되지만
// 사용자는 기본 템플릿 형식에 맞춰 템플릿 인자 두 개를 모두 제공해야 함
template <typename T>
struct Buffer<T, 0> // 템플릿 인자의 패턴
{
};

Buffer<int, 0> buffer;

부분 특수화된 템플릿을 사용하는 경우에도 모든 템플릿 매개변수는 최종적으로 결정되어야 합니다. 명시적으로 작성하지 않은 매개변수는 추론하거나 기본 템플릿 인자(default)로 결정할 수 있지만, 끝까지 결정할 수 없으면 오류가 발생합니다.

따라서 위의 bufferCount0일 때 선택되는 부분 특수화를 사용합니다. 기본 템플릿에서 TCount를 모두 요구하므로 int0을 제공해야 합니다. 0을 생략하면 컴파일러가 Count를 추론할 단서가 없으므로 오류로 처리합니다.


부분 특수화의 template<...>에는 패턴을 표현하고 그 안에서 추론할 매개변수를 선언합니다. 따라서 기본 템플릿보다 많은 매개변수를 선언하는 모양도 가능합니다.

#include <utility>

template <typename Value>
struct TypeCategory
{
};

template <typename First, typename Second>
struct TypeCategory<std::pair<First, Second>>
{
};template_lab.cpp

선언 자체는 더 많은 템플릿 매개변수를 받지만, TypeCategory가 표현하는 템플릿 인자의 패턴은 기본 템플릿과 동일한 개수로 선언해야 합니다. 그래서 여러 템플릿 매개변수를 선언해 여러 템플릿 인자를 받는 것처럼 보이지만, 최종적으로 클래스 템플릿의 형태는 기본 템플릿과 동일한 하나의 타입을 받는 것으로 결정되어야 합니다.

TypeCategory가 받는 템플릿 인자는 여전히 std::pair<int, double> 하나입니다. 부분 특수화는 그 하나의 복합 타입에서 First = int, Second = double을 추론합니다.

TypeCategory<std::pair<int, double>>
→ TypeCategory<std::pair<First, Second>> 패턴과 일치
→ First = int, Second = double

반대로 기본 템플릿이 인자 하나를 받는데 TypeCategory<int, double>처럼 인자 두 개를 전달할 수는 없습니다. 부분 특수화는 기본 템플릿의 인자 구조를 바꾸는 기능이 아니라, 그 구조 안에서 더 구체적인 패턴을 정의하도록 도와주는 기능입니다.


명시적 특수화에는 미정인 매개변수를 남길 수 없습니다.

template <typename T>
struct TypeCategory;

template <>
struct TypeCategory<T*>;    // 오류: template<>에는 T가 선언되어 있지 않음

template <>
struct TypeCategory<int*>;  // 정상: 정확히 int*를 지정

함수 템플릿은 부분 특수화 대신 오버로드를 사용한다

앞서 살펴본 부분 특수화는 모두 클래스 템플릿에만 적용됩니다. 함수 템플릿은 부분 특수화를 지원하지 않기 때문입니다.

함수 템플릿은 명시적 특수화를 지원하지만 부분 특수화 문법은 지원하지 않습니다. 함수는 같은 이름이더라도 서로 다른 매개변수 목록을 가진 오버로드를 선언할 수 있기 때문입니다. 포인터처럼 더 구체적인 패턴을 처리하려면 별도의 함수 템플릿 오버로드를 작성합니다.

template <typename T>
int Category(T)
{
    return 0;
}

template <typename T>
int Category(T*)
{
    return 1;
}template_lab.cpp

두 선언은 기본 템플릿과 부분 특수화의 관계가 아닙니다. 처음부터 서로 독립된 함수 템플릿이며 같은 이름을 사용하므로 하나의 오버로드 집합에서 비교됩니다.

Category(T)   독립된 함수 템플릿 A
Category(T*)  독립된 함수 템플릿 B
int number = 10;
Category(&number);

Category를 호출하면 이름이 같은 함수의 오버로드 집합에서 비교가 진행됩니다. 이 과정에서 함수 인자의 타입을 기반으로 템플릿 인자 추론과 치환을 진행합니다.

먼저 &number의 타입은 int*입니다. 컴파일러는 각각의 함수 템플릿에 대해 독립적으로 추론을 시도합니다.

Category(T)는 함수 인자의 타입 자체가 int*이기 때문에 함수 인자 타입과 템플릿 인자의 타입을 동일하게 맞춰 Category<int*>(int*)를 후보로 만듭니다.

Category(T*)는 함수 매개변수 패턴 자체에 포인터가 들어 있습니다. 따라서 함수 인자로 들어온 int*와 함수 매개변수 T*를 비교하여 T = int로 치환한 후보를 만듭니다.

Category(T)  : T = int* → Category<int*>(int*) 후보
Category(T*) : T = int  → Category<int>(int*) 후보

이렇게 만들어진 두 후보는 모두 함수의 호출 인자가 int*이므로 호출할 수 있습니다. 컴파일러는 템플릿 부분 순서 결정에 따라 더 특수한 후보를 선택합니다.

T를 매개변수로 받는 함수는 어떤 타입이든 받을 수 있습니다. 그러나 T*를 매개변수로 받는 함수는 포인터 타입만 받을 수 있습니다. 따라서 T*가 허용하는 범위가 더 좁으므로 특수성이 더 크다고 보고 후자를 선택합니다.

템플릿 매개변수의 이름만 바꾸는 것은 새로운 오버로드를 만들지 않습니다.

template <typename T>
int Category(T*);

template <typename U>
int Category(U*); // 같은 함수 템플릿의 재선언

TU는 이름만 다르므로 두 선언의 구조는 같습니다. 같은 선언을 여러 번 작성하는 것은 가능하지만, 두 선언에 각각 본문을 작성하면 같은 함수 템플릿을 두 번 정의한 것이므로 재정의 오류가 발생합니다. 반면 Category(T)Category(T*)는 함수 매개변수의 타입 패턴이 다르므로 별개의 함수 템플릿 오버로드입니다.


반면 클래스 템플릿은 함수처럼 매개변수 목록이 다른 같은 이름의 템플릿을 오버로드할 수 없습니다.

template <typename T>
struct Timer
{
};

template <typename T, typename U>
struct Timer // 오류: 별개의 Timer 클래스 템플릿 오버로드가 되지 않음
{
};

그래서 클래스 템플릿은 기본 템플릿 하나에 명시적 특수화와 부분 특수화를 연결하여 인자에 맞는 정의를 선택합니다.

함수 템플릿
Category(T)  ─┐
              ├─ 같은 이름의 독립된 오버로드를 비교
Category(T*) ─┘

클래스 템플릿
TypeCategory<T>       기본 템플릿
├─ TypeCategory<T*>   부분 특수화
└─ TypeCategory<int>  명시적 특수화

함수 템플릿은 오버로드와 부분 순서 결정으로 구체적인 함수 매개변수 패턴을 선택합니다. 클래스 템플릿은 부분 특수화를 이용해 기본 템플릿보다 구체적인 타입 패턴을 정의하고 선택합니다.

2. TkServiceBinding.hpp로 익히는 템플릿

1절에서는 템플릿에 전달되는 매개변수와 인자, 타입 추론과 치환, 인스턴스화와 특수화의 관계를 살펴봤습니다. 이제 각각의 문법이 실제 코드에서 어떻게 조합되는지 TkServiceBinding.hpp를 예시로 확인하겠습니다.

이 파일에는 단순한 함수와 클래스 템플릿부터 비타입 템플릿 매개변수, 부분 특수화, SFINAE, type traits, parameter pack까지 C++17의 여러 템플릿 기능이 함께 사용됩니다. 각각의 기능은 서로 떨어져 있는 문법이 아니라 다음 단계의 입력으로 연결됩니다.

타입과 값을 템플릿 매개변수로 분리
→ 템플릿 인자 추론과 치환
→ 특정 타입 패턴이 유효한지 검사
→ 검사 결과에 맞는 특수화 선택
→ 컴파일 타임에 잘못된 사용 차단
→ 여러 타입을 하나의 parameter pack으로 처리

예를 들어 Packet은 함수와 클래스에 전달되는 타입 템플릿 매개변수이고, Handlerauto로 선언된 비타입 템플릿 매개변수입니다. PacketContract는 기본 템플릿과 부분 특수화를 이용해 전달된 타입이 요구하는 형태를 갖췄는지 검사하며, BindingSet은 개수가 정해지지 않은 여러 타입을 parameter pack으로 받습니다.

template <typename Packet>
TkResult DecodeRequest(/* ... */);

template <typename Service, typename Request, auto Handler>
struct OneWayBinding;

template <typename Packet, typename Check = void>
struct PacketContract;

template <typename First, typename... Rest>
struct BindingSet;
코드에 사용된 문법확인할 내용
typename Packet타입을 함수와 클래스에 적용하는 방법
auto Handler컴파일 타임 값을 템플릿 인자로 전달하는 방법
std::void_t, std::enable_if, type_traits타입 패턴을 검사하고 부분 특수화를 선택하는 방법
static_assert검사 결과가 거짓인 템플릿 사용을 차단하는 방법
typename... Rest여러 함수 인자의 타입을 하나의 pack으로 추론하는 방법

이 절에서는 Service Host가 요청을 등록하고 실행하는 전체 구조를 설명하지 않습니다. Service, Request, Response, Handler 같은 이름은 템플릿에 타입과 값을 전달하는 실제 사례로만 사용합니다. Service Host의 역할과 등록 흐름은 후속 글에서 별도로 다룹니다.

2.1 함수와 클래스의 타입 템플릿 매개변수

타입 템플릿 매개변수는 함수나 클래스에 달라지는 타입을 분리하고, 실제 사용할 때 구체 타입을 그 자리에 적용하게 합니다.

TkServiceBinding.hpp에서는 같은 패킷 처리 코드를 여러 패킷 타입에 적용하기 위해 함수 템플릿을 사용합니다.

template <typename Packet>
TkResult DecodeRequest(
    TkByteView payload,
    void *const requestStorage,
    TkDiagnosticCallbackInfo diagnostic) noexcept;TkServiceBinding.hpp

Packet은 타입을 받을 템플릿 매개변수입니다. Request 타입을 템플릿 인자로 전달하면 DecodeRequest<Request>라는 함수 템플릿 특수화를 구성할 수 있습니다.

DecodeRequest<Request>(payload, storage, diagnostic);

그런데 이 함수의 일반 함수 매개변수에는 Packet이 나타나지 않습니다. payloadTkByteView, requestStoragevoid* 타입이기 때문에 컴파일러가 이 함수 인자들로부터 Packet을 추론할 방법이 없습니다.

따라서 함수 인자로부터 템플릿 인자를 추론할 수 없다면 함수 템플릿에 적용할 템플릿 인자를 명시해야 합니다.

DecodeRequest(payload, storage, diagnostic);          // Packet을 추론할 수 없음
DecodeRequest<Request>(payload, storage, diagnostic); // Packet을 명시

클래스 템플릿에서는 여러 타입과 값을 하나의 구체적인 클래스 타입으로 묶습니다.

template <typename Service, typename Request, auto Handler>
struct OneWayBinding;TkServiceBinding.hpp

여기서 ServiceRequest는 타입 템플릿 매개변수이고, Handler는 뒤에서 살펴볼 비타입 템플릿 매개변수입니다. 세 템플릿 인자가 모두 결정되면 하나의 구체적인 클래스 타입이 됩니다.

using MoveBinding =
    OneWayBinding<Service, Request, &Service::OnRequest>;

템플릿 인자 조합이 달라지면 서로 다른 클래스 타입이 됩니다. 같은 Service를 사용하더라도 RequestHandler가 다르면 서로 다른 클래스 타입입니다.

using First = OneWayBinding<Service, Request, &Service::OnRequest>;
using Second =
    OneWayBinding<Service, OtherRequest, &Service::OnOtherRequest>;

static_assert(!std::is_same_v<First, Second>);

함수 템플릿은 하나의 규칙으로 여러 구체적인 함수를 만들고, 클래스 템플릿은 하나의 규칙으로 여러 구체적인 클래스 타입을 만듭니다. 두 경우 모두 템플릿 매개변수에 들어갈 인자가 결정되어야 특수화를 구성할 수 있다는 점은 같습니다.

2.2 함수 템플릿 인자 추론과 팩토리 함수

함수 템플릿의 인자는 호출자가 직접 지정할 수도 있고, 함수 인자의 타입으로부터 추론할 수도 있습니다. TkServiceBinding.hpp의 두 팩토리 함수는 이 차이를 연속해서 보여줍니다.

먼저 BindOneWay에는 일반 함수 인자가 없습니다.

template <typename Service, typename Request, auto Handler>
constexpr OneWayBinding<Service, Request, Handler> BindOneWay() noexcept
{
    return {};
}TkServiceBinding.hpp

컴파일러가 추론에 사용할 함수 인자가 없으므로 호출자가 템플릿 인자를 모두 지정해야 합니다.

auto binding =
    BindOneWay<Service, Request, &Service::OnRequest>();

반환 타입도 같은 템플릿 인자들로 구성됩니다.

OneWayBinding<Service, Request, &Service::OnRequest>

return {};는 이 반환 타입의 객체를 값 초기화합니다. OneWayBinding에는 비정적 데이터 멤버가 없으므로 별도로 채울 런타임 데이터도 없습니다. 어떤 서비스, 요청, 핸들러를 나타내는지는 객체에 저장된 값이 아니라 OneWayBinding<...> 타입 자체에 들어 있습니다.

이렇게 만든 여러 바인딩 객체는 MakeBindings에 전달됩니다.

template <typename First, typename... Rest>
constexpr BindingSet<First, Rest...>
MakeBindings(const First &first, const Rest &...rest) noexcept
{
    return {first, rest...};
}TkServiceBinding.hpp

이번에는 함수 인자가 존재합니다. 첫 번째 함수 인자의 타입에서 First를 추론하고, 나머지 함수 인자의 타입에서 Rest...를 추론합니다.

constexpr auto bindings = MakeBindings(
    BindOneWay<Service, Request, &Service::OnRequest>(),
    BindOneWay<Service, OtherRequest, &Service::OnOtherRequest>());

추론 결과는 다음과 같습니다.

First   = OneWayBinding<Service, Request, &Service::OnRequest>
Rest... = OneWayBinding<Service, OtherRequest, &Service::OnOtherRequest>

따라서 MakeBindings의 반환 타입은 다음과 같이 결정됩니다.

BindingSet<
    OneWayBinding<Service, Request, &Service::OnRequest>,
    OneWayBinding<Service, OtherRequest, &Service::OnOtherRequest>>

return {first, rest...};는 추론된 반환 타입의 생성자를 호출하는 list initialization입니다. 실제 BindingSet 생성자는 인자들을 받지만 멤버에 저장하지 않습니다.

constexpr BindingSet(const First &, const Rest &...) noexcept
{
}TkServiceBinding.hpp

각 바인딩의 정보는 이미 FirstRest...라는 타입에 포함되어 있고, 나중에는 각 타입의 정적 함수인 Describe()를 호출해 필요한 값을 구성합니다. 따라서 생성자 인자는 BindingSet<First, Rest...>를 만들 수 있는 형식을 제공하지만, 별도의 바인딩 객체 상태를 보관하기 위한 것은 아닙니다.

이 과정은 클래스 템플릿 인자 추론(CTAD)이 아닙니다. MakeBindings라는 함수 템플릿이 함수 인자로부터 FirstRest...를 추론하고, 그 결과를 명시적인 반환 타입 BindingSet<First, Rest...>에 전달하는 과정입니다.

2.3 auto 비타입 템플릿 매개변수와 멤버 함수 포인터

타입 템플릿 매개변수에는 타입을 전달하지만, 비타입 템플릿 매개변수에는 컴파일 타임에 결정할 수 있는 값을 전달합니다. C++17에서는 auto를 사용해 비타입 템플릿 매개변수의 타입을 컴파일러가 추론하게 할 수 있습니다.

template <typename Service, typename Request, auto Handler>
struct OneWayBinding;TkServiceBinding.hpp

Handler 자리에는 타입이 아니라 호출할 멤버 함수의 포인터 값이 들어갑니다.

OneWayBinding<Service, Request, &Service::OnRequest>

&Service::OnRequestService의 일반 멤버 함수를 가리키는 포인터입니다. 프로젝트에서 요구하는 정확한 타입은 다음과 같습니다.

TkResult (Service::*)(
    const TkServiceContext &,
    const Request &) noexcept

Service::*Service에 속한 멤버를 가리키는 포인터를 선언하는 C++ 문법입니다. 일반 멤버 함수는 호출할 때 대상 객체가 필요하므로 일반 함수 포인터와는 타입과 호출 문법이 다릅니다.

TkResult (Service::*handler)(
    const TkServiceContext &,
    const Request &) noexcept = &Service::OnRequest;

평소처럼 멤버 이름을 직접 호출할 때는 객체와 함수가 표현식에 함께 나타납니다.

service.OnRequest(context, request);

반면 멤버 함수 포인터 handler에는 호출할 함수만 들어 있고 대상 객체는 들어 있지 않습니다. 따라서 호출 시점에 .* 또는 ->* 연산자로 객체와 멤버 함수 포인터를 결합해야 합니다.

(service.*handler)(context, request);       // 객체가 있을 때
(servicePointer->*handler)(context, request); // 객체 포인터가 있을 때

프로젝트 코드도 void*Service*로 복원한 뒤 ->*Handler를 결합합니다.

return (static_cast<Service *>(serviceInstance)->*Handler)(
    *context,
    *static_cast<const Request *>(requestStorage));TkServiceBinding.hpp

여기서 *는 함수 주소를 한 번 더 역참조한다는 뜻이 아닙니다. ->* 전체가 객체 포인터에 멤버 포인터를 적용하는 하나의 연산자입니다.

반면 정적 멤버 함수는 객체 없이 호출할 수 있으므로 일반 함수 포인터로 취급합니다. 이 코드에서 검사하는 대상은 객체가 필요한 비정적 멤버 함수이므로 Service::*->*를 사용합니다.

비타입 템플릿 인자의 값도 클래스 타입을 구분하는 요소입니다. 함수 타입이 같더라도 서로 다른 멤버 함수 포인터를 전달하면 서로 다른 클래스 템플릿 특수화가 됩니다.

using MoveBinding =
    OneWayBinding<Service, Request, &Service::OnRequest>;
using LogBinding =
    OneWayBinding<Service, Request, &Service::OnLogRequest>;

static_assert(!std::is_same_v<MoveBinding, LogBinding>);

2.4 기본 템플릿과 부분 특수화로 이해하는 SFINAE

PacketContract는 전달된 Packet 타입이 필요한 멤버와 함수를 제공하는지 컴파일 타임에 검사합니다. 이 구조를 이해하려면 기본 템플릿이 먼저 인스턴스화된 뒤 부분 특수화로 교체되는 것이 아니라는 점을 알아야 합니다.

먼저 검사 실패를 나타내는 기본 템플릿이 있습니다.

template <typename Packet, typename Check = void>
struct PacketContract final
{
    static constexpr bool is_valid = false;
};TkServiceBinding.hpp

Check는 검사 결과를 부분 특수화 패턴과 연결하기 위한 보조 템플릿 매개변수입니다. 사용 코드는 보통 Packet만 전달하고 Check에는 기본 인자 void가 사용됩니다.

PacketContract<Request>

이를 시작으로 컴파일러가 정의를 선택하는 순서를 단계별로 살펴보겠습니다.

1. 실제 템플릿 인자 목록을 완성한다

사용자가 명시한 인자는 Request 하나입니다. 컴파일러는 기본 템플릿 선언으로 템플릿 매개변수의 개수를 확인하고, 생략된 Check를 기본 인자 void로 채웁니다.

PacketContract<Request>
→ PacketContract<Request, void>

이 단계에서는 기본 템플릿의 정의를 선택하거나 본문을 인스턴스화하지 않습니다. 기본 템플릿 선언을 기준으로 유효한 실제 템플릿 인자 목록을 만든 것뿐입니다. 기본 인자가 없다면 사용자가 두 번째 인자까지 직접 전달해야 합니다.

template <typename Packet, typename Check>
struct PacketContract;

PacketContract<Request>;       // 오류: Check를 결정할 수 없음
PacketContract<Request, void>; // 유효한 실제 인자 목록

기본 인자는 검사 자체를 수행하지 않습니다. 검사용 보조 인자를 사용자가 반복해서 작성하지 않도록 생략하게 하고, 부분 특수화의 계산 결과와 비교할 기준 타입을 제공합니다.

2. 부분 특수화 패턴에 추론과 치환을 시도한다

PacketContract의 부분 특수화는 다음과 같은 패턴을 가지고 있습니다.

template <typename Packet>
struct PacketContract<
    Packet,
    std::void_t<
        decltype(Packet::PacketId),
        decltype(Packet::PayloadBytes),
        decltype(std::declval<Packet &>().Decode(
            std::declval<TkByteView>(),
            TkDiagnosticCallbackInfo{})),
        decltype(std::declval<const Packet &>().Encode(
            std::declval<TkMutableByteView>(),
            TkDiagnosticCallbackInfo{}))>> final
{
    // ...
};TkServiceBinding.hpp

컴파일러는 완성된 실제 템플릿 인자 목록과 이 패턴을 비교합니다.

실제 인자 목록
PacketContract<Request, void>

부분 특수화 패턴
PacketContract<Packet, std::void_t<의존 표현식...>>

첫 번째 인자를 비교하면 부분 특수화의 Packet을 추론할 수 있습니다.

Packet = Request

그리고 나서 이 타입을 std::void_t 안의 표현식들에 치환합니다. Request에 필요한 멤버와 호출 가능한 함수가 모두 존재한다면 각 decltype이 유효한 타입을 만들고, std::void_t<...>의 결과는 void가 됩니다.

PacketContract<Packet, std::void_t<의존 표현식...>>
→ Packet = Request 치환
→ 모든 표현식이 유효함
→ PacketContract<Request, void>

그 결과가 실제 템플릿 인자 목록인 PacketContract<Request, void>와 일치하므로 이 부분 특수화는 선택 가능한 정의가 됩니다.

3. 치환 실패는 부분 특수화를 제외한다

RequestPacketId가 없다고 가정하겠습니다.

struct Request
{
};

부분 특수화에 Packet = Request를 치환하면 다음 표현식을 구성할 수 없습니다.

decltype(Request::PacketId)

이 실패는 부분 특수화의 템플릿 인자 패턴을 치환하는 과정에서 발생합니다. SFINAE(Substitution Failure Is Not An Error)에 따라 전체 컴파일 오류로 처리하지 않고, 해당 부분 특수화만 선택 대상에서 제외합니다.

Packet = Request 추론
→ 부분 특수화 패턴에 치환
→ Request::PacketId를 구성할 수 없음
→ 치환 실패
→ 부분 특수화 제외

SFINAE가 기본 템플릿을 직접 선택하지는 않습니다. SFINAE의 역할은 치환에 실패한 부분 특수화를 오류 없이 제외하는 데까지입니다. 그 결과 일치하는 부분 특수화가 하나도 남지 않으면 기본 템플릿을 fallback으로 선택합니다. 선택된 기본 템플릿을 인스턴스화할 수 없다면 컴파일 오류가 발생합니다.

4. 일치하는 정의를 선택한 뒤 인스턴스화한다

부분 특수화 매칭 결과에 따른 선택은 다음과 같습니다.

매칭 결과선택 결과
일치하는 부분 특수화가 없음기본 템플릿을 선택
일치하는 부분 특수화가 하나 있음해당 부분 특수화를 선택
여러 부분 특수화가 일치함부분 순서 결정으로 가장 특수한 것을 선택
가장 특수한 하나를 결정할 수 없음모호성 컴파일 오류

부분 특수화와 기본 템플릿 중에서 사용할 정의를 결정한 뒤 해당 클래스 정의를 인스턴스화합니다.

사용 코드가 템플릿 인자 제공
→ 기본 템플릿 선언의 기본 인자로 실제 인자 목록 완성
→ 각 부분 특수화 패턴에 추론과 치환
→ 일치하는 부분 특수화 확인
→ 사용할 정의 선택
→ 선택된 정의 인스턴스화

이를 통해 컴파일러가 추론과 치환 과정에서 부분 특수화를 바로 선택하고 본문까지 인스턴스화하는 것이 아니라는 점을 확인할 수 있습니다. 추론과 치환은 각 부분 특수화가 조건을 만족하는지 확인하고, 조건을 만족한 경우 가장 특수화된 템플릿을 선택하도록 합니다.

5. void_t의 검사와 is_valid의 검사는 다르다

std::void_t는 나열된 타입을 모두 구성할 수 있는지만 확인합니다. PacketId가 존재하더라도 그 타입이 프로젝트가 요구하는 std::uint16_t인지는 판단하지 않습니다.

정확한 타입은 부분 특수화가 선택된 뒤 is_valid에서 별도로 검사합니다.

static constexpr bool is_valid =
    std::is_default_constructible<Packet>::value &&
    std::is_destructible<Packet>::value &&
    std::is_same<
        typename std::remove_cv<decltype(Packet::PacketId)>::type,
        std::uint16_t>::value &&
    std::is_same<
        typename std::remove_cv<decltype(Packet::PayloadBytes)>::type,
        std::size_t>::value;TkServiceBinding.hpp

이를 역할별로 나누면 다음과 같습니다.

구성 요소역할
Check = void생략 가능한 검사 결과 자리와 비교 기준을 제공
std::void_t<...>멤버와 표현식이 존재하여 타입을 구성할 수 있는지 검사
부분 특수화 매칭과 SFINAE치환 실패 시 검사 정의만 제외하고 기본 템플릿으로 연결
std::is_same, std::is_*존재하는 멤버와 표현식의 타입이 요구 조건과 같은지 검사
static_assert최종 검사 결과가 거짓인 템플릿 사용을 컴파일 오류로 차단

이 차이 때문에 PacketId가 존재하지만 타입이 잘못된 패킷은 void_t 검사에는 성공하여 부분 특수화를 사용합니다. 그러나 부분 특수화 안의 is_validfalse가 되고, 이를 사용하는 위치의 static_assert가 명확한 오류 메시지를 발생시킵니다.

이 단계의 오류는 부분 특수화가 선택되고 인스턴스화가 진행된 뒤 발생하므로 SFINAE에 의해 fallback 템플릿으로 연결되지 않습니다.

6. sentinel 타입은 void가 아니어도 된다

기본 인자와 검사 성공 결과가 같은 타입이기만 하면 void가 아닌 타입도 사용할 수 있습니다.

template <typename...>
using int_t = int;

template <typename T, typename Check = int>
struct HasPacketId : std::false_type
{
};

template <typename T>
struct HasPacketId<T, int_t<decltype(T::PacketId)>> : std::true_type
{
};

HasPacketId<Request>는 기본 인자로 HasPacketId<Request, int>가 됩니다. Request::PacketId가 존재하면 int_t<...>int가 되어 부분 특수화와 일치합니다. 존재하지 않으면 치환 실패로 부분 특수화가 제외됩니다.

기본값 없이 HasPacketId<Request, int>라고 직접 작성해도 이후 매칭 과정은 같습니다. 기본값은 사용 구문을 줄이고 보조 인자를 구현 세부사항으로 감추는 역할을 할 뿐입니다. void_t가 널리 쓰이는 이유는 이 목적의 별칭이 표준 라이브러리에 준비되어 있고, 검사 결과를 사용하지 않는다는 의미도 자연스럽기 때문입니다.

7. enable_if도 같은 치환 규칙을 이용한다

std::void_t가 표현식으로 타입을 구성할 수 있는지 검사한다면, std::enable_ifbool 조건에 따라 타입을 제공하거나 제공하지 않습니다.

template <typename T, typename Check = void>
struct IsDefaultConstructible : std::false_type
{
};

template <typename T>
struct IsDefaultConstructible<
    T,
    std::enable_if_t<std::is_default_constructible_v<T>>>
    : std::true_type
{
};

조건이 true이면 std::enable_if_t<true>가 기본 타입인 void가 되어 부분 특수화와 일치합니다. 조건이 false이면 해당 타입이 존재하지 않아 치환에 실패하고 부분 특수화가 제외됩니다.

void_t      : 의존 표현식으로 타입을 구성할 수 있는가
enable_if   : 의존하는 bool 조건이 true인가
공통 원리   : 성공 시 sentinel 타입과 일치, 실패 시 SFINAE로 특수화 제외

함수 템플릿에서는 치환 실패한 함수 후보를 오버로드 집합에서 제외하고, 클래스 템플릿에서는 치환 실패한 부분 특수화를 매칭 대상에서 제외합니다. 검사 대상이 템플릿 선언의 문맥에 있기 때문에 실패가 SFINAE로 처리됩니다. 반면 선택된 정의의 본문을 인스턴스화한 뒤 발생한 오류는 SFINAE가 아니라 컴파일 오류입니다.

정리하며

템플릿은 타입이 다른 코드를 반복해서 작성하지 않도록 만드는 문법에서 출발하지만, 실제로 사용할 때는 컴파일러의 추론, 치환, 선택과 인스턴스화가 순서대로 이어집니다. 이 순서를 구분하면 템플릿 오류가 발생한 위치와 SFINAE가 작동하는 이유도 함께 이해할 수 있습니다.

함수 템플릿에서는 명시적으로 지정한 템플릿 인자와 함수 인자에서 추론한 타입을 템플릿 매개변수에 치환합니다. 그 결과로 함수 후보를 구성하고, 함수 인자를 변환하거나 참조에 바인딩할 수 있는지 확인한 뒤 최적의 오버로드를 선택합니다. 선택된 함수의 본문을 인스턴스화한 뒤 발생한 오류는 SFINAE로 제외되지 않고 컴파일 오류가 됩니다.

클래스 템플릿의 부분 특수화는 기본 템플릿 선언의 기본 인자로 실제 템플릿 인자 목록을 완성하고, 각 부분 특수화 패턴에 추론과 치환을 시도한 뒤 일치하는 정의를 선택합니다. 치환 실패는 해당 부분 특수화를 제외하며, 일치하는 부분 특수화가 없을 때 기본 템플릿이 선택됩니다.

TkServiceBinding.hpp에서는 이 규칙들이 함께 사용됩니다. 함수와 클래스의 타입 템플릿 매개변수는 패킷마다 필요한 구체적인 함수와 클래스 타입을 만들고, MakeBindings는 함수 인자에서 바인딩 타입을 추론합니다. auto Handler에는 멤버 함수 포인터 값을 컴파일 타임 인자로 전달하며, 실제 호출에서는 ->* 연산자로 서비스 객체와 결합합니다.

PacketContract는 기본 인자 voidstd::void_t의 결과를 맞춰 필요한 멤버와 표현식의 존재 여부를 검사합니다. 치환에 실패하면 SFINAE로 부분 특수화를 제외하고, 성공하면 부분 특수화 안에서 type_traits로 정확한 타입까지 검사합니다. 마지막에는 static_assert가 잘못된 템플릿 사용을 컴파일 시점에 차단합니다.

핵심 요약:

참고 자료


이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.


공유하기:

이전 글
[C++] Service Host: 패킷과 서비스 로직을 연결하는 공통 파이프라인
다음 글
[C++] Packet Tool: JSON 스키마 기반 패킷 코드 자동화