C++ <type_traits>로 타입 검사·변환하기: SFINAE 원리, enable_if 위치, decay와 커스텀 traits

이 글의 핵심

SFINAE가 컴파일러 내부에서 왜 '에러가 아닌 것'으로 취급되는지, enable_if를 반환 타입과 템플릿 매개변수 중 어디에 두느냐에 따라 오버로드 해석이 어떻게 갈리는지, decay·remove_reference를 perfect forwarding에서 반드시 써야 하는 이유까지 실제 겪은 버그 사례와 함께 정리합니다.

<type_traits>는 C++11부터 표준 라이브러리에 들어온 헤더로, 템플릿 인자로 넘어온 타입에 대해 “이 타입이 정수인가”, “포인터인가”, “다른 타입으로 변환 가능한가” 같은 질문을 런타임 코드를 한 줄도 실행하지 않고 컴파일 시점에 답할 수 있게 해줍니다. 템플릿 메타프로그래밍, SFINAE 기반 오버로드 제어, C++17의 if constexpr 분기, perfect forwarding 등 상당수의 “제네릭 코드가 타입에 따라 다르게 동작해야 하는” 상황이 이 헤더 위에 쌓여 있습니다. 이 글에서는 단순히 trait 목록을 나열하는 대신, 왜 이런 도구가 필요했는지와 실제로 잘못 쓰면 어떤 문제가 생기는지를 함께 짚습니다.

기본 타입 체크

#include <type_traits>
using namespace std;

int main() {
    cout << is_integral<int>::value << endl;         // 1
    cout << is_floating_point<double>::value << endl; // 1
    cout << is_pointer<int*>::value << endl;         // 1
    cout << is_array<int[]>::value << endl;          // 1
    cout << is_class<string>::value << endl;         // 1
}

이 코드에서 is_integral<int>는 실제로 값을 계산하는 함수가 아니라, int를 템플릿 인자로 받아 내부에 static constexpr bool value = true;를 갖는 구조체를 인스턴스화한 것입니다. 즉 is_integral<int>::value를 평가하는 시점에는 이미 컴파일러가 답을 알고 있고, 실행 파일에는 이 판단을 위한 어떤 코드도 남지 않습니다. 이 성질 때문에 if constexpr이나 enable_if처럼 “컴파일 타임 조건문”의 조건식으로 그대로 넣을 수 있는 것입니다.

여기서 실무에서 자주 나오는 함정 하나를 짚고 갑니다. is_pointer<T>::value처럼 ::value를 붙이는 전통적인 형태와, C++17부터 제공되는 is_pointer_v<T> 변수 템플릿을 혼용하다가 ::value를 빼먹는 실수가 은근히 잦습니다. 예를 들어 if (is_pointer<T>) 라고만 쓰면, 이는 bool이 아니라 is_pointer<T>라는 타입 자체를 조건식에 넣은 것이 되고, 클래스 타입은 암시적으로 bool로 변환될 수 있는 연산자(operator bool)를 가지고 있기 때문에 컴파일은 통과해 버립니다. std::true_type/std::false_type 계열은 실제로 그런 변환 연산자를 제공하므로, 이 실수는 컴파일 에러 없이 “항상 true로 평가되는” 조용한 버그로 이어질 수 있습니다. 코드 리뷰에서 is_*<T>가 ::value나 _v 없이 조건식에 단독으로 들어가 있으면 일단 의심해 보는 습관을 들이는 편이 안전합니다.

타입 변환

// const 제거
remove_const<const int>::type  // int

// 참조 제거
remove_reference<int&>::type   // int
remove_reference<int&&>::type  // int

// 포인터 추가/제거
add_pointer<int>::type         // int*
remove_pointer<int*>::type     // int

// CV 제거
remove_cv<const volatile int>::type  // int

// decay
decay<int[10]>::type           // int*
decay<int()>::type             // int(*)()

remove_reference, remove_cv, decay 같은 변환 trait들이 필요한 이유는 대부분 perfect forwarding과 맞닿아 있습니다. template<typename T> void wrapper(T&& arg) 형태의 함수에서 T는 인자로 넘어온 값의 값 범주(lvalue/rvalue)에 따라 T 자체가 참조 타입으로 추론될 수 있습니다(레퍼런스 컬랩싱). 이 상태의 T를 그대로 다른 곳, 예를 들어 클래스 멤버 타입이나 컨테이너의 원소 타입으로 쓰면 “참조를 저장하는 컨테이너”처럼 원래 의도하지 않은 형태가 되어버립니다. 그래서 “값으로서 저장하거나 반환할 공통 타입”이 필요한 지점에서는 remove_reference로 참조를 벗기거나, 배열·함수 타입까지 포함해 함수 인자 전달 시의 타입 붕괴(decay)와 동일한 규칙을 적용하고 싶을 때는 decay를 씁니다. decay<int[10]>::type이 int*가 되는 것도 배열을 함수 인자로 넘길 때 포인터로 붕괴하는 언어 규칙을 trait로 그대로 재현한 것뿐입니다.

