SFINAE

SFINAE

템플릿 메타프로그래밍에서 오버로드 해석이 어떤 함수를 선택할지 정할 때, 템플릿 매개변수에 치환한 결과가 잘못된 코드가 되면 컴파일 오류를 내는 대신 그 특수화를 후보에서 빼버리는 규칙이 있어요. 그 규칙의 머리글자를 따서 SFINAE("Substitution Failure Is Not An Error", 치환 실패는 오류가 아니다)라고 불러요.

출처: cppreference

본문

동작 방식(Explanation)

함수 템플릿 매개변수는 (템플릿 인자로) 두 번 치환돼요.

  • 명시적으로 지정된 템플릿 인자는 템플릿 인자 추론 전에 치환된다
  • 추론된 인자와 기본 인자에서 얻은 인자는 템플릿 인자 추론 후에 치환된다

치환은 다음에서 일어나요.

  • 함수 타입에 쓰인 모든 타입(반환 타입과 모든 매개변수의 타입 포함)
  • 템플릿 매개변수 선언에 쓰인 모든 타입
  • 부분 특수화(partial specialization)의 템플릿 인자 목록에 쓰인 모든 타입
  • 함수 타입에 쓰인 모든 표현식(C++11부터)
  • 템플릿 매개변수 선언에 쓰인 모든 표현식(C++11부터)
  • 부분 특수화의 템플릿 인자 목록에 쓰인 모든 표현식(C++11부터)
  • explicit 지정자에 쓰인 모든 표현식(C++20부터)

**치환 실패(substitution failure)**는 위의 타입이나 표현식을 치환된 인자로 써서 적어본다면 (진단이 요구되는 방식으로) ill-formed가 되는 모든 상황이에요.

함수 타입이나 그 템플릿 매개변수 타입, 또는 explicit 지정자(C++20부터)의 직접 문맥(immediate context) 안에 있는 타입과 표현식에서의 실패만 SFINAE 오류가 돼요. 치환된 타입/표현식의 평가가 어떤 템플릿 특수화의 인스턴스화나 암시적으로 정의된 멤버 함수의 생성 같은 부수 효과를 일으키는데, 그 부수 효과에서 발생하는 오류는 **하드 오류(hard error)**로 취급돼요. 람다 표현식은 직접 문맥의 일부로 간주되지 않아요. (C++20부터)

치환은 어휘 순서로 진행되며 실패를 만나면 멈춰요.

어휘 순서가 다른 여러 선언이 있어서(예: 뒤따르는 반환 타입으로 선언된 함수 템플릿과, 매개변수보다 앞서 치환될 보통의 반환 타입으로 다시 선언된 것) 템플릿 인스턴스화가 다른 순서로 일어나거나 아예 일어나지 않게 된다면, 프로그램은 ill-formed이고 진단이 필요 없어요. (C++11부터)

template<typename A>
struct B { using type = typename A::type; };

template<
    class T,
    class U = typename T::type,    // T에 type 멤버가 없으면 SFINAE 실패
    class V = typename B<T>::type> // B에 type 멤버가 없으면 하드 오류
                                   // (U의 기본 템플릿 인자에 대한 치환이 먼저
                                   //  실패하므로 CWG 1227 때문에 이것은
                                   //  발생하지 않음이 보장된다)
void foo(int);

template<class T>
typename T::type h(typename B<T>::type);

template<class T>
auto h(typename B<T>::type) -> typename T::type; // 재선언

template<class T>
void h(...) {}

using R = decltype(h<int>(0)); // ill-formed, 진단 불필요

타입 SFINAE(Type SFINAE)

다음 타입 오류들은 SFINAE 오류예요.

  • 서로 다른 길이의 여러 팩을 포함하는 팩 확장을 인스턴스화하려는 시도 (C++11부터)
  • void 배열, 참조 배열, 함수 배열, 음수 크기 배열, 정수가 아닌 크기의 배열, 또는 크기 0 배열을 만들려는 시도:
template<int I>
void div(char(*)[I % 2 == 0] = nullptr)
{
    // I가 짝수일 때 이 오버로드가 선택된다
}

