SFINAE 패턴 정리: enable_if, void_t, Detection Idiom과 Concepts로 옮길 시점

들어가며: “이 타입에 size()가 있는지 컴파일 타임에 알고 싶어요”

구체적인 문제 시나리오

템플릿을 작성하다 보면 이런 상황을 자주 겪습니다:

  • 정수형만 받는 함수를 만들었는데, std::vector를 넘기면 50줄 넘는 템플릿 인스턴스화 에러가 난다
  • 컨테이너를 받아 size()로 크기를 반환하고 싶은데, int나 double도 넘어올 수 있어서 “이 타입에 size()가 있는지”를 컴파일 타임에 검사하고 싶다
  • 직렬화 함수에서 operator<<가 있는 타입만 처리하며, 없으면 to_string()으로 폴백하고 싶다
  • 반복자를 받는 알고리즘에서 std::random_access_iterator인지 std::forward_iterator인지에 따라 다른 구현을 선택하고 싶다 이런 “타입에 따라 다른 오버로드를 선택”하거나 “표현식이 유효한지 검사”하는 기법이 SFINAE(Substitution Failure Is Not An Error)입니다. 비유하면 “이 자리에 맞는 부품만 끼워 넣을 수 있다”고 규칙을 두면, 맞지 않는 부품은 “에러”가 아니라 “이 오버로드는 후보에서 제외”되는 것입니다.

SFINAE 동작 원리 시각화

flowchart TD
    A[템플릿 인스턴스화 시도] --> B{치환 Substitution 성공?}
    B -->|예| C[오버로드 후보에 포함]
    B -->|아니오| D[에러가 아님 - 후보에서 제외]
    D --> E{다른 후보가 있음?}
    E -->|예| F[다른 후보로 해결]
    E -->|아니오| G[no matching function 에러]

추가 문제 시나리오

JSON 직렬화

int, double, std::string, std::vector 등 타입별로 직렬화 방식이 다릅니다. operator<<가 있는 타입은 스트림으로, begin()/end()가 있는 타입은 배열로 출력하고 싶습니다. SFINAE로 “이 타입에 begin()이 있는지” 검사해 오버로드를 분기합니다.

로깅 유틸리티

log(T x)에서 T가 std::string이면 그대로, int*면 역참조해서 출력하며, std::vector면 [1,2,3] 형태로 출력하고 싶습니다. std::enable_if로 타입별 오버로드를 나눕니다.

알고리즘 최적화

std::vector는 []로 O(1) 접근이 가능하지만 std::list는 불가능합니다. “이 컨테이너에 operator[]가 있는지” 검사해 random_access 구현과 forward 구현을 분리합니다.

스마트 포인터 래퍼

T*와 std::unique_ptr<T>를 모두 받는 함수에서, T*는 그대로 사용하고 unique_ptr은 .get()으로 포인터를 꺼내야 합니다. std::is_pointer_v와 has_get_member 같은 detection으로 분기합니다.

프로토콜 버퍼

has_serialize() 메서드가 있는 타입만 직렬화하며, 없으면 static_assert로 컴파일 에러를 내고 싶습니다. Detection idiom으로 “이 타입에 serialize(OutputStream&)가 있는지” 검사합니다.

테스트 목 오브젝트

Mock 클래스에 expect_call() 메서드가 있는지 검사해, 있으면 Mock 모드로, 없으면 실제 구현으로 분기하는 테스트 유틸리티를 만들 때 SFINAE를 사용합니다.

“치환 실패는 에러가 아니다”라는 SFINAE 규칙에서 출발해 std::enable_if로 오버로드를 켜고 끄는 방법, void_t와 Detection idiom, 재사용 가능한 is_detected 패턴까지 차례로 다룹니다.


SFINAE 기초

SFINAE란?

SFINAE(Substitution Failure Is Not An Error)는 C++ 템플릿에서 치환(Substitution) 과정에서 발생하는 실패를 에러로 처리하지 않고, 해당 오버로드 후보만 제외하는 규칙입니다. 템플릿을 인스턴스화할 때, 컴파일러는 템플릿 파라미터를 실제 타입으로 치환합니다. 이 과정에서 std::enable_if<false, T>::type처럼 유효하지 않은 타입이 나오면, 그 오버로드는 “에러”가 아니라 후보에서 제외됩니다. 다른 오버로드가 매칭되면 정상적으로 컴파일됩니다.