실제로 겪은 사례를 하나 공유하면, 예전에 로깅 유틸리티를 만들면서 가변 인자를 그대로 저장해 두었다가 나중에 포맷팅하는 deferred_log 같은 구조를 짠 적이 있습니다. template<typename T> void capture(T&& value) { storage_.emplace_back(std::forward<T>(value)); } 형태였는데, storage_의 원소 타입을 T에서 곧바로 뽑아 쓰면서 remove_reference_t<T>를 빼먹었습니다. lvalue를 넘기면 T가 Foo&로 추론되고, 이 타입 그대로 std::vector<Foo&> 같은 걸 만들려 하니 “참조의 컨테이너는 만들 수 없다”는 컴파일 에러가 났습니다. 문제는 에러 메시지가 템플릿 인스턴스화 체인을 타고 한참 내려간 곳에서 나오다 보니, 처음에는 vector가 문제인 줄 알고 엉뚱한 곳을 한참 들여다봤다는 점입니다. decay_t<T> 혹은 remove_reference_t<T>로 참조를 벗겨서 저장 타입을 결정하고 나서야 문제가 사라졌습니다. perfect forwarding 코드에서 “전달은 참조로, 저장은 값으로” 라는 원칙을 지키려면 이 변환 trait들이 사실상 필수라는 걸 그때 체감했습니다.

타입 관계

// 같은 타입
is_same<int, int>::value       // 1
is_same<int, long>::value      // 0

// 변환 가능
is_convertible<int, double>::value    // 1
is_convertible<int*, void*>::value    // 1

// 상속 관계
is_base_of<Base, Derived>::value      // 1

is_same은 타입 동등성 비교의 기준으로, 템플릿 특수화가 의도한 대로 선택되는지 static_assert(is_same_v<decltype(expr), int>) 같은 형태로 디버깅할 때도 자주 씁니다. is_convertible은 암묵적 변환 가능 여부를 확인하므로, 함수 템플릿이 특정 인터페이스를 만족하는 타입만 받도록 제약할 때 유용합니다. is_base_of는 다중 상속이나 CRTP(Curiously Recurring Template Pattern) 패턴에서 “이 타입이 정말 기대한 기반 클래스를 상속했는가”를 컴파일 타임에 검증하는 방어 코드로 많이 쓰입니다.

실전 예시

아래 다이어그램은 if constexpr을 사용한 타입별 분기가 컴파일 타임에 어떻게 처리되는지를 보여줍니다. 런타임 분기와 달리, 조건에 맞지 않는 브랜치는 아예 코드 생성 대상에서 제외됩니다.

flowchart TD
    A["템플릿 인스턴스화: process(T)"] --> B{"is_integral_v&lt;T&gt;?"}
    B -- "true" --> C["정수 분기 코드만 생성"]
    B -- "false" --> D{"is_floating_point_v&lt;T&gt;?"}
    D -- "true" --> E["실수 분기 코드만 생성"]
    D -- "false" --> F{"is_pointer_v&lt;T&gt;?"}
    F -- "true" --> G["포인터 분기 코드만 생성"]
    F -- "false" --> H["기타 분기 코드만 생성"]

예시 1: 타입별 처리

template<typename T>
void process(T value) {
    if constexpr (is_integral_v<T>) {
        cout << "정수: " << value << endl;
    } else if constexpr (is_floating_point_v<T>) {
        cout << "실수: " << value << endl;
    } else if constexpr (is_pointer_v<T>) {
        cout << "포인터: " << value << endl;
    } else {
        cout << "기타 타입" << endl;
    }
}

int main() {
    process(10);      // 정수
    process(3.14);    // 실수
    process(&main);   // 포인터
}

