SFINAE 패턴 정리: enable_if, void_t, Detection Idiom과 Concepts로 옮길 시점
템플릿을 작성하다 보면 “타입에 따라 다른 구현을 고르고 싶다”는 요구가 자주 생깁니다. 정수형만 받는 함수에 std::vector를 넘겼더니 수십 줄짜리 인스턴스화 에러가 나거나, 컨테이너면 size()를 쓰고 int면 다른 처리를 하고 싶거나, operator<<가 있는 타입만 스트림으로 출력하고 싶은 경우입니다. 이렇게 “이 표현식이 유효한 타입인지”를 컴파일 시점에 검사해 오버로드를 고르는 기법의 바탕이 SFINAE(Substitution Failure Is Not An Error) 규칙입니다.
flowchart TD
A[템플릿 인자 추론 후 치환 시도] --> B{치환 Substitution 성공?}
B -->|예| C[오버로드 후보에 포함]
B -->|아니오| D[에러가 아님 - 후보에서 제외]
D --> E{다른 후보가 있음?}
E -->|예| F[다른 후보로 해결]
E -->|아니오| G[no matching function 에러]
이 글은 규칙 자체에서 출발해 std::enable_if로 오버로드를 켜고 끄는 방법, void_t와 Detection idiom, 재사용 가능한 is_detected 패턴, 자주 나는 에러, 그리고 C++20 Concepts로 옮기는 기준까지 차례로 다룹니다.
SFINAE 기초
SFINAE란?
SFINAE(Substitution Failure Is Not An Error)는 함수 템플릿의 오버로드 해석 중 템플릿 인자를 치환하다가 실패하면, 그것을 에러로 처리하지 않고 해당 후보만 제외하는 규칙입니다.
컴파일러는 함수 템플릿을 호출하는 식을 보면 템플릿 인자를 추론한 뒤 함수의 선언부에 실제 타입을 치환합니다. 이 과정에서 std::enable_if<false, T>::type처럼 유효하지 않은 타입이나 식이 나오면, 그 템플릿은 에러가 아니라 후보에서 제외됩니다. 남은 후보 중 하나가 맞으면 정상적으로 컴파일됩니다.
치환 실패가 SFINAE로 처리되는 곳
SFINAE는 치환의 “직접 문맥(immediate context)“에서 생긴 실패에만 적용됩니다. 구체적으로는 함수의 반환 타입, 매개변수 타입, 템플릿 매개변수 선언(기본 템플릿 인자 포함), 그리고 C++20부터는 explicit(bool) 조건식이 여기에 해당합니다.
반대로 함수 본문은 오버로드가 선택된 뒤에야 인스턴스화되므로, 본문에서 생긴 에러는 언제나 하드 에러입니다. 본문 안의 static_assert도 마찬가지입니다. 예외 명세(noexcept(...))도 치환 단계에서 인스턴스화되지 않고 필요할 때 따로 인스턴스화되므로, 여기에 쓴 식이 실패하면 후보 제외가 아니라 에러가 됩니다. 또 치환 중에 다른 클래스 템플릿을 인스턴스화하다가 그 클래스 본문에서 에러가 나면, 그것은 직접 문맥 밖이라 역시 하드 에러입니다.
최소 예제: 정수형만 받는 add
#include <type_traits>
#include <iostream>
// T가 정수형일 때만 이 오버로드 활성화
template <typename T>
typename std::enable_if<std::is_integral<T>::value, T>::type add(T a, T b) {
return a + b;
}
int main() {
std::cout << add(3, 5) << "\n"; // 8
std::cout << add(3u, 5u) << "\n"; // 8
// add(3.0, 5.0); // 컴파일 에러: is_integral<double> = false
return 0;
}
is_integral<T>::value가 false이면 enable_if는 ::type을 정의하지 않으므로 치환 실패가 발생하고, 이 오버로드는 후보에서 제외됩니다. add(3.0, 5.0)을 호출하면 남은 후보가 없어 “no matching function” 에러가 나고, 진단 메시지에 enable_if 조건 때문에 후보가 제외되었다는 설명이 붙습니다.
SFINAE 적용 위치 비교
flowchart LR
subgraph valid[SFINAE 적용됨]
V1[반환 타입]
V2[파라미터 타입]
V3[템플릿 매개변수 선언]
V4["explicit(bool) (C++20)"]
end
subgraph invalid[SFINAE 적용 안 됨]
I1[함수 본문]
I2[noexcept 명세]
I3[다른 클래스 템플릿 본문]
end
std::enable_if를 두는 자리: 반환 타입, 파라미터, 템플릿 기본값
enable_if 동작 원리
std::enable_if<Cond, T>는 조건 Cond가 true일 때만 ::type이 T로 정의됩니다. Cond가 false면 ::type이 없어서 치환 실패가 발생합니다.
// <type_traits> 내부 개념
template <bool B, typename T = void>
struct enable_if {};
template <typename T>
struct enable_if<true, T> {
using type = T;
};
// C++14: _t 별칭
template <bool B, typename T = void>
using enable_if_t = typename enable_if<B, T>::type;
두 번째 인자의 기본값이 void라는 점은 실수의 원인이 되기도 합니다. 반환 타입 자리에 std::enable_if_t<std::is_integral_v<T>>만 쓰면 그 함수의 반환 타입은 void가 되므로, 값을 돌려주는 함수라면 std::enable_if_t<조건, T>처럼 반환 타입을 두 번째 인자로 꼭 넣어야 합니다. 반대로 템플릿 매개변수 자리에 둘 때는 std::enable_if_t<조건, int> = 0처럼 값이 있는 타입을 쓰는 편이 안전합니다. typename = std::enable_if_t<조건> 형태는 조건만 다른 두 오버로드가 “기본 템플릿 인자만 다른 같은 선언”으로 취급되어 재선언 에러가 나기 때문입니다(아래 에러 2).
반환 타입에 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.0, 5.0) << "\n"; // 8 (부동소수, 기본 출력 형식)
// add("a", "b"); // 에러: 매칭되는 오버로드 없음
return 0;
}
add(3, 5)와 add(3.0, 5.0)은 서로 다른 오버로드입니다. 정수형과 부동소수형을 같은 함수에서 처리하려면 if constexpr나 Concepts를 사용하는 것이 좋습니다.
파라미터에 enable_if 사용 (더미 파라미터)
#include <type_traits>
#include <iostream>
template <typename T>
T add(T a, T b, typename std::enable_if<std::is_integral<T>::value, int>::type = 0) {
return a + b;
}
int main() {
std::cout << add(3, 5) << "\n"; // 8
return 0;
}
세 번째 파라미터는 “더미”입니다. enable_if가 성공하면 int 타입에 기본값 0이 붙으며, 실패하면 ::type이 없어 치환 실패가 됩니다. 호출 시 add(3, 5)처럼 두 인자만 넘기면 됩니다.
템플릿 기본값에 enable_if 사용
#include <type_traits>
#include <iostream>
template <typename T,
typename = std::enable_if_t<std::is_integral<T>::value>>
T add(T a, T b) {
return a + b;
}
int main() {
std::cout << add(3, 5) << "\n"; // 8
return 0;
}
이 방식은 오버로드 구분에 한계가 있습니다. 정수용과 부동소수용 add를 둘 다 typename = enable_if_t<...>로 만들면 재선언 에러가 납니다(아래 에러 2). 오버로드를 나눠야 한다면 std::enable_if_t<조건, int> = 0이나 std::enable_if_t<조건>* = nullptr처럼 조건이 매개변수의 타입 자체에 들어가는 형태를 씁니다.
enable_if로 오버로드 분리 (정수 vs 부동소수)
#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;
}
// 포인터는 제외
template <typename T>
std::enable_if_t<std::is_pointer_v<T>, T> add(T a, T b) = delete;
int main() {
std::cout << add(3, 5) << "\n";
std::cout << add(3.0, 5.0) << "\n";
// int x = 1, y = 2;
// add(&x, &y); // deleted function
return 0;
}
클래스 템플릿 부분 특수화로 분기 (void_t)
클래스 템플릿은 오버로드가 없으므로, 기본값이 void인 추가 템플릿 매개변수를 두고 조건이 맞을 때만 매칭되는 부분 특수화를 만듭니다. 아래 예제는 다음 절에서 설명할 void_t를 미리 사용합니다.
#include <type_traits>
#include <iostream>
#include <string>
#include <utility>
template <typename T, typename = void>
struct Printer {
static void print(const T& x) {
std::cout << "default: " << x << "\n";
}
};
// operator<<가 있는 타입
template <typename T>
struct Printer<T, std::void_t<decltype(std::cout << std::declval<T>())>> {
static void print(const T& x) {
std::cout << "stream: " << x << "\n";
}
};
int main() {
Printer<int>::print(42); // stream: 42
Printer<std::string>::print("hi"); // stream: hi
return 0;
}
void_t와 표현식 검사
void_t란?
std::void_t(C++17)는 임의의 타입들을 받아서 void로 매핑하는 메타 함수입니다. 표현식이 유효한지 검사할 때 사용합니다.
// C++17 <type_traits>
template <typename...>
using void_t = void;
// 사용 예 (개념 설명용)
// std::void_t<int> → void
// std::void_t<int, double> → void
// std::void_t<decltype(expr)> → expr이 유효하면 void, 아니면 치환 실패
void_t로 “이 타입에 size()가 있는지” 검사
#include <type_traits>
#include <iostream>
#include <vector>
#include <string>
template <typename T, typename = void>
struct has_size : std::false_type {};
template <typename T>
struct has_size<T, std::void_t<decltype(std::declval<T>().size())>> : std::true_type {};
template <typename T>
inline constexpr bool has_size_v = has_size<T>::value;
template <typename T>
std::enable_if_t<has_size_v<T>, size_t> get_size(const T& x) {
return x.size();
}
template <typename T>
std::enable_if_t<!has_size_v<T>, size_t> get_size(const T&) {
return sizeof(T);
}
int main() {
std::vector<int> v{1, 2, 3};
std::cout << get_size(v) << "\n"; // 3
std::cout << get_size(42) << "\n"; // sizeof(int)
return 0;
}
has_size<T>라고 쓰면 두 번째 인자는 기본값 void로 채워져 has_size<T, void>가 됩니다. 컴파일러는 부분 특수화가 이 인자 목록과 맞는지 확인하는데, std::declval<T>().size()가 유효한 식이면 void_t<...>가 void가 되어 특수화가 매칭되고 true_type이 선택됩니다. 식이 유효하지 않으면 decltype 치환이 실패해 특수화가 조용히 무시되고, 기본 템플릿의 false_type이 쓰입니다. std::declval<T>()는 T를 실제로 생성하지 않고 “T 타입의 값이 있다고 치는” 식을 만들어 주므로, 기본 생성자가 없는 타입도 검사할 수 있습니다.
void_t로 “이 타입에 begin()/end()가 있는지” 검사
#include <type_traits>
#include <vector>
#include <iostream>
template <typename T, typename = void>
struct is_iterable : std::false_type {};
template <typename T>
struct is_iterable<T, std::void_t<
decltype(std::declval<T>().begin()),
decltype(std::declval<T>().end())
>> : std::true_type {};
template <typename T>
inline constexpr bool is_iterable_v = is_iterable<T>::value;
template <typename T>
std::enable_if_t<is_iterable_v<T>> print_container(const T& c) {
for (const auto& x : c)
std::cout << x << " ";
std::cout << "\n";
}
int main() {
std::vector<int> v{1, 2, 3};
print_container(v); // 1 2 3
// print_container(42); // 에러: is_iterable_v<int> = false
return 0;
}
void_t로 멤버 타입 검사
#include <map>
#include <type_traits>
#include <vector>
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>>);
static_assert(has_value_type_v<std::map<int, int>>);
static_assert(!has_value_type_v<int>);
Detection Idiom
Detection Idiom이란?
Detection idiom은 “타입 T에 특정 표현식이 유효한지”를 검사하는 패턴입니다. void_t를 활용해, 유효하면 한 특수화가 매칭되고, 유효하지 않으면 기본 정의가 매칭됩니다.
표현식 검사 템플릿
#include <type_traits>
#include <utility>
template <typename AlwaysVoid, template <typename...> class Op, typename... Args>
struct detector : std::false_type {};
template <template <typename...> class Op, typename... Args>
struct detector<std::void_t<Op<Args...>>, Op, Args...> : std::true_type {};
template <template <typename...> class Op, typename... Args>
inline constexpr bool is_detected_v = detector<void, Op, Args...>::value;
Op<Args...>가 유효한 타입이면 void_t<Op<Args...>>는 void가 되고, detector<void, Op, Args...> 특수화가 매칭됩니다. Op<Args...>가 유효하지 않으면 치환 실패로 기본 정의가 사용됩니다.
”size() 멤버 함수” 검사
#include <type_traits>
#include <utility>
#include <vector>
#include <iostream>
template <typename AlwaysVoid, template <typename...> class Op, typename... Args>
struct detector : std::false_type {};
template <template <typename...> class Op, typename... Args>
struct detector<std::void_t<Op<Args...>>, Op, Args...> : std::true_type {};
template <template <typename...> class Op, typename... Args>
inline constexpr bool is_detected_v = detector<void, Op, Args...>::value;
template <typename T>
using size_result_t = decltype(std::declval<T>().size());
int main() {
std::cout << std::boolalpha;
std::cout << is_detected_v<size_result_t, std::vector<int>> << "\n"; // true
std::cout << is_detected_v<size_result_t, int> << "\n"; // false
return 0;
}
”operator<<” 검사
#include <type_traits>
#include <utility>
#include <iostream>
template <typename T>
using ostreamable_t = decltype(std::declval<std::ostream&>() << std::declval<T>());
template <typename AlwaysVoid, template <typename...> class Op, typename... Args>
struct detector : std::false_type {};
template <template <typename...> class Op, typename... Args>
struct detector<std::void_t<Op<Args...>>, Op, Args...> : std::true_type {};
template <template <typename...> class Op, typename... Args>
inline constexpr bool is_detected_v = detector<void, Op, Args...>::value;
template <typename T>
void print_if_streamable(const T& x) {
if constexpr (is_detected_v<ostreamable_t, T>) {
std::cout << x << "\n";
} else {
std::cout << "[not streamable]\n";
}
}
int main() {
print_if_streamable(42); // 42
print_if_streamable("hello"); // hello
// print_if_streamable(std::vector<int>{}); // [not streamable] (operator<< 없음)
return 0;
}
is_detected 패턴
is_detected 완전 구현
is_detected 계열은 Library Fundamentals TS v2에 std::experimental::is_detected로 들어 있을 뿐, C++17을 포함한 정식 표준 라이브러리에는 채택되지 않았습니다. libstdc++ 등 일부 구현이 <experimental/type_traits>로 제공하지만 이식성을 위해 직접 구현하는 경우가 많습니다.
#include <type_traits>
#include <utility>
namespace detail {
template <typename AlwaysVoid, template <typename...> class Op, typename... Args>
struct detector {
using value_t = std::false_type;
};
template <template <typename...> class Op, typename... Args>
struct detector<std::void_t<Op<Args...>>, Op, Args...> {
using value_t = std::true_type;
};
}
template <template <typename...> class Op, typename... Args>
struct is_detected : detail::detector<void, Op, Args...>::value_t {};
template <template <typename...> class Op, typename... Args>
inline constexpr bool is_detected_v = is_detected<Op, Args...>::value;
detected_or_t와 detected_t: 검사된 타입 추출
표현식이 유효할 때 그 결과 타입이 필요하면, 유효하지 않을 때 쓸 기본 타입을 함께 받는 detected_or_t를 만듭니다. TS의 detected_t는 기본 타입으로 아무 연산도 할 수 없는 nonesuch를 쓰는 특수한 경우입니다.
#include <cstddef>
#include <type_traits>
#include <utility>
#include <vector>
namespace detail {
template <typename Default, typename AlwaysVoid, template <typename...> class Op, typename... Args>
struct detector_or {
using type = Default;
};
template <typename Default, template <typename...> class Op, typename... Args>
struct detector_or<Default, std::void_t<Op<Args...>>, Op, Args...> {
using type = Op<Args...>;
};
}
template <typename Default, template <typename...> class Op, typename... Args>
using detected_or_t = typename detail::detector_or<Default, void, Op, Args...>::type;
struct nonesuch {
nonesuch() = delete;
~nonesuch() = delete;
nonesuch(const nonesuch&) = delete;
void operator=(const nonesuch&) = delete;
};
template <template <typename...> class Op, typename... Args>
using detected_t = detected_or_t<nonesuch, Op, Args...>;
// 사용: size() 반환 타입 추출
template <typename T>
using size_result_t = decltype(std::declval<T>().size());
template <typename T>
using size_type_t = detected_or_t<void, size_result_t, T>;
// size_type_t<std::vector<int>> → std::size_t
// size_type_t<int> → void (size() 없음)
is_detected_exact: 반환 타입까지 검사
식이 유효한지뿐 아니라 결과 타입이 정확히 원하는 타입인지까지 확인합니다. 검출에 실패하면 detected_t가 nonesuch가 되므로, Expected가 void인 경우에도 잘못 참이 되지 않습니다.
template <typename Expected, template <typename...> class Op, typename... Args>
using is_detected_exact = std::is_same<Expected, detected_t<Op, Args...>>;
static_assert(is_detected_exact<std::size_t, size_result_t, std::vector<int>>::value);
실전: has_reserve 검사
#include <type_traits>
#include <utility>
#include <vector>
#include <list>
#include <iostream>
template <typename T>
using reserve_expr = decltype(std::declval<T>().reserve(std::declval<size_t>()));
template <typename AlwaysVoid, template <typename...> class Op, typename... Args>
struct detector : std::false_type {};
template <template <typename...> class Op, typename... Args>
struct detector<std::void_t<Op<Args...>>, Op, Args...> : std::true_type {};
template <template <typename...> class Op, typename... Args>
inline constexpr bool is_detected_v = detector<void, Op, Args...>::value;
template <typename T>
inline constexpr bool has_reserve_v = is_detected_v<reserve_expr, T>;
template <typename T>
std::enable_if_t<has_reserve_v<T>> maybe_reserve(T& c, size_t n) {
c.reserve(n);
}
template <typename T>
std::enable_if_t<!has_reserve_v<T>> maybe_reserve(T&, size_t) {
// reserve 없음: 아무것도 안 함
}
int main() {
std::vector<int> v;
maybe_reserve(v, 100); // v.reserve(100) 호출됨
std::list<int> l;
maybe_reserve(l, 100); // 아무것도 안 함 (list에는 reserve 없음)
return 0;
}
본문 안 enable_if, 기본 인자 재선언, 모호한 오버로드 같은 에러
에러 1: enable_if가 함수 본문에 있어서 SFINAE가 안 됨
원인: SFINAE는 함수 시그니처에만 적용됩니다. 본문 안의 if (std::is_integral_v<T>) 같은 코드는 인스턴스화 후에 평가되므로, T가 정수가 아니면 이미 본문에서 에러가 난 뒤입니다.
// ❌ 잘못된 예: 본문에서 is_integral 사용
template <typename T>
T add(T a, T b) {
if (std::is_integral_v<T>) // T가 vector면 a+b에서 이미 에러
return a + b;
return a + b;
}
// ✅ 올바른 예: 반환 타입에 enable_if
template <typename T>
std::enable_if_t<std::is_integral_v<T>, T> add(T a, T b) {
return a + b;
}
에러 2: 기본 템플릿 파라미터 중복으로 재선언 에러
원인: 두 오버로드가 모두 template <typename T, typename = enable_if_t<...>>를 쓰면, 기본 템플릿 인자는 함수 템플릿 시그니처의 일부가 아니므로 두 템플릿은 template <typename T, typename> T add(T, T)라는 같은 선언이 됩니다. 조건이 서로 달라도 컴파일러는 같은 함수 템플릿을 두 번 정의했다고 보고 에러를 냅니다. std::enable_if_t<조건, int> = 0처럼 비타입 템플릿 인자의 타입에 조건을 넣거나 반환 타입에 두면 시그니처 자체가 달라져 공존할 수 있습니다.
// ❌ 잘못된 예
template <typename T, typename = std::enable_if_t<std::is_integral_v<T>>>
T add(T a, T b) { return a + b; }
template <typename T, typename = std::enable_if_t<std::is_floating_point_v<T>>>
T add(T a, T b) { return a + b; } // 재선언 에러!
// ✅ 올바른 예 1: 반환 타입에 enable_if
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; }
// ✅ 올바른 예 2: 비타입 템플릿 인자
template <typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0>
T sub(T a, T b) { return a - b; }
template <typename T, std::enable_if_t<std::is_floating_point_v<T>, int> = 0>
T sub(T a, T b) { return a - b; }
에러 3: void_t에 잘못된 표현식
원인: void_t 안의 표현식이 타입이어야 합니다. decltype(expr)을 사용해 타입으로 만듭니다.
// ❌ 잘못된 예
std::void_t<std::declval<T>().size()>; // size()는 값, 타입 아님
// ✅ 올바른 예
std::void_t<decltype(std::declval<T>().size())>;
에러 4: declval을 잘못된 컨텍스트에서 사용
원인: std::declval<T>()는 평가되지 않는 문맥(decltype, sizeof, noexcept 연산자 등) 전용입니다. 실제로 평가되는 코드에서 쓰면 표준상 ill-formed이고, libstdc++·libc++ 같은 구현은 static_assert로 컴파일 에러를 냅니다.
// ❌ 잘못된 예
auto x = std::declval<T>(); // 컴파일 에러: declval은 평가되는 문맥에서 쓸 수 없음
// ✅ 올바른 예
decltype(std::declval<T>().size());
에러 5: const/참조 무시
원인: std::declval<T>()는 T&&를 반환합니다. const T&로 호출하고 싶으면 std::declval<const T&>()를 사용합니다.
template <typename T>
using size_expr = decltype(std::declval<const T&>().size());
에러 6: 모든 오버로드가 SFINAE로 제외됨
원인: 어떤 오버로드의 조건도 만족하지 않는 타입을 넘기면 “no matching function” 에러가 나고, 메시지에는 각 후보가 왜 제외됐는지가 길게 나열됩니다. 정수용과 실수용 add만 있을 때 std::string을 넘기는 경우가 그렇습니다.
해결: 모든 타입을 받아야 한다면 나머지 조건의 부정으로 폴백 오버로드를 두고, 받지 않을 타입이라면 폴백 안에 static_assert를 두어 원인을 한 줄로 알려 줍니다.
template <typename T>
std::enable_if_t<!std::is_arithmetic_v<T>, T> add(T, T) {
static_assert(std::is_arithmetic_v<T>, "add는 산술 타입만 지원합니다");
return {};
}
에러 7: 구형 컴파일러에서 void_t가 SFINAE로 동작하지 않음
원인: 별칭 템플릿에서 쓰이지 않는 템플릿 인자의 치환 실패를 SFINAE로 볼지가 C++14 표준에서 모호했습니다(CWG 1558). 이 결함이 수정되기 전의 컴파일러(GCC 5 이전 등)에서는 template <typename...> using void_t = void;가 치환 실패를 무시해 버려, 검사가 항상 true가 될 수 있습니다.
해결: C++11/14 코드에서 직접 정의할 때는 구조체를 한 번 거치는 형태로 정의합니다. C++17의 std::void_t를 쓸 수 있다면 그것을 씁니다.
template <typename... Ts> struct make_void { using type = void; };
template <typename... Ts> using void_t = typename make_void<Ts...>::type;
에러 8: static_assert를 SFINAE 대신 사용
원인: static_assert는 치환 실패가 아니라 컴파일 에러를 발생시킵니다. 오버로드 후보를 “제외”하는 것이 아니라, 인스턴스화 자체를 막습니다.
// ❌ 오버로드 분기가 아님
template <typename T>
T add(T a, T b) {
static_assert(std::is_integral_v<T>, "T must be integral");
return a + b;
}
// add(3.0, 5.0) -> static_assert 실패로 컴파일 에러
// ✅ SFINAE: 다른 오버로드가 받을 수 있음
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; }
에러 9: C++20에서 Concepts와 혼용 시 혼란
원인: requires와 enable_if를 같은 함수에 섞어 쓰면 의도가 불명확해집니다.
해결: C++20 이상에서는 Concepts를 우선 사용하며, SFINAE는 레거시 호환용으로만 유지합니다.
// ✅ C++20: Concepts 사용
template <std::integral T>
T add(T a, T b) { return a + b; }
에러 10: 조건이 겹쳐서 모호한 오버로드
원인: 에러 6의 반대 경우입니다. 두 오버로드의 enable_if 조건이 서로 배타적이지 않으면, 둘 다 만족하는 타입에서 “ambiguous call” 에러가 납니다. is_integral과 is_signed로 나눈 오버로드에 int를 넘기면 두 조건이 모두 참이 되는 식입니다. SFINAE는 후보를 제거할 뿐 우선순위를 매기지 않으므로, 조건들이 타입 공간을 겹치지 않게 나눠야 합니다.
해결: 조건을 서로 배타적으로 만들거나(is_integral && is_signed / is_integral && is_unsigned), 우선순위가 필요하면 태그 디스패치나 C++20 Concepts의 포섭(subsumption) 규칙을 씁니다.
template <typename T>
std::enable_if_t<std::is_integral_v<T> && std::is_signed_v<T>> f(T);
template <typename T>
std::enable_if_t<std::is_integral_v<T> && std::is_unsigned_v<T>> f(T);
에러 11: 클래스 템플릿 멤버 함수에 enable_if를 직접 씀
원인: SFINAE는 그 함수 템플릿 자신의 템플릿 파라미터를 치환할 때(immediate context)만 동작합니다. 클래스 템플릿의 T로 멤버 함수를 조건부로 만들려고 std::enable_if_t<std::is_copy_constructible_v<T>, Foo> clone() const;처럼 쓰면, T는 클래스를 인스턴스화할 때 이미 정해지므로 치환 실패가 아니라 하드 에러가 됩니다. 복사 불가능한 타입으로 클래스를 만드는 순간 컴파일이 깨집니다.
해결: 멤버 함수를 템플릿으로 만들고 기본값을 T로 둔 자기 파라미터를 조건에 씁니다. 그래야 호출 시점에 치환이 일어나 SFINAE가 됩니다. C++20이라면 requires std::copy_constructible<T>를 멤버 함수에 붙이는 편이 훨씬 간단합니다.
template <typename T>
class Box {
public:
template <typename U = T> // ✅ 멤버 자신의 파라미터
std::enable_if_t<std::is_copy_constructible_v<U>, Box> clone() const;
Box clone2() const requires std::copy_constructible<T>; // ✅ C++20
};
설계 원칙: Concepts 우선, 검사 로직은 트레이트로 분리
C++20을 쓸 수 있다면 Concepts가 더 읽기 쉽고 에러 메시지도 좋으므로, SFINAE는 레거시 코드나 C++17 이하를 지원해야 하는 라이브러리에 남겨 둡니다. SFINAE를 쓴다면 has_size, is_iterable 같은 검사 로직을 이름 있는 트레이트로 분리해 두는 것이 좋습니다. 같은 decltype(...) 식을 여러 오버로드에 복사해 두면 하나만 고쳐 조건이 어긋나기 쉽고, 트레이트 이름 자체가 “이 오버로드는 T에 size()가 있을 때만 활성화된다”는 문서 역할을 합니다. 검사할 표현식은 실제 호출과 같은 값 범주로 써야 합니다. 함수가 const T&로 받는다면 std::declval<const T&>()로 검사해야 const 멤버 함수만 있는 경우를 정확히 반영합니다. void_t는 C++17의 std::void_t를 쓰고, C++11/14에서는 아래 에러 7의 make_void 형태로 직접 정의합니다.
enable_if의 위치는 상황에 따라 고릅니다. 반환 타입 자리는 눈에 잘 띄지만 생성자와 변환 연산자에는 반환 타입이 없으므로 쓸 수 없고, 이때는 template <typename T, std::enable_if_t<조건, int> = 0> 형태의 비타입 템플릿 인자가 가장 무난합니다.
JSON 직렬화 분기, reserve 최적화, 반복자 카테고리별 알고리즘
아래 패턴들은 앞에서 정의한 is_detected_v, is_iterable_v, reserve_expr를 사용합니다.
패턴 1: JSON 직렬화 타입 분기
여러 조건이 겹치는 분기는 부분 특수화 여러 개로 나누면 모호성이 생기기 쉽습니다. 예를 들어 std::string은 operator<<도 있고 begin()/end()도 있어서, 두 조건을 각각 특수화로 만들면 “ambiguous partial specialization” 에러가 납니다. 함수 하나 안에서 if constexpr로 우선순위를 정하는 편이 단순합니다.
#include <string>
#include <type_traits>
template <typename T>
std::string to_json(const T& x) {
if constexpr (std::is_same_v<T, bool>) {
return x ? "true" : "false";
} else if constexpr (std::is_arithmetic_v<T>) {
return std::to_string(x); // 숫자는 따옴표 없이
} else if constexpr (std::is_convertible_v<const T&, std::string>) {
return "\"" + std::string(x) + "\""; // 문자열 (이스케이프 처리는 생략)
} else if constexpr (is_iterable_v<T>) {
std::string result = "[";
bool first = true;
for (const auto& e : x) {
if (!first) result += ",";
result += to_json(e);
first = false;
}
return result + "]";
} else {
static_assert(sizeof(T) == 0, "JSON으로 직렬화할 수 없는 타입");
}
}
실제 JSON 출력에는 문자열 안의 따옴표와 제어 문자 이스케이프가 필요하므로, 실무에서는 nlohmann/json 같은 라이브러리를 쓰는 편이 낫습니다. 여기서는 분기 구조만 보면 됩니다.
패턴 2: 컨테이너 reserve 최적화
template <typename Container>
void append_reserved(Container& c, size_t extra) {
if constexpr (is_detected_v<reserve_expr, Container>) {
c.reserve(c.size() + extra);
}
// reserve가 없는 컨테이너(list 등)는 그냥 넘어감
}
패턴 3: 스마트 포인터와 raw 포인터 통합
template <typename T>
using get_expr = decltype(std::declval<T>().get());
template <typename T>
std::enable_if_t<std::is_pointer_v<T>, std::remove_pointer_t<T>&> deref(T p) {
return *p;
}
template <typename T>
std::enable_if_t<is_detected_v<get_expr, T>, typename T::element_type&> deref(T& smart) {
return *smart.get();
}
패턴 4: 반복자 카테고리별 알고리즘
카테고리 태그는 상속 관계(random_access는 bidirectional을, bidirectional은 forward를 상속)를 이루므로 is_same보다 is_base_of로 검사해야 더 강한 카테고리의 반복자도 올바르게 걸립니다. 표준 라이브러리의 std::advance가 이미 이 분기를 하므로, 실무에서는 직접 만들 일이 드뭅니다.
#include <iterator>
template <typename It>
using iter_category_t = typename std::iterator_traits<It>::iterator_category;
template <typename It>
std::enable_if_t<std::is_base_of_v<std::random_access_iterator_tag, iter_category_t<It>>>
advance_many(It& it, std::size_t n) {
it += static_cast<typename std::iterator_traits<It>::difference_type>(n); // O(1)
}
template <typename It>
std::enable_if_t<!std::is_base_of_v<std::random_access_iterator_tag, iter_category_t<It>>>
advance_many(It& it, std::size_t n) {
while (n--) ++it; // O(n)
}
패턴 5: 로깅 유틸리티
template <typename T>
std::enable_if_t<std::is_arithmetic_v<T>> log_value(const T& x) {
std::cout << "value: " << x << "\n";
}
template <typename T>
std::enable_if_t<std::is_pointer_v<T>> log_value(T p) {
if (p) std::cout << "ptr: " << *p << "\n";
else std::cout << "ptr: null\n";
}
template <typename T>
std::enable_if_t<is_iterable_v<T>> log_value(const T& c) {
std::cout << "container: [";
for (const auto& x : c) std::cout << x << " ";
std::cout << "]\n";
}
세 조건(산술 타입, 포인터, 반복 가능)이 서로 겹치지 않으므로 모호성이 없습니다. 조건을 추가할 때는 기존 조건과 겹치지 않는지 확인해야 합니다(에러 10).
패턴 6: has_serialize 검사와 명확한 에러
template <typename T>
using serialize_expr = decltype(std::declval<const T&>().serialize(std::declval<std::ostream&>()));
template <typename T>
inline constexpr bool has_serialize_v = is_detected_v<serialize_expr, T>;
template <typename T>
void save(const T& obj, std::ostream& out) {
static_assert(has_serialize_v<T>, "T must have serialize(ostream&) const");
obj.serialize(out);
}
대안 구현이 없고 “serialize가 없으면 사용자 실수”인 경우에는 오버로드를 둘로 나눌 필요 없이 함수 하나에 static_assert를 두는 편이 낫습니다. 에러 8에서 설명한 것처럼 static_assert는 후보를 제외하지 못하지만, 그 대신 메시지가 분명합니다.
SFINAE vs Concepts
비교표
| 항목 | SFINAE | Concepts (C++20) |
|---|---|---|
| 가독성 | enable_if 등으로 복잡 | requires로 선언적 |
| 에러 메시지 | 인스턴스화 스택이 길음 | 어떤 제약이 만족되지 않았는지 표시 |
| 오버로드 우선순위 | 조건을 서로 배타적으로 직접 나눠야 함 | 포섭(subsumption) 규칙으로 더 구체적인 제약이 이김 |
| C++ 표준 | C++11~ | C++20~ |
| 레거시 호환 | 널리 사용됨 | C++20 컴파일러 필요 |
마이그레이션 예시
// SFINAE (C++11~17)
template <typename T>
std::enable_if_t<std::is_integral_v<T>, T> add(T a, T b) { return a + b; }
// Concepts (C++20)
template <std::integral T>
T add(T a, T b) { return a + b; }
// void_t 기반 has_size 대신 requires 표현식
template <typename T>
concept HasSize = requires(const T& t) { t.size(); };
레거시 코드를 옮길 때는 한 번에 다 바꾸기보다, 검사 로직을 담은 트레이트(has_size_v 등)를 먼저 concept으로 바꾸고 이를 쓰는 오버로드를 하나씩 옮기는 편이 안전합니다. C++17 이하 컴파일러를 함께 지원해야 하는 라이브러리라면 매크로로 두 방식을 나누기보다 SFINAE를 유지하는 쪽이 유지보수 부담이 작은 경우가 많습니다.