치환이 일어나는 곳

SFINAE가 적용되는 곳은 함수 타입에 직접 관여하는 부분입니다:

  • 함수 반환 타입
  • 함수 파라미터 타입
  • 템플릿 파라미터의 기본값
  • 함수 noexcept 지정자 (C++11~) 다음은 SFINAE가 적용되지 않는 곳입니다 (치환 실패 시 에러):
  • 함수 본문 안
  • static_assert (함수 본문에 있으면 인스턴스화 시 평가됨)

최소 예제: 정수형만 받는 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;
}

설명: std::enable_if<std::is_integral<T>::value, T>::type에서 is_integral<T>::value가 false이면 enable_if는 ::type을 정의하지 않습니다. 따라서 치환 실패가 발생하며, 이 오버로드는 후보에서 제외됩니다. add(3.0, 5.0)을 호출하면 “no matching function” 에러가 나지만, “정수형이 아님”이라는 의도가 드러납니다.

SFINAE 적용 위치 비교

flowchart LR
    subgraph valid[SFINAE 적용됨]
        V1[반환 타입]
        V2[파라미터 타입]
        V3[템플릿 기본값]
        V4[noexcept]
    end
    subgraph invalid[SFINAE 적용 안 됨]
        I1[함수 본문]
        I2[클래스 본문]
    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;

반환 타입에 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.0 (부동소수)
    // 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<...>를 쓰면 기본 템플릿 파라미터가 같아져 재선언 에러가 날 수 있습니다. 이때는 typename std::enable_if<...>::type* = 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;
}

enable_if로 클래스 템플릿 특수화

#include <type_traits>
#include <iostream>
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(x)>;   // x가 유효하면 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;
}

동작 원리:

  1. has_size<T, void>는 기본적으로 false_type을 상속합니다.
  2. has_size<T, void_t<decltype(std::declval<T>().size())>>에서 T().size()가 유효한 표현식이면 void_t<...>는 void가 됩니다.
  3. void는 두 번째 템플릿 파라미터의 기본값과 같으므로, 이 특수화가 선택됩니다.
  4. T().size()가 유효하지 않으면 decltype이 실패해 치환 실패가 되고, 기본 정의가 사용됩니다.

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 <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>>);
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 완전 구현

C++17 표준에는 std::experimental::is_detected가 있지만, 표준 라이브러리에 포함되지 않았습니다. 직접 구현하는 패턴입니다.

#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_t: 검사된 타입 추출

#include <type_traits>
#include <utility>
namespace detail {
    template <typename Default, typename AlwaysVoid, template <typename...> class Op, typename... Args>
    struct detector {
        using value_t = Default;
    };
    template <typename Default, template <typename...> class Op, typename... Args>
    struct detector<Default, std::void_t<Op<Args...>>, Op, Args...> {
        using value_t = Op<Args...>;
    };
}
template <typename Default, template <typename...> class Op, typename... Args>
using detected_t = typename detail::detector<Default, void, Op, Args...>::value_t;
// 사용: size() 반환 타입 추출, 없으면 void
template <typename T>
using size_result_t = decltype(std::declval<T>().size());
template <typename T>
using size_type_t = detected_t<void, size_result_t, T>;
// vector<int>::size() -> size_t
// int에는 size() 없음 -> void

is_detected_exact: 반환 타입까지 검사

#include <type_traits>
#include <utility>
template <typename Expected, template <typename...> class Op, typename... Args>
struct is_detected_exact : std::false_type {};
template <typename Expected, template <typename...> class Op, typename... Args>
struct is_detected_exact<Expected, Op, Args...>
    : std::is_same<Expected, detected_t<void, Op, Args...>> {};

실전: 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 = 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; }  // 재선언 에러!
// ✅ 올바른 예: 반환 타입이나 서로 다른 기본 파라미터 사용
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; }

에러 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>()는 선언 전용입니다. sizeof나 decltype 안에서만 사용해야 합니다.

// ❌ 잘못된 예
auto x = std::declval<T>();  // 링크 에러 또는 정의 필요
// ✅ 올바른 예
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: 중첩된 템플릿에서 쉼표 해석