이 코드가 C++17 이전이었다면 SFINAE와 함수 오버로드로 나눠 작성해야 했을 로직입니다. if constexpr은 조건이 거짓인 분기의 본문을 아예 인스턴스화하지 않으므로, 예를 들어 포인터가 아닌 타입에 대해 *value를 호출하는 코드가 다른 분기에 있어도 컴파일 에러가 나지 않습니다. 이 점이 일반 if와 결정적으로 다른 부분이며, “타입에 따라 다른 코드가 필요하지만 함수를 여러 개로 쪼개고 싶지는 않다”는 요구를 매우 간결하게 해결해 줍니다.

예시 2: SFINAE

// 정수 타입만
template<typename T>
enable_if_t<is_integral_v<T>, T>
twice(T value) {
    return value * 2;
}

// 실수 타입만
template<typename T>
enable_if_t<is_floating_point_v<T>, T>
twice(T value) {
    return value * 2.0;
}

int main() {
    cout << twice(10) << endl;    // 20
    cout << twice(3.14) << endl;  // 6.28
    // twice("hello");  // 에러
}

SFINAE(Substitution Failure Is Not An Error)를 제대로 이해하려면 “왜 이게 하드 에러가 아닌가”를 봐야 합니다. 컴파일러가 오버로드 후보를 고를 때는 함수 시그니처에 나오는 템플릿 인자를 실제 타입으로 치환(substitution)해 봅니다. 이때 enable_if_t<false, T>처럼 ::type이 아예 존재하지 않는 표현식이 나오면, 표준은 이를 “이 함수 템플릿은 문법적으로 잘못됐다”가 아니라 “이 후보는 애초에 없었던 것으로 치고 오버로드 목록에서 제외한다”고 규정합니다. 즉 실패가 프로그램 전체를 멈추는 에러가 아니라, 오버로드 해석이라는 국소적인 단계에서 후보 하나를 조용히 걸러내는 신호로만 쓰이는 것입니다. 이 설계 덕분에 twice(10)을 호출할 때 정수용 오버로드는 치환에 성공해 살아남고, 실수용 오버로드는 enable_if_t<is_floating_point_v<int>, int>가 정의되지 않은 타입이 되어 조용히 사라집니다. 만약 이게 하드 에러였다면, 애초에 여러 개의 조건부 오버로드를 나열하는 패턴 자체가 성립할 수 없었을 것입니다.

다만 SFINAE는 선언부(시그니처)에서 발생한 치환 실패에만 적용된다는 점이 중요합니다. 함수 본문 안에서 발생하는 타입 오류는 이미 그 오버로드가 “선택된” 이후의 문제이므로 SFINAE로 걸러지지 않고 그대로 하드 에러가 됩니다. 이 경계선을 헷갈리면 “SFINAE로 걸러질 줄 알았는데 컴파일이 깨진다”는 상황을 만나게 됩니다.

enable_if의 위치가 오버로드 해석에 미치는 영향

enable_if를 반환 타입에 쓸지, 템플릿 매개변수 목록에 쓸지는 단순한 스타일 차이가 아닙니다.

// 반환 타입
template<typename T>
enable_if_t<is_integral_v<T>, T>
func(T value) {
    return value * 2;
}

// 템플릿 매개변수
template<typename T, enable_if_t<is_integral_v<T>, int> = 0>
T func(T value) {
    return value * 2;
}

반환 타입에 두면 함수 시그니처 자체가 “조건이 거짓일 때 정의되지 않은 타입”이 되므로, 반환 타입을 auto로 추론해야 하거나 생성자처럼 반환 타입이 아예 없는 함수(생성자, 소멸자, 변환 연산자)에는 이 방식을 쓸 수 없습니다. 반면 템플릿 매개변수 목록에 기본값 형태로 넣으면 반환 타입은 자유롭게 유지하면서 조건을 걸 수 있어 생성자 오버로드를 가리는 데 더 적합합니다. 대신 이 방식은 동일한 함수 시그니처를 가진 두 오버로드가 “추가 템플릿 매개변수의 기본값”만 다르게 갖는 형태가 되기 쉬운데, 이 경우 두 오버로드가 서로 다른 템플릿 매개변수 개수를 가져야 “재정의”가 아니라 “오버로드”로 인정됩니다.