template<int I>
void div(char(*)[I % 2 == 1] = nullptr)
{
    // I가 홀수일 때 이 오버로드가 선택된다
}
  • 스코프 해석 연산자 :: 왼쪽에 클래스나 열거형이 아닌 타입을 쓰려는 시도:
template<class T>
int f(typename T::B*);

template<class T>
int f(T);

int i = f<int>(0); // 두 번째 오버로드 사용
  • 타입의 멤버를 사용하려는 시도인데, 그 타입이 지정된 멤버를 포함하지 않거나, 지정된 멤버가 타입이 요구되는 곳에서 타입이 아니거나, 템플릿이 요구되는 곳에서 템플릿이 아니거나, 비타입이 요구되는 곳에서 비타입인 경우:
template<int I>
struct X {};

template<template<class T> class>
struct Z {};

template<class T>
void f(typename T::Y*) {}

template<class T>
void g(X<T::N>*) {}

template<class T>
void h(Z<T::template TT>*) {}

struct A {};
struct B { int Y; };
struct C { typedef int N; };
struct D { typedef int TT; };
struct B1 { typedef int Y; };
struct C1 { static const int N = 0; };
struct D1
{
    template<typename T>
    struct TT {};
};

int main()
{
    // 다음 각 경우에서 추론이 실패한다:
    f<A>(0); // A에는 멤버 Y가 없다
    f<B>(0); // B의 멤버 Y는 타입이 아니다
    g<C>(0); // C의 멤버 N은 비타입이 아니다
    h<D>(0); // D의 멤버 TT는 템플릿이 아니다

    // 다음 각 경우에서 추론이 성공한다:
    f<B1>(0);
    g<C1>(0);
    h<D1>(0);
}
  • 참조에 대한 포인터를 만들려는 시도
  • void에 대한 참조를 만들려는 시도
  • T가 클래스 타입이 아닌데 T의 멤버에 대한 포인터를 만들려는 시도:
template<typename T>
class is_class
{
    typedef char yes[1];
    typedef char no[2];

    template<typename C>
    static yes& test(int C::*); // C가 클래스 타입이면 선택

    template<typename C>
    static no& test(...);       // 그 외에는 선택
public:
    static bool const value = sizeof(test<T>(nullptr)) == sizeof(yes);
};
  • 비타입 템플릿 매개변수에 잘못된 타입을 주려는 시도:
template<class T, T>
struct S {};

template<class T>
int f(S<T, T()>*);

struct X {};
int i0 = f<X>(0);
  • 템플릿 인자 표현식이나 함수 선언에 쓰인 표현식에서 잘못된 변환을 수행하려는 시도:
template<class T, T*> int f(int);
int i2 = f<int, 1>(0); // 1을 int*로 변환할 수 없음
  • void 타입 매개변수를 가진 함수 타입을 만들려는 시도
  • 배열 타입이나 함수 타입을 반환하는 함수 타입을 만들려는 시도

표현식 SFINAE(Expression SFINAE)

C++11 이전에는 타입에서 쓰이는 상수 표현식(예: 배열 경계)만 SFINAE(하드 오류가 아니라)로 취급하도록 요구됐어요.

다음 표현식 오류들은 SFINAE 오류예요.

  • 템플릿 매개변수 타입에 쓰인 ill-formed 표현식
  • 함수 타입에 쓰인 ill-formed 표현식:
struct X {};
struct Y { Y(X){} }; // X는 Y로 변환 가능

template<class T>
auto f(T t1, T t2) -> decltype(t1 + t2); // 오버로드 #1

X f(Y, Y);                               // 오버로드 #2

X x1, x2;
X x3 = f(x1, x2); // #1에서 추론이 실패한다 (표현식 x1 + x2가 ill-formed)
                  // 오버로드 집합에 #2만 남고, 호출된다

(C++11부터)

부분 특수화에서의 SFINAE(SFINAE in partial specializations)