원인: std::void_t<A, B>에서 쉼표가 “함수 인자 구분”으로 해석될 수 있습니다. (void)A, (void)B처럼 괄호로 감싸거나, 단일 decltype((expr))로 묶습니다.

// ✅ 안전한 다중 표현식 검사
template <typename T>
struct has_begin_end : std::false_type {};
template <typename T>
struct has_begin_end<T, std::void_t<
    decltype(std::declval<T>().begin()),
    decltype(std::declval<T>().end())
>> : std::true_type {};

에러 7: 모든 오버로드가 SFINAE로 제외됨

원인: 조건이 너무 엄격해 어떤 타입도 매칭되지 않을 수 있습니다.

// ❌ int와 double 둘 다 is_integral, is_floating_point가 false인 경우?
// (실제로는 int는 integral, double은 floating_point)
// ✅ 폴백 오버로드 제공
template <typename T>
std::enable_if_t<std::is_arithmetic_v<T>, T> add(T a, T b) {
    return a + b;
}

에러 8: MSVC에서 void_t 특수화 문제

원인: 일부 구 MSVC는 void_t 특수화를 다르게 처리할 수 있습니다. 해결: __void_t 같은 컴파일러 확장 대신, struct detector 패턴을 명시적으로 사용합니다.

에러 9: 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; }

에러 10: 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; }

에러 11: 조건이 겹쳐서 모호한 오버로드

원인: 에러 7의 반대 경우입니다. 두 오버로드의 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);

에러 12: 클래스 템플릿 멤버 함수에 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 사용

Concepts가 더 읽기 쉽고 에러 메시지도 좋습니다.

// ✅ C++20
template <std::integral T>
T add(T a, T b) { return a + b; }
// SFINAE는 레거시 또는 라이브러리 호환용

enable_if는 반환 타입에 두기

반환 타입이 가장 눈에 잘 띄고, 오버로드 구분도 명확합니다.

template <typename T>
std::enable_if_t<std::is_integral_v<T>, T> add(T a, T b) { return a + b; }

검사 로직을 별도 트레이트로 분리

has_size, is_iterable 등을 재사용 가능한 트레이트로 만들어 두면 유지보수가 쉽습니다.

template <typename T>
inline constexpr bool has_size_v = /* ... */;
template <typename T>
std::enable_if_t<has_size_v<T>, size_t> get_size(const T& x) { return x.size(); }

void_t는 표준 std::void_t 사용 (C++17)

C++17에서는 std::void_t를 사용하며, C++11/14에서는 template<typename...> using void_t = void;를 직접 정의합니다.

detected_t로 반환 타입 추출

표현식이 유효할 때 그 결과 타입도 필요하면 detected_t를 사용합니다.

template <typename T>
using size_type_t = detected_t<void, decltype(std::declval<T>().size()), T>;

const/참조 일관성

const T&로 호출할 함수라면 std::declval<const T&>()를 사용해 검사합니다.

문서화

SFINAE 조건이 복잡하면 “이 오버로드는 T에 size()가 있을 때만 활성화”처럼 주석을 달아 둡니다.


JSON 직렬화 분기, reserve 최적화, 반복자 카테고리별 알고리즘

패턴 1: JSON 직렬화 타입 분기

#include <type_traits>
#include <iostream>
#include <sstream>
#include <vector>
#include <string>
template <typename T, typename = void>
struct JsonSerializer {
    static std::string serialize(const T& x) {
        return std::to_string(x);
    }
};
template <typename T>
struct JsonSerializer<T, std::void_t<decltype(std::declval<std::ostream&>() << std::declval<T>())>> {
    static std::string serialize(const T& x) {
        std::ostringstream oss;
        oss << x;
        return "\"" + oss.str() + "\"";
    }
};
template <typename T>
struct JsonSerializer<T, std::void_t<
    decltype(std::declval<T>().begin()),
    decltype(std::declval<T>().end())
>> {
    static std::string serialize(const T& x) {
        std::string result = "[";
        bool first = true;
        for (const auto& e : x) {
            if (!first) result += ",";
            result += JsonSerializer<std::decay_t<decltype(e)>>::serialize(e);
            first = false;
        }
        result += "]";
        return result;
    }
};