여기서 실제로 겪었던 버그를 하나 더 공유합니다. 어떤 컨테이너 클래스에 “요소 타입으로 초기화하는 생성자”와 “다른 컨테이너를 복사해서 초기화하는 생성자”를 SFINAE로 분리한 적이 있는데, 처음에는 무심코 enable_if를 반환 타입 자리에 쓰려다가 “생성자에는 반환 타입이 없다”는 사실을 뒤늦게 깨달았습니다. 그래서 템플릿 매개변수 쪽으로 옮겼는데, 이번에는 두 생성자 모두 template<typename U, enable_if_t<..., int> = 0> 형태로 시그니처가 거의 같다 보니, 정수 계열 타입 하나를 넘겼을 때 기대했던 “요소 타입 생성자”가 아니라 조건이 더 느슨하게 걸려 있던 “컨테이너 복사 생성자” 쪽이 인스턴스화되면서 전혀 다른 동작이 나온 적이 있습니다. 원인은 두 enable_if 조건이 상호 배타적이지 않고 특정 타입에서 둘 다 참이 될 수 있었기 때문이었는데, 오버로드 해석은 에러 없이 그냥 “더 특수한 쪽”을 골라버려서 한참 동안 로그를 찍어보며 원인을 추적해야 했습니다. 이후로는 SFINAE 조건을 여러 오버로드에 나눠 걸 때 반드시 조건들이 서로 배타적인지(교집합이 비어 있는지) 먼저 종이에 적어보는 습관이 생겼습니다.

예시 3: 타입 안전 직렬화

template<typename T>
string serialize(const T& value) {
    if constexpr (is_arithmetic_v<T>) {
        return to_string(value);
    } else if constexpr (is_same_v<T, string>) {
        return "\"" + value + "\"";
    } else if constexpr (is_pointer_v<T>) {
        return serialize(*value);
    } else {
        static_assert(always_false<T>, "지원하지 않는 타입");
    }
}

template<typename T>
inline constexpr bool always_false = false;

int main() {
    cout << serialize(42) << endl;
    cout << serialize(3.14) << endl;
    cout << serialize(string("Hello")) << endl;
}

static_assert(always_false<T>, ...)처럼 T에 의존하는 표현식을 조건으로 쓰는 이유는, static_assert(false, ...)를 곧바로 쓰면 함수 템플릿이 인스턴스화되지 않은 상태에서도(즉 아무도 호출하지 않아도) 일부 컴파일러가 즉시 에러를 낼 수 있기 때문입니다. always_false<T>처럼 템플릿 매개변수에 의존하는 형태로 만들면, 이 static_assert는 실제로 해당 타입으로 serialize가 인스턴스화될 때에만 평가됩니다. 이런 패턴은 if constexpr의 else 분기에서 “여기까지 왔다면 지원하지 않는 타입”이라는 사실을 컴파일 타임 에러로 명확히 알려주고 싶을 때 자주 씁니다.

예시 4: 컨테이너 체크

template<typename T, typename = void>
struct is_container : false_type {};

template<typename T>
struct is_container<T, void_t<
    typename T::value_type,
    decltype(declval<T>().begin()),
    decltype(declval<T>().end())
>> : true_type {};

template<typename T>
inline constexpr bool is_container_v = is_container<T>::value;

int main() {
    cout << is_container_v<vector<int>> << endl;  // 1
    cout << is_container_v<list<int>> << endl;    // 1
    cout << is_container_v<int> << endl;          // 0
}

이 패턴은 “detection idiom”이라고도 불리며, void_t의 동작 원리를 이해해야 왜 동작하는지 알 수 있습니다. void_t<Args...>는 넘겨받은 타입들이 전부 유효하게 존재하기만 하면 항상 void로 평가되는 별칭 템플릿입니다. 두 번째 is_container 특수화는 T::value_type, T::begin(), T::end()가 전부 유효한 경우에만 치환에 성공해 void_t<...>가 void가 되고, 이는 첫 번째 매개변수 목록의 기본값 void와 일치하므로 이 특수화가 선택됩니다. 반대로 T가 int처럼 begin()이 없는 타입이면 치환 실패가 발생해 이 특수화는 후보에서 제외되고, 기본 정의인 false_type으로 폴백합니다. 즉 앞서 설명한 SFINAE 원리가 클래스 템플릿 특수화 단계에서 그대로 적용된 예시입니다. C++20부터는 이런 패턴 대부분을 concepts의 requires 절로 훨씬 짧게 표현할 수 있습니다.

조건부 타입

// 조건부 타입 선택
conditional<true, int, double>::type   // int
conditional<false, int, double>::type  // double

// 사용 예
template<bool UseDouble>
using Number = conditional_t<UseDouble, double, int>;

Number<true> x = 3.14;   // double
Number<false> y = 10;    // int