클래스 또는 변수(C++14부터) 템플릿의 특수화가 어떤 부분 특수화나 기본 템플릿에 의해 생성되는지 결정할 때에도 추론과 치환이 일어나요. 그런 결정 동안 치환 실패는 하드 오류로 취급되지 않고, 함수 템플릿의 오버로드 해석에서처럼 해당 부분 특수화 선언이 무시되도록 만들어요.

// 기본 템플릿이 참조할 수 없는 타입을 처리한다:
template<class T, class = void>
struct reference_traits
{
    using add_lref = T;
    using add_rref = T;
};

// 특수화가 참조할 수 있는 타입을 인식한다:
template<class T>
struct reference_traits<T, std::void_t<T&>>
{
    using add_lref = T&;
    using add_rref = T&&;
};

template<class T>
using add_lvalue_reference_t = typename reference_traits<T>::add_lref;

template<class T>
using add_rvalue_reference_t = typename reference_traits<T>::add_rref;

라이브러리 지원(Library support)

표준 라이브러리 구성요소 std::enable_if는, 컴파일 타임에 평가된 조건에 따라 특정 오버로드를 활성화하거나 비활성화하기 위해 치환 실패를 만드는 데 쓸 수 있어요. (C++11부터)

또한 많은 타입 특성(type traits)은 적절한 컴파일러 확장이 없다면 SFINAE로 구현해야 해요.

표준 라이브러리 구성요소 std::void_t는 부분 특수화 SFINAE 응용을 단순화하는 또 다른 유틸리티 메타함수예요. (C++17부터)

대안(Alternatives)

적용 가능한 곳에서는 태그 디스패치(tag dispatch), if constexpr(C++17부터), 개념(concepts)(C++20부터)이 보통 SFINAE보다 선호돼요.

조건부 컴파일 타임 오류만 원한다면 보통 static_assert가 SFINAE보다 선호돼요. (C++11부터)

예제

흔한 관용구는 반환 타입에 표현식 SFINAE를 쓰는 것이에요. 콤마 연산자를 쓰는데, 왼쪽 피연산자는 검사 대상 표현식(반환 타입에 정의된 사용자 정의 콤마 연산자가 선택되지 않도록 void로 캐스팅)이고, 오른쪽 피연산자는 함수가 반환해야 하는 타입을 가지는 것이죠.

#include <iostream>

// C가 클래스 또는 클래스-참조 타입이고 F가 C의 멤버 함수에 대한 포인터이면
// 이 오버로드가 오버로드 집합에 추가된다
template<class C, class F>
auto test(C c, F f) -> decltype((void)(c.*f)(), void())
{
    std::cout << "(1) Class/class reference overload called\n";
}

// C가 클래스-포인터 타입이고 F가 C의 멤버 함수에 대한 포인터이면
// 이 오버로드가 오버로드 집합에 추가된다
template<class C, class F>
auto test(C c, F f) -> decltype((void)((c->*f)()), void())
{
    std::cout << "(2) Pointer overload called\n";
}

// 이 오버로드는 항상 오버로드 집합에 있다: 줄임표
// 매개변수는 오버로드 해석에서 가장 낮은 순위를 가진다
void test(...)
{
    std::cout << "(3) Catch-all overload called\n";
}

int main()
{
    struct X { void f() {} };
    X x;
    X& rx = x;
    test(x, &X::f);  // (1)
    test(rx, &X::f); // (1), x의 복사본을 만든다
    test(&x, &X::f); // (2)
    test(42, 1337);  // (3)
}

출력:

(1) Class/class reference overload called
(1) Class/class reference overload called
(2) Pointer overload called
(3) Catch-all overload called

test(x, &X::f)는 클래스 오버로드를, test(&x, &X::f)는 포인터 오버로드를 선택하고, 둘 다 매칭되지 않는 test(42, 1337)은 catch-all 오버로드로 넘어가요.

더 알아보기

  • 템플릿 메타프로그래밍: SFINAE가 주로 쓰이는 분야.
  • 오버로드 해석(overload resolution): 함수 템플릿 후보 선택 과정.
  • requires 표현식과 개념(concepts): C++20 이후 SFINAE를 대체하는 더 선호되는 수단.