C++ type traits 활용: 표준 traits, std::enable_if, 커스텀 traits 작성
들어가며: 템플릿에 잘못된 타입이 넘어가면 에러 메시지가 난해하다
템플릿 함수는 어떤 타입이든 받을 수 있어서 유연하지만, 의도하지 않은 타입이 넘어가면 컴파일러는 긴 인스턴스화 스택을 출력합니다. “이 함수는 정수만 받는다”는 조건을 컴파일 시점에 검사하고 싶을 때 쓰는 도구가 type traits(타입 트레이트)입니다.
다음 코드에서 출발해 보겠습니다.
template <typename T>
T add(T a, T b) {
return a + b;
}
int main() {
add(3, 5); // OK
add(std::vector<int>{}, std::vector<int>{}); // 50줄 넘는 에러...
}
add는 어떤 타입 T든 받을 수 있어서, std::vector를 넘기면 함수 본문 안에서 operator+가 없다는 에러가 나옵니다. 이때 에러 메시지는 템플릿 인스턴스화 스택이 길게 이어져 원인 파악이 어렵습니다. type traits로 조건을 선언부에 올리면 다음과 같습니다.
#include <type_traits>
#include <iostream>
template <typename T>
std::enable_if_t<std::is_integral_v<T>, T> add(T a, T b) {
return a + b;
}
int main() {
std::cout << add(3, 5) << "\n"; // 8
// add(std::vector<int>{}, std::vector<int>{}); // 컴파일 에러: is_integral 불만족
}
std::enable_if_t<std::is_integral_v<T>, T>는 “T가 정수형일 때만 이 함수를 사용 가능하게 하며, 반환 타입은 T”라는 의미입니다. std::vector를 넘기면 is_integral_v가 false가 되어 이 오버로드가 제외되고, “no matching function” 에러가 나오지만, 에러가 호출 지점에서 바로 나고 후보가 제외된 이유(enable_if 조건)가 메시지에 함께 표시되므로 원인을 찾기가 훨씬 쉽습니다.
같은 도구는 타입에 따라 구현을 바꿔야 하는 곳에도 쓰입니다. JSON 직렬화에서 bool, 정수, 문자열을 각각 다르게 출력하거나, 로깅 함수에서 포인터만 역참조하거나, trivially copyable 타입이면 memcpy로 복사하는 식입니다. 이 글 뒤쪽의 예제들이 이런 경우를 다룹니다.
type traits 동작 원리
flowchart TD
A[템플릿 인스턴스화] --> B{type trait 검사}
B -->|true| C[해당 오버로드/특수화 선택]
B -->|false| D[SFINAE로 제외 또는 다른 분기]
C --> E[컴파일 성공]
D --> F{다른 오버로드 있음?}
F -->|예| C
F -->|아니오| G[컴파일 에러]
type trait이 true면 해당 구현이 선택되고, false면 SFINAE로 제외되거나 if constexpr의 다른 분기가 선택됩니다. 모든 오버로드가 제외되면 컴파일 에러가 발생합니다.
type traits가 하는 일
type traits란?
type traits는 컴파일 시점에 타입의 속성을 검사하거나 타입을 변형하는 메타프로그래밍 도구입니다. <type_traits> 헤더에 정의되어 있으며, C++11에서 표준화되었습니다. 각 trait은 ::value(bool) 또는 ::type(타입 별칭)을 제공하고, ::type 대신 쓰는 _t 별칭은 C++14부터, ::value 대신 쓰는 _v 변수 템플릿은 C++17부터 쓸 수 있습니다.
#include <type_traits>
#include <iostream>
int main() {
// ::value (C++11)
std::cout << std::is_integral<int>::value << "\n"; // 1
std::cout << std::is_integral<double>::value << "\n"; // 0
// _v (C++17)
std::cout << std::is_integral_v<int> << "\n"; // 1
std::cout << std::is_integral_v<double> << "\n"; // 0
// ::type (C++11)과 _t (C++14)
using T0 = std::remove_const<const int>::type; // int
using T1 = std::remove_const_t<const int>; // int
static_assert(std::is_same_v<T1, int>);
}
std::is_integral은 정수형(int, long, unsigned 등)이면 true, 아니면 false를 반환합니다. std::remove_const_t는 const를 제거한 타입을 반환합니다.
trait 분류
| 분류 | 예시 | 용도 |
|---|---|---|
| 타입 분류 | is_integral, is_floating_point, is_pointer | 타입 종류 검사 |
| 타입 비교 | is_same, is_base_of, is_convertible | 타입 관계 검사 |
| 타입 변환 | remove_const, remove_reference, decay | 타입 변형 |
| 조건부 | enable_if, conditional | 조건에 따른 타입/함수 선택 |
trait은 “이 타입이 어떤 종류인지”, “두 타입이 같은지”, “타입을 어떻게 변형할지”, “조건에 따라 무엇을 선택할지”를 컴파일 시점에 결정합니다.
표준 type traits
std::is_integral
정수형(char, short, int, long, long long 및 unsigned 변형, wchar_t·char16_t 같은 문자 타입)인지 검사합니다. bool도 정수형으로 분류됩니다.
#include <type_traits>
// 정수형
static_assert(std::is_integral_v<int>);
static_assert(std::is_integral_v<unsigned long>);
static_assert(std::is_integral_v<char>);
static_assert(std::is_integral_v<bool>);
// 정수형 아님
static_assert(!std::is_integral_v<double>);
static_assert(!std::is_integral_v<float>);
static_assert(!std::is_integral_v<std::string>);
static_assert(!std::is_integral_v<int*>);
정수만 받는 함수, 비트 연산이 필요한 함수처럼 나머지 연산이나 시프트가 유효한 타입만 허용할 때 사용합니다. bool과 char도 통과한다는 점은 의도와 맞는지 따로 확인해야 합니다.
template <typename T>
std::enable_if_t<std::is_integral_v<T>, T> mod(T a, T b) {
return a % b; // 정수형에서만 유효
}
std::is_same
두 타입이 완전히 동일한지 검사합니다. const, 참조, volatile까지 구분합니다.
#include <type_traits>
static_assert(std::is_same_v<int, int>);
static_assert(!std::is_same_v<int, long>);
static_assert(!std::is_same_v<int, const int>);
static_assert(!std::is_same_v<int, int&>);
// decay 후 비교가 필요할 때
static_assert(std::is_same_v<std::decay_t<int&>, int>);
static_assert(std::is_same_v<std::decay_t<const int>, int>);
특정 타입만 다르게 처리하거나, 템플릿 인자가 예상 타입인지 검증할 때 사용합니다.
template <typename T>
void process(T value) {
if constexpr (std::is_same_v<T, std::string>) {
std::cout << "String: " << value << "\n";
} else if constexpr (std::is_same_v<T, bool>) {
std::cout << "Bool: " << (value ? "true" : "false") << "\n";
} else {
std::cout << "Other: " << value << "\n";
}
}
std::is_pointer, std::is_reference
포인터·참조 타입인지 검사합니다.
#include <type_traits>
static_assert(std::is_pointer_v<int*>);
static_assert(std::is_pointer_v<std::vector<int>*>);
static_assert(!std::is_pointer_v<int>);
static_assert(std::is_lvalue_reference_v<int&>);
static_assert(std::is_rvalue_reference_v<int&&>);
static_assert(std::is_reference_v<int&>);
static_assert(!std::is_reference_v<int>);
std::is_arithmetic, std::is_floating_point
산술 타입(정수+실수), 실수 타입만 검사합니다.
#include <type_traits>
static_assert(std::is_arithmetic_v<int>);
static_assert(std::is_arithmetic_v<double>);
static_assert(!std::is_arithmetic_v<std::string>);
static_assert(std::is_floating_point_v<float>);
static_assert(std::is_floating_point_v<double>);
static_assert(!std::is_floating_point_v<int>);
std::remove_reference, std::remove_const, std::decay
타입에서 참조·const·volatile을 제거하거나, 함수 인자로 받을 때의 “원본 타입”을 추출합니다.
#include <type_traits>
// 참조 제거
static_assert(std::is_same_v<std::remove_reference_t<int&>, int>);
static_assert(std::is_same_v<std::remove_reference_t<int&&>, int>);
// const 제거
static_assert(std::is_same_v<std::remove_const_t<const int>, int>);
// decay: T& -> T, T[] -> T*, 함수 -> 함수 포인터
static_assert(std::is_same_v<std::decay_t<int&>, int>);
static_assert(std::is_same_v<std::decay_t<int[5]>, int*>);
// C++20: remove_cvref (const, volatile, 참조 한 번에 제거)
static_assert(std::is_same_v<std::remove_cvref_t<const int&>, int>);
std::decay는 함수 인자로 T를 받을 때 “실제로 저장되는 타입”을 얻을 때 유용합니다. 배열은 포인터로, 함수는 함수 포인터로 변환됩니다.
std::is_convertible
한 타입이 다른 타입으로 암시적 변환 가능한지 검사합니다.
#include <string>
#include <type_traits>
static_assert(std::is_convertible_v<int, double>);
static_assert(std::is_convertible_v<double, int>); // 축소 변환이지만 암시적으로 허용됨
static_assert(!std::is_convertible_v<double*, int>);
static_assert(std::is_convertible_v<const char*, std::string>); // 비explicit 생성자
static_assert(!std::is_convertible_v<int, std::string>); // int를 받는 변환 생성자 없음
std::enable_if로 오버로드 조건 걸기
enable_if 기본 원리
std::enable_if<Condition, T>는 Condition이 true일 때 T를 ::type으로 제공하고, false일 때는 ::type이 없어서 치환이 실패합니다. 이 실패는 에러가 아니라 후보 제외로 처리되는데, 이를 SFINAE(Substitution Failure Is Not An Error)라고 합니다. 이 오버로드가 제외되고, 다른 오버로드를 찾게 됩니다.
#include <type_traits>
// Condition이 true일 때
using T1 = typename std::enable_if<true, int>::type; // T1 = int
using T2 = std::enable_if_t<true, int>; // T2 = int (C++14)
// Condition이 false일 때
// using T3 = std::enable_if_t<false, int>; // ::type 없음 → 치환 실패
반환 타입에 enable_if 적용
#include <type_traits>
#include <iostream>
template <typename T>
std::enable_if_t<std::is_integral_v<T>, T> add(T a, T b) {
return a + b;
}
template <typename T>
std::enable_if_t<std::is_floating_point_v<T>, T> add(T a, T b) {
return a + b;
}
int main() {
std::cout << add(3, 5) << "\n"; // 8 (정수 오버로드)
std::cout << add(3.14, 2.0) << "\n"; // 5.14 (실수 오버로드)
// add("a", "b"); // 컴파일 에러: 두 오버로드 모두 불만족
}
정수형이면 첫 번째, 실수형이면 두 번째 add가 선택됩니다. 문자열을 넘기면 두 오버로드 모두 enable_if 조건이 false가 되어 SFINAE로 제외되고, “no matching function” 에러가 발생합니다.
기본 템플릿 인자에 enable_if 적용
#include <type_traits>
template <typename T,
typename = std::enable_if_t<std::is_integral_v<T>>>
T mod(T a, T b) {
return a % b;
}
// 사용
// mod(10, 3); // OK
// mod(10.0, 3.0); // 에러: 두 번째 템플릿 인자 치환 실패
이 형태는 오버로드를 하나만 둘 때는 문제가 없지만, 조건만 다른 오버로드를 같은 형태로 하나 더 만들면 재정의 에러가 납니다. 기본 템플릿 인자는 함수 템플릿의 시그니처에 포함되지 않아, 두 선언이 template <typename T, typename> T mod(T, T)로 똑같아지기 때문입니다(아래 “에러 2”). 오버로드를 나눌 때는 조건을 non-type 템플릿 매개변수의 타입 안에 넣습니다.
#include <cmath>
template <typename T,
std::enable_if_t<std::is_integral_v<T>, int> = 0>
T mod(T a, T b) {
return a % b;
}
template <typename T,
std::enable_if_t<std::is_floating_point_v<T>, int> = 0>
T mod(T a, T b) {
return std::fmod(a, b);
}
두 매개변수가 모두 int여도 괜찮습니다. 매개변수의 타입이 조건 표현식에 의존하므로 두 템플릿은 서로 다른 시그니처가 됩니다. = 0은 조건이 참일 때 int 타입이 된 이 매개변수를 채우는 기본값이고, 조건이 거짓이면 ::type이 없어 치환이 실패합니다.
함수 매개변수에 enable_if 적용
#include <iostream>
#include <type_traits>
template <typename T>
void process(T value, std::enable_if_t<std::is_pointer_v<T>, int> = 0) {
std::cout << "Pointer: " << *value << "\n";
}
template <typename T>
void process(T value, std::enable_if_t<!std::is_pointer_v<T>, int> = 0) {
std::cout << "Value: " << value << "\n";
}
int main() {
int x = 42;
process(x); // Value: 42
process(&x); // Pointer: 42
}
어디에 두느냐가 실제로 차이를 만드는 경우
세 위치는 취향 문제가 아닙니다. 생성자, 소멸자, 변환 연산자에는 반환 타입이 없으므로 반환 타입 방식을 쓸 수 없고, 생성자 오버로드를 조건부로 가릴 때는 std::enable_if_t<조건, int> = 0 형태의 템플릿 매개변수 방식이 가장 무난합니다(기본값이 있는 추가 함수 매개변수도 가능하지만 생성자의 인자 개수를 바꿉니다). 또 SFINAE는 선언부(반환 타입, 매개변수, 템플릿 매개변수)를 치환할 때 생긴 실패에만 적용됩니다. 조건을 함수 본문 안에 두면 그 오버로드는 이미 선택된 뒤라 후보에서 빠지지 않고 그대로 하드 에러가 됩니다. “SFINAE로 걸러질 줄 알았는데 컴파일이 깨진다”면 대개 조건이 본문에 들어가 있는 경우입니다. 여러 오버로드에 조건을 나눠 걸 때는 조건들이 서로 배타적인지도 확인해야 합니다. 어떤 타입에서 두 조건이 동시에 참이면 두 후보가 모두 살아남아 모호한 호출 에러가 납니다.
enable_if vs if constexpr
| 방식 | enable_if | if constexpr |
|---|---|---|
| 문법 | 반환 타입/인자에 복잡한 표현 | 함수 내부에서 분기 |
| 오버로드 | 타입별로 다른 함수 시그니처 | 하나의 함수 템플릿 |
| 조건이 거짓인 타입 | 후보에서 제외됨 | 함수는 그대로 후보에 남음 |
| C++ 버전 | C++11 | C++17 |
C++17 이상에서 함수 하나의 내부 구현만 타입별로 바꾸면 된다면 if constexpr가 더 읽기 쉽습니다. 반면 조건을 만족하지 않는 타입에 대해 함수가 아예 없는 것처럼 보여야 할 때, 즉 다른 오버로드가 선택되게 하거나 std::is_invocable 같은 검사에서 “호출 불가”로 판정되게 하려면 enable_if(C++20이라면 requires)가 필요합니다. if constexpr로 막은 함수는 여전히 후보로 선택된 뒤 본문에서 에러가 나기 때문입니다.
// C++17: if constexpr가 더 깔끔
template <typename T>
void process(T value) {
if constexpr (std::is_pointer_v<T>) {
std::cout << *value << "\n";
} else {
std::cout << value << "\n";
}
}
커스텀 type traits
has_member 패턴 (std::void_t)
특정 멤버 함수나 멤버 타입이 있는지 검사합니다. std::void_t와 SFINAE를 활용합니다.
#include <type_traits>
// T에 size() 멤버 함수가 있는지 검사
template <typename T, typename = void>
struct has_size_member : std::false_type {};
template <typename T>
struct has_size_member<T, std::void_t<decltype(std::declval<T>().size())>>
: std::true_type {};
template <typename T>
inline constexpr bool has_size_member_v = has_size_member<T>::value;
// 사용
static_assert(has_size_member_v<std::vector<int>>);
static_assert(has_size_member_v<std::string>);
static_assert(!has_size_member_v<int>);
std::declval<T>()는 T의 객체를 실제로 만들지 않고 “T 타입의 값이 있다고 치고” 표현식을 적게 해 주는 함수이고, decltype(...)은 그 표현식의 타입을 구합니다. .size() 호출이 유효하면 std::void_t<...>가 void가 되어 기본 인자 void와 맞아떨어지므로 부분 특수화(true_type)가 선택되고, 유효하지 않으면 치환 실패로 특수화가 제외되어 기본 템플릿(false_type)이 남습니다. void_t가 하는 일은 “안의 타입들이 모두 올바르면 void”라는 것뿐이고, 판정은 특수화 선택 규칙이 합니다.
has_member_type 패턴
멤버 타입이 있는지 검사합니다.
#include <type_traits>
template <typename T, typename = void>
struct has_value_type : std::false_type {};
template <typename T>
struct has_value_type<T, std::void_t<typename T::value_type>>
: std::true_type {};
template <typename T>
inline constexpr bool has_value_type_v = has_value_type<T>::value;
static_assert(has_value_type_v<std::vector<int>>); // value_type 있음
static_assert(has_value_type_v<std::map<int,int>>); // value_type 있음
static_assert(!has_value_type_v<int>);
is_container 커스텀 트레이트
value_type, begin(), end()가 있는 타입을 컨테이너로 판단합니다.
#include <type_traits>
#include <vector>
#include <list>
#include <string>
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,
decltype(std::declval<T>().begin()),
decltype(std::declval<T>().end())
>> : std::true_type {};
template <typename T>
inline constexpr bool is_container_v = is_container<T>::value;
static_assert(is_container_v<std::vector<int>>);
static_assert(is_container_v<std::list<double>>);
static_assert(is_container_v<std::string>);
static_assert(!is_container_v<int>);
is_callable 트레이트
주어진 인자로 호출 가능한지 검사합니다.
#include <type_traits>
template <typename F, typename... Args>
struct is_callable {
private:
template <typename G>
static auto test(int) -> decltype(
std::declval<G>()(std::declval<Args>()...),
std::true_type{}
);
template <typename>
static std::false_type test(...);
public:
static constexpr bool value = decltype(test<F>(0))::value;
};
template <typename F, typename... Args>
inline constexpr bool is_callable_v = is_callable<F, Args...>::value;
// 사용
static_assert(is_callable_v<int(*)(int), int>);
inline auto twice = [](int x) { return x * 2; };
static_assert(is_callable_v<decltype(twice), int>);
static_assert(!is_callable_v<int, int>);
이 트레이트는 SFINAE 원리를 보여 주기 위한 구현이고, C++17부터는 같은 일을 하는 std::is_invocable_v<F, Args...>가 표준에 있으므로 실제 코드에서는 그것을 쓰면 됩니다.
타입별 직렬화, 정수·실수 오버로드, has_to_string 예제
예제 1: 타입별 직렬화 (std::is_integral, std::is_same, if constexpr)
#include <iostream>
#include <string>
#include <type_traits>
template <typename T>
std::string serialize(const T& value) {
if constexpr (std::is_same_v<T, bool>) { // bool도 is_integral이므로 먼저 검사
return value ? "true" : "false";
} else if constexpr (std::is_integral_v<T>) {
return std::to_string(value);
} else if constexpr (std::is_floating_point_v<T>) {
return std::to_string(value);
} else if constexpr (std::is_same_v<T, std::string>) {
return "\"" + value + "\"";
} else if constexpr (std::is_pointer_v<T>) {
return value ? std::to_string(reinterpret_cast<uintptr_t>(value)) : "null";
} else {
return "[unknown]";
}
}
int main() {
std::cout << serialize(42) << "\n"; // 42
std::cout << serialize(3.14) << "\n"; // 3.140000
std::cout << serialize(true) << "\n"; // true
std::cout << serialize(std::string("hi")) << "\n"; // "hi"
int x = 10;
std::cout << serialize(&x) << "\n"; // 주소
}
예제 2: enable_if로 정수/실수 오버로드 분리
#include <iostream>
#include <type_traits>
#include <cmath>
template <typename T>
std::enable_if_t<std::is_integral_v<T>, T> safe_divide(T a, T b) {
if (b == 0) throw std::runtime_error("division by zero");
return a / b;
}
template <typename T>
std::enable_if_t<std::is_floating_point_v<T>, T> safe_divide(T a, T b) {
if (std::fpclassify(b) == FP_ZERO) throw std::runtime_error("division by zero");
return a / b;
}
int main() {
std::cout << safe_divide(10, 3) << "\n"; // 3
std::cout << safe_divide(10.0, 3.0) << "\n"; // 3.333...
}
예제 3: has_to_string + if constexpr
#include <iostream>
#include <string>
#include <type_traits>
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 to_string_impl(const T& value) {
if constexpr (has_to_string<T>::value) {
return value.to_string();
} else if constexpr (std::is_arithmetic_v<T>) {
return std::to_string(value);
} else {
return "[no string representation]";
}
}
struct Point {
int x, y;
std::string to_string() const {
return "(" + std::to_string(x) + "," + std::to_string(y) + ")";
}
};
int main() {
std::cout << to_string_impl(42) << "\n"; // 42
std::cout << to_string_impl(Point{1, 2}) << "\n"; // (1,2)
}
예제 4: decay, remove_cvref 활용
#include <type_traits>
template <typename T>
void foo(T&& arg) {
using decayed = std::decay_t<T>;
using clean = std::remove_cvref_t<T>;
// decayed: 배열→포인터, 함수→함수포인터, 참조 제거
// clean: const, volatile, 참조만 제거 (배열/함수는 그대로)
}
// C++20 remove_cvref
static_assert(std::is_same_v<std::remove_cvref_t<const int&>, int>);
static_assert(std::is_same_v<std::remove_cvref_t<volatile int&&>, int>);
모든 오버로드 제외, 기본 인자 충돌, declval 오용 같은 에러
에러 1: 커스텀 trait이 예상과 다르게 false가 되어 모든 오버로드 제외
no matching function for call 에러가 나고 모든 오버로드가 치환 실패로 제외되었다면, enable_if 조건이 너무 엄격하거나 std::void_t 안의 decltype 표현식이 의도와 다른 것을 검사하고 있는 경우가 많습니다. 예를 들어 decltype(T::foo)는 foo가 비정적 멤버 변수일 때는 유효하지만, 멤버 함수일 때는 호출 없이 이름만 쓴 형태라 잘못된 표현식이 되어 trait이 false로 나옵니다. “foo()를 호출할 수 있는가”를 묻고 싶다면 호출식을 그대로 적어야 합니다.
// 멤버 함수면 false가 됨
template <typename T>
struct has_foo<T, std::void_t<decltype(T::foo)>> : std::true_type {};
// foo()를 호출할 수 있는지 검사
template <typename T>
struct has_foo<T, std::void_t<decltype(std::declval<T>().foo())>>
: std::true_type {};
에러 2: enable_if 기본 인자 충돌
여러 오버로드가 typename = std::enable_if_t<...> 형태를 쓰면 template parameter redefinition이나 재정의 에러가 납니다. 기본 템플릿 인자는 시그니처에 포함되지 않으므로 컴파일러는 두 선언을 같은 템플릿으로 봅니다.
// 재정의 에러
template <typename T, typename = std::enable_if_t<std::is_integral_v<T>>>
void foo(T);
template <typename T, typename = std::enable_if_t<std::is_floating_point_v<T>>>
void foo(T); // 재정의로 인식됨
// 조건을 non-type 템플릿 매개변수의 타입으로 옮기면 서로 다른 시그니처가 됨
template <typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0>
void foo(T);
template <typename T, std::enable_if_t<std::is_floating_point_v<T>, int> = 0>
void foo(T);
에러 3: if constexpr의 else 분기가 모든 나머지 타입을 감당하지 못함
print(std::string("hi"))에서 no matching function for call to 'to_string(std::string&)' 에러가 나는 경우입니다. T = std::string이면 is_integral_v<T>가 false이므로 else 분기가 선택된 분기입니다. if constexpr가 건너뛰는 것은 선택되지 않은 분기뿐이라, else 안의 코드는 “나머지” 모든 타입에 대해 유효해야 합니다. 또 버려진 분기라도 템플릿 매개변수에 의존하지 않는 코드는 템플릿 정의 시점에 항상 검사됩니다.
// T가 std::string일 때 std::to_string(std::string) 없음
template <typename T>
void print(T x) {
if constexpr (std::is_integral_v<T>) {
std::cout << std::to_string(x) << "\n";
} else {
std::cout << std::to_string(x) << "\n"; // T=string이면 에러
}
}
각 분기에서는 그 분기에 들어오는 타입에 대해 유효한 표현식만 사용해야 합니다.
template <typename T>
void print(T x) {
if constexpr (std::is_integral_v<T>) {
std::cout << std::to_string(x) << "\n";
} else if constexpr (std::is_same_v<T, std::string>) {
std::cout << x << "\n";
} else {
std::cout << "[?]\n";
}
}
지원하지 않는 타입을 "[?]"로 흘려보내는 대신 컴파일 에러로 막고 싶다면 마지막 else에 static_assert를 둡니다. 이때 static_assert(false, ...)라고 쓰면 C++20까지는 조건이 템플릿 매개변수에 의존하지 않아 아무도 호출하지 않아도 템플릿 정의 시점에 실패할 수 있으므로, T에 의존하는 헬퍼를 씁니다.
template <typename>
inline constexpr bool always_false = false;
template <typename T>
std::string to_text(const T& value) {
if constexpr (std::is_arithmetic_v<T>) {
return std::to_string(value);
} else if constexpr (std::is_same_v<T, std::string>) {
return value;
} else {
static_assert(always_false<T>, "to_text: 지원하지 않는 타입");
}
}
C++23에서는 이 규칙이 바뀌어(P2593) 인스턴스화되지 않은 템플릿 안의 static_assert(false)가 허용되고, GCC 13·Clang 17 이상은 이전 표준 모드에서도 이 동작을 적용합니다. 오래된 컴파일러까지 지원해야 한다면 always_false<T> 형태가 여전히 안전합니다.
에러 4: declval을 평가되는 문맥에서 사용
declval() must not be used! 같은 static_assert 에러가 나는 경우입니다. std::declval<T>()는 decltype, sizeof, noexcept 같은 평가되지 않는 문맥에서만 쓰도록 만들어진 함수라, 실제로 호출되는 코드에 쓰면 표준 라이브러리 구현이 컴파일 단계에서 막습니다.
auto x = std::declval<int>(); // 평가되는 문맥: 컴파일 에러
using T = decltype(std::declval<int>()); // 평가되지 않는 문맥: OK (int&&)
에러 5: 참조·cv 한정자 때문에 trait이 false
std::is_same_v<T, int>는 int&, const int를 모두 int와 다르게 봅니다. 이 문제는 전달 참조(T&&)를 쓸 때 특히 자주 생깁니다. lvalue int를 넘기면 T가 int&로, const int lvalue를 넘기면 const int&로 추론되므로, 비교 전에 std::remove_cvref_t(C++20)나 std::decay_t로 정규화합니다. 참고로 const T& 매개변수에서는 T가 const 없이 추론되므로 이 문제가 생기지 않습니다.
template <typename T>
void process(T&& value) {
using U = std::remove_cvref_t<T>; // int&, const int& 모두 int로
if constexpr (std::is_same_v<U, int>) {
// ...
}
}
Concepts 우선, _v/_t, static_assert: trait을 쓰는 원칙
C++20에서는 Concepts 우선
C++20 이상에서는 std::integral, std::floating_point 같은 Concepts를 사용하면 가독성과 에러 메시지가 크게 개선됩니다.
// C++20 Concepts
template <std::integral T>
T add(T a, T b) {
return a + b;
}
template <std::integral T> 한 줄로 “정수만 받는다”는 의도가 명확합니다. type traits + enable_if보다 읽기 쉽습니다.
value/type 접미사 활용
C++14/17의 _v, _t를 사용해 ::value, ::type 접근을 간소화합니다.
// C++11 형태
typename std::enable_if<std::is_integral<T>::value, T>::type
// C++17 형태
std::enable_if_t<std::is_integral_v<T>, T>
static_assert로 조기 검증
템플릿 함수 시작 부분에서 static_assert로 요구사항을 검사하면, 잘못된 타입을 쓰는 순간 직접 쓴 에러 메시지를 보여 줄 수 있습니다. 다만 static_assert는 함수가 이미 선택된 뒤의 검사라, enable_if와 달리 다른 오버로드로 넘어가게 하지는 못합니다.
template <typename T>
T mod(T a, T b) {
static_assert(std::is_integral_v<T>, "mod() requires integral types");
return a % b;
}
커스텀 trait 네이밍
has_xxx, is_xxx 형태로 일관되게 네이밍합니다. _v 변수 템플릿도 함께 제공합니다.
template <typename T>
inline constexpr bool has_size_member_v = has_size_member<T>::value;
trait 조합 시 괄호 주의
여러 trait과 비교식을 조합할 때 enable_if 안에서는 조건 전체를 괄호로 묶습니다. 템플릿 인자 목록 안에서 괄호 밖의 >는 목록의 끝으로 해석되므로, sizeof(T) > 4 같은 비교가 들어가면 괄호가 없을 때 컴파일 에러가 납니다.
template <typename T>
std::enable_if_t<(std::is_integral_v<T> && sizeof(T) >= 4), T>
foo(T x) { return x; }
태그 디스패치, 직렬화 디스패처, trivial 타입 최적화
패턴 1: 태그 디스패치
type trait으로 “태그” 타입을 선택하며, 오버로드로 구현을 분리합니다.
#include <type_traits>
#include <vector>
#include <list>
#include <iostream>
struct vector_tag {};
struct list_tag {};
template <typename T>
struct container_category {
using type = void;
};
template <typename T, typename A>
struct container_category<std::vector<T, A>> {
using type = vector_tag;
};
template <typename T, typename A>
struct container_category<std::list<T, A>> {
using type = list_tag;
};
template <typename C>
void algorithm_impl(const C& c, vector_tag) {
std::cout << "vector: O(1) random access\n";
}
template <typename C>
void algorithm_impl(const C& c, list_tag) {
std::cout << "list: sequential access\n";
}
template <typename C>
void algorithm(const C& c) {
algorithm_impl(c, typename container_category<C>::type{});
}
int main() {
algorithm(std::vector<int>{1,2,3});
algorithm(std::list<int>{1,2,3});
}
이 예제는 원리를 보이려고 컨테이너 타입별로 태그를 직접 붙였지만, 등록하지 않은 컨테이너(std::deque 등)는 void{}를 만들 수 없어 컴파일 에러가 납니다. 실제로는 표준이 이미 제공하는 반복자 태그를 쓰는 것이 일반적입니다. typename std::iterator_traits<typename C::const_iterator>::iterator_category{}를 넘기고 std::random_access_iterator_tag와 std::forward_iterator_tag에 대해 오버로드하면, 태그끼리의 상속 관계 덕분에 deque는 임의 접근 버전으로, list·forward_list는 순차 버전으로 자동으로 연결됩니다.
패턴 2: 직렬화 디스패처
타입별 직렬화를 trait + 특수화로 분리합니다.
#include <iostream>
#include <string>
#include <type_traits>
#include <vector>
template <typename T, typename = void>
struct Serializer {
static std::string serialize(const T& value) {
return std::to_string(value); // 기본: 산술 타입
}
};
template <>
struct Serializer<bool> {
static std::string serialize(bool value) {
return value ? "true" : "false";
}
};
template <>
struct Serializer<std::string> {
static std::string serialize(const std::string& value) {
return "\"" + value + "\"";
}
};
template <typename T>
struct Serializer<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 += Serializer<T>::serialize(vec[i]);
}
return result + "]";
}
};
template <typename T>
std::string serialize(const T& value) {
return Serializer<T>::serialize(value);
}
패턴 3: 안전한 포인터 역참조
#include <iostream>
#include <type_traits>
template <typename T>
void log_value(const T& value) {
if constexpr (std::is_pointer_v<T>) {
if (value) {
std::cout << "*ptr = " << *value << "\n";
} else {
std::cout << "null pointer\n";
}
} else {
std::cout << "value = " << value << "\n";
}
}
int main() {
int x = 42;
log_value(x);
log_value(&x);
}
패턴 4: trivial 타입 최적화
std::is_trivially_copyable_v, std::is_trivially_destructible_v로 메모리 복사·소멸 최적화를 적용합니다.
#include <type_traits>
#include <cstring>
template <typename T>
void copy_range(T* dest, const T* src, size_t n) {
if constexpr (std::is_trivially_copyable_v<T>) {
std::memcpy(dest, src, n * sizeof(T));
} else {
for (size_t i = 0; i < n; ++i) {
dest[i] = src[i];
}
}
}
else 분기의 대입은 dest에 이미 객체가 생성되어 있다고 가정합니다. 초기화되지 않은 메모리에 복사하는 경우라면 대입 대신 std::uninitialized_copy처럼 생성자를 호출하는 방식을 써야 합니다. 실제로는 std::copy가 trivially copyable 타입에 대해 내부적으로 memmove를 쓰도록 구현되어 있는 경우가 많으므로, 직접 분기하기 전에 표준 알고리즘으로 충분한지 먼저 확인하는 편이 좋습니다.
참고 자료
같이 보면 좋은 글
- C++ 템플릿 특수화
- C++ constexpr 함수와 변수 | 컴파일 타임에 계산하기 [#26-1]
- C++20 Concepts | 템플릿 에러 메시지를 읽기 쉽게 만드는 방법
- C++ 컴파일 타임 프로그래밍 기법 | 런타임 오버헤드 제거와 constexpr·consteval 실전
- C++26 리플렉션 기초 | ^^ 연산자·std::meta::info로 타입 정보 조회하기
- C++20 Ranges | begin/end 반복 탈출하고 ranges 알고리즘 쓰기