conditional은 런타임의 삼항 연산자를 컴파일 타임 타입 선택으로 옮겨놓은 것에 가깝습니다. 플랫폼별로 정수 크기를 달리 잡거나, 디버그/릴리즈 빌드에서 서로 다른 자료구조를 쓰게 만드는 등 “빌드 설정에 따라 타입 자체가 달라져야 하는” 상황에서 유용합니다. 다만 두 분기의 타입이 모두 존재해야 하므로, 존재하지 않는 타입을 조건부로 고르고 싶다면 conditional이 아니라 if constexpr이나 부분 특수화를 써야 합니다.

타입 카테고리

// 기본 타입
is_void<void>::value
is_null_pointer<nullptr_t>::value
is_integral<int>::value
is_floating_point<double>::value

// 복합 타입
is_array<int[]>::value
is_pointer<int*>::value
is_reference<int&>::value
is_member_pointer<int Widget::*>::value

// 타입 속성
is_const<const int>::value
is_volatile<volatile int>::value
is_signed<int>::value
is_unsigned<unsigned int>::value

이 분류들은 실제로는 표준이 정의한 “타입 카테고리 트리”를 그대로 반영합니다. is_arithmetic은 is_integral과 is_floating_point의 합집합이고, is_fundamental은 여기에 is_void, is_null_pointer를 더한 것이며, is_compound는 그 나머지 전부입니다. 이 계층 구조를 알아두면 “정수와 실수를 모두 받고 싶다”처럼 여러 조건을 OR로 나열하는 대신 is_arithmetic_v<T> 하나로 줄일 수 있는 지점을 더 쉽게 찾을 수 있습니다.

생성/소멸 특성

// 생성 가능
is_default_constructible<Widget>::value
is_copy_constructible<Widget>::value
is_move_constructible<Widget>::value

// trivial
is_trivially_copyable<int>::value
is_trivially_destructible<int>::value

// 소멸자
is_destructible<Widget>::value
has_virtual_destructor<Base>::value

이 그룹의 trait들은 컴파일러가 자동으로 생성해 주는 특수 멤버 함수(생성자, 복사/이동 생성자, 소멸자)의 존재 여부를 확인할 때 씁니다. 특히 is_trivially_copyable은 memcpy로 안전하게 복사할 수 있는 타입인지를 판단하는 근거로 자주 쓰이는데, 예를 들어 커스텀 직렬화나 공유 메모리에 데이터를 그대로 밀어 넣는 코드에서 “이 타입이 정말 바이트 단위로 복사해도 안전한가”를 static_assert로 강제하는 용도로 유용합니다. has_virtual_destructor는 다형적 삭제(polymorphic deletion)가 필요한 기반 클래스 포인터를 다루는 코드에서, 실수로 가상 소멸자가 없는 클래스를 delete base_ptr; 형태로 지우는 정의되지 않은 동작을 컴파일 타임에 막는 방어 장치로 쓸 수 있습니다.

자주 발생하는 문제

문제 1: _v vs ::value

// C++14 이전
if (is_integral<T>::value) {
}

// C++17 이후 (간결)
if (is_integral_v<T>) {
}

두 표현은 최종적으로 같은 bool 상수 표현식으로 귀결되지만, _v 접미사가 붙은 변수 템플릿 쪽이 typename/::value를 반복적으로 타이핑하다 실수할 여지를 줄여줍니다. 대규모 템플릿 코드베이스를 ::value 스타일에서 _v 스타일로 옮기는 리팩터링은 기계적이라 위험도가 낮은 편이지만, 위에서 언급한 것처럼 ::value나 _v 자체를 빼먹는 실수는 여전히 컴파일이 통과해 버릴 수 있으므로 정적 분석 도구나 코드 리뷰로 잡아내는 편이 안전합니다.

문제 2: enable_if 위치

// 반환 타입
template<typename T>
enable_if_t<is_integral_v<T>, T>
func(T value) {
    return value * 2;
}

// 템플릿 매개변수
template<typename T, enable_if_t<is_integral_v<T>, int> = 0>
T func(T value) {
    return value * 2;
}

앞서 SFINAE 절에서 다룬 것처럼, 이 두 형태는 단순한 취향 차이가 아니라 생성자나 변환 연산자처럼 반환 타입이 없는 함수에 적용 가능한지, 그리고 오버로드 해석 시 다른 오버로드와 시그니처가 얼마나 유사해지는지에 실질적인 영향을 줍니다.

문제 3: 복잡한 타입 체크

// ❌ 복잡
template<typename T>
enable_if_t<
    is_integral<T>::value &&
    is_signed<T>::value &&
    sizeof(T) == 4,
    T