패턴 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 없으면 그냥 push_back
}

패턴 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: 반복자 카테고리별 알고리즘

template <typename It>
std::enable_if_t<std::is_same_v<typename std::iterator_traits<It>::iterator_category,
                                std::random_access_iterator_tag>, void>
advance_many(It& it, size_t n) {
    it += n;
}
template <typename It>
std::enable_if_t<!std::is_same_v<typename std::iterator_traits<It>::iterator_category,
                                 std::random_access_iterator_tag>, void>
advance_many(It& it, size_t n) {
    while (n--) ++it;
}

패턴 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) {
    std::cout << "ptr: " << (p ? std::to_string(*p) : "null") << "\n";
}
template <typename T>
std::enable_if_t<is_iterable_v<T> && !std::is_pointer_v<T>> log_value(const T& c) {
    std::cout << "container: [";
    for (const auto& x : c) std::cout << x << " ";
    std::cout << "]\n";
}

패턴 6: 타입 안전한 to_string

template <typename T>
std::enable_if_t<std::is_arithmetic_v<T>, std::string> to_string_safe(const T& x) {
    return std::to_string(x);
}
template <typename T>
std::enable_if_t<std::is_same_v<T, std::string>, std::string> to_string_safe(const T& s) {
    return s;
}
template <typename T>
std::enable_if_t<!std::is_arithmetic_v<T> && !std::is_same_v<T, std::string>, std::string>
to_string_safe(const T&) {
    return "[non-serializable]";
}

패턴 7: 프로토콜 버퍼 has_serialize 검사

template <typename T>
using serialize_expr = decltype(std::declval<T>().serialize(std::declval<std::ostream&>()));
template <typename T>
inline constexpr bool has_serialize_v = is_detected_v<serialize_expr, T>;
template <typename T>
std::enable_if_t<has_serialize_v<T>> save(const T& obj, std::ostream& out) {
    obj.serialize(out);
}
template <typename T>
std::enable_if_t<!has_serialize_v<T>> save(const T& obj, std::ostream& out) {
    static_assert(has_serialize_v<T>, "T must have serialize(ostream&)");
}

SFINAE vs Concepts

비교표

항목SFINAEConcepts (C++20)
가독성enable_if 등으로 복잡requires로 선언적
에러 메시지인스턴스화 스택이 길음”constraints not satisfied” 등 명확
작성 난이도높음상대적으로 낮음
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; }

SFINAE 코드 점검 항목

실무에서 SFINAE를 도입할 때 확인할 항목입니다.

  • C++20이 가능한가? → Concepts 우선 검토
  • 조건이 함수 시그니처(반환 타입, 파라미터, 템플릿 기본값)에 있는가? → 본문이 아닌 시그니처
  • 오버로드 간 기본 템플릿 파라미터가 겹치지 않는가?
  • void_t 안에 decltype으로 타입을 넘기는가?
  • std::declval을 sizeof/decltype 안에서만 사용하는가?
  • 폴백 오버로드가 필요한가? (모든 타입이 제외되지 않도록)
  • 문서화가 되어 있는가? (복잡한 조건일 때)
  • 모든 코드 블록에 언어 태그(cpp, mermaid)가 있는가?

같이 보면 좋은 글


SFINAE로 타입 조건에 따라 오버로드를 분기하며, void_t·detection idiom으로 “이 타입에 X가 있는지”를 컴파일 타임에 검사할 수 있습니다. C++20에서는 Concepts를 우선 고려하세요.


자주 묻는 질문 (FAQ)

Q. enable_if로 조건만 다른 함수 템플릿 두 개를 만들었는데 redefinition 에러가 나는 이유는 무엇인가요?

A. enable_if를 기본 템플릿 인자 자리(typename = std::enable_if_t<...>)에 넣으면, 기본 인자는 함수 템플릿 시그니처의 일부가 아니라서 두 템플릿이 같은 선언으로 취급됩니다. 그래서 조건이 서로 달라도 컴파일러는 같은 함수를 두 번 정의했다고 보고 에러를 냅니다. std::enable_if_t<조건, int> = 0처럼 비타입 템플릿 인자의 타입에 조건을 넣거나 반환 타입에 enable_if를 두면 시그니처가 달라져 오버로드로 공존할 수 있습니다.