>
func(T value) {
    return value;
}

// ✅ Concepts 사용 (C++20)
template<typename T>
concept SignedInt32 = integral<T> && signed_integral<T> && sizeof(T) == 4;

template<SignedInt32 T>
T func(T value) {
    return value;
}

C++17까지는 여러 조건을 조합할수록 enable_if_t<A && B && C, T>처럼 반환 타입 자리가 점점 읽기 힘든 조건식 덩어리로 변했고, 조건이 실패했을 때 컴파일러가 뱉는 에러 메시지도 “치환 실패”라는 사실만 알려줄 뿐 정확히 어느 조건이 걸렸는지 짚어주지 않는 경우가 많았습니다. C++20의 concepts는 같은 조건을 이름이 있는 제약(SignedInt32)으로 분리하고, 제약을 만족하지 못하면 어떤 조건이 실패했는지 훨씬 구체적인 진단 메시지를 제공합니다. 그래서 요즘 새로 작성하는 코드에서는 SFINAE 기반의 enable_if 체인을 concepts로 대체할 수 있는지부터 검토하는 편이고, <type_traits>의 존재 가치는 사라지는 대신 “제약을 표현하기 위한 빌딩 블록”으로 역할이 재배치되고 있다고 보는 편이 정확합니다. 실제로 integral, signed_integral 같은 표준 concepts 자체가 내부적으로 is_integral_v, is_signed_v 같은 type trait 위에 정의되어 있습니다.

커스텀 Traits

// 커스텀 trait
template<typename T>
struct is_string : false_type {};

template<>
struct is_string<string> : true_type {};

template<>
struct is_string<string_view> : true_type {};

template<typename T>
inline constexpr bool is_string_v = is_string<T>::value;

int main() {
    cout << is_string_v<string> << endl;       // 1
    cout << is_string_v<string_view> << endl;  // 1
    cout << is_string_v<int> << endl;          // 0
}

표준 라이브러리가 제공하는 trait만으로는 도메인 특화된 질문(예: “이 타입이 직렬화 가능한가”, “이 타입이 우리 프레임워크의 Serializable 인터페이스를 만족하는가”)에 답할 수 없으므로, 이렇게 true_type/false_type을 상속하는 커스텀 trait을 직접 정의하는 경우가 많습니다. 기본 템플릿을 false_type으로 두고 특정 타입에 대해서만 전체 특수화로 true_type을 상속하는 패턴은 사실상 표준 trait들이 내부적으로 구현된 방식과 동일하며, 이 구조를 한번 이해해 두면 표준 헤더의 구현을 직접 읽어볼 때도 훨씬 수월해집니다.

FAQ

Q1: type traits는 언제 사용하나요?

A:

  • 템플릿 메타프로그래밍
  • SFINAE
  • 컴파일 타임 분기
  • 타입 체크

Q2: 성능 오버헤드는?

A: 없습니다. 컴파일 타임에 모두 처리됩니다.

Q3: Concepts vs type traits?

A: C++20 이상이면 Concepts가 더 읽기 쉽습니다. 다만 concepts 정의 자체가 내부적으로 type trait을 기반으로 하는 경우가 많으므로, <type_traits>가 필요 없어지는 것이 아니라 더 낮은 레벨의 빌딩 블록으로 역할이 옮겨간다고 보는 편이 정확합니다.

Q4: 커스텀 trait는?

A: true_type/false_type을 상속하여 구현.

Q5: type traits 디버깅은?

A: static_assert로 타입 체크.

Q6: Type Traits 학습 리소스는?

A:

  • cppreference.com
  • “C++ Templates: The Complete Guide”
  • “Effective Modern C++“

type_traits에서 concepts로 이어지는 원리

<type_traits>는 화려한 문법이 아니라, 컴파일러가 이미 알고 있는 타입 정보를 프로그래머가 코드로 질의할 수 있게 열어준 인터페이스에 가깝습니다. SFINAE가 “치환 실패는 에러가 아니다”라는 원칙 위에서 오버로드 후보를 조용히 걸러내는 방식, enable_if의 위치에 따라 오버로드 해석이 달라지는 이유, decay나 remove_reference가 perfect forwarding에서 참조를 값으로 정규화해야 하는 이유를 한 번 제대로 짚어두면, 이후 concepts나 if constexpr 기반 코드를 읽을 때도 그 밑바탕에 깔린 원리가 결국 같다는 것을 알아볼 수 있습니다.


같이 보면 좋은 글