본문 바로가기
프로그래밍/C & C++

[C++] Traits(특질) 구현 (2) - SFINAE

by 별준 2023. 2. 2.

References

  • Ch 19, C++ Templates The Complete Guide

Contents

  • SFINAE-Based Tratis
  • IsConvertibleT
  • Detecting Members

SFINAE-Based Tratis

SFINAE (substitution failure is not an error; 치환 실패는 에러가 아님) 원칙으로 인해 템플릿 인자를 추론하는 동안 형성되는 유효하지 않은 타입과 표현식은 에러가 아닌 단순히 추론 실패로 취급됩니다. 따라서 다른 오버로딩 후보를 선택할 수 있습니다. 원래 SFINAE는 함수 템플릿 오버로딩에서 발생할 수 있는 가짜 에러를 피하기 위해 도입되었지만, 현재는 특정 타입이나 표현식이 유효한지를 컴파일 과정에 결정할 수 있는 훌륭한 기법으로 사용됩니다. 이를 사용하면 어떤 타입이 특정 멤버를 가지고 있는지, 어떤 연산을 지원하는지, 아니면 클래스 인지 아닌지 등을 결정하는 특질을 구현할 수 있습니다.

 

SFINAE 기반의 특질은 일반적으로 두 가지 방법으로 나눌 수 있습니다.

  • SFINAE out function overloads
  • SFINAE out partial specializations

 

SFNIAE Out Function Overloads

먼저 살펴볼 SFINAE 기반 특질은 어떤 타입이 기본 생성이 가능하여 초기화를 위한 값을 전달하지 않아도 객체를 생성할 수 있는지 알아보는 기능을 가지며, 함수 오버로딩을 사용하는 기법입니다. 즉, 주어진 타입 T에 대해서 T()와 같은 표현식이 유효한지 확인합니다.

 

기본 구현은 아래와 같습니다.

#include <type_traits>
template<typename T>
struct IsDefaultConstructibleT {
private:
    // test() trying substitute call of a default constructor for T passed as U:
    template<typename U, typename = decltype(U())>
    static char test(void*);
    // test() fallback:
    template<typename>
    static long test(...);

public:
    static constexpr bool value = std::is_same<decltype(test<T>(nullptr)), char>::value;
};

 

함수 오버로딩을 활용하여 SFINAE 기반 특질을 구현할 때에는 일반적으로 다양한 반환 타입을 갖는 오버로딩 함수 템플릿 test() 두 개를 선언합니다.

template<...> static char test(void*);
template<...> static long test(...);

첫 번째 오버로드 함수는 요청된 검사가 성공했을 때 일치하도록 설계합니다 (어떻게 성공하는지에 대해서는 잠시 뒤에 살펴보겠습니다). 두 번째 오버로드 함수는 fallback이며, 첫 번째 함수가 일치하지 않으면 선택되는데, '...'을 사용하고 있어서 모든 호출에 일치하지만 첫 번째 오버로드에 일치하면 첫 번째를 선호하도록 설계됩니다.

따라서, 리턴 값인 value는 어떤 오버로드 test 함수가 선택되었는지에 따라 다릅니다.

static constexpr bool value = std::is_same<decltype(test<...>(nullptr)), char>::value;

첫 번째 test() 멤버, 즉, 반환 타입이 char인 오버로드 함수가 선택되면 value는 is_same<char,char>로 초기화되는데, 이 값은 true 입니다. 두 번째 test() 멤버가 선택되면 is_same<long, char>로 초기화되어 false가 됩니다.

 

이제, 우리가 테스트하고자 하는 속성에 대해서 자세히 다루어 보겠습니다. 첫 번째 test() 오버로드 함수는 오직 검사하고자 하는 조건을 통과했을 때만 유효해야 합니다. 이를 위해서는 첫 번째 test()의 U에 T를 전달하고 이름이 없는 두 번째 템플릿 인자를 전달하는데, 이 인자는 변환이 유효한 경우에만 유효합니다. 즉, 이 경우에서는 암묵적이든 명시적이든 기본 생성자인 U()가 있을 때에만 유효할 수 있는 표현식을 사용합니다. 그리고 이 표현식을 decltype으로 둘러싸서 타입 파라미터를 초기화할 수 있는 유효한 표현식으로 만들었습니다.

 

두 번째 템플릿 파라미터는 추론할 수 없기 때문에 어떠한 인자도 두 번째 파라미터로 전달되지 않습니다. 그리고 두 번째 템플릿 파라미터에 대해 명시적인 템플릿 인자도 제공하지 않습니다. 따라서, 두 번째 템플릿 인자에 대한 치환이 실패하면 SFINAE에 의해서 첫 번쨰 오버로드 test() 선언은 버려지게 되고 대체 함수(두 번째 test())만 일치하게 됩니다.

 

결과적으로 이렇게 구현한 템플릿은 아래와 같이 사용할 수 있습니다.

IsDefaultConstructibleT<int>::value // yields true

struct S {
    S() = delete;
};
IsDefaultConstructibleT<S>::value // yields false

 

참고로 첫 번째 test() 함수에서 템플릿 파라미터 T를 직접 사용할 수 없다는 점에 유의해야 합니다.

#include <type_traits>
template<typename T>
struct IsDefaultConstructibleT {
private:
    // ERROR: test() uses T directly:
    template<typename, typename = decltype(T())>
    static char test(void*);
    // test() fallback:
    template<typename>
    static long test(...);

public:
    static constexpr bool value = std::is_same<decltype(test<T>(nullptr)), char>::value;
};

이렇게 구현하면 제대로 동작하지 않습니다. 참고 서적에서는 어떠한 T에 대해서도 모든 멤버 함수가 항상 치환되어, 첫 번째 test() 오버로드 함수를 무시하는게 아닌 컴파일 에러가 발생한다고 언급하고 있습니다만, 저의 경우에는 컴파일 에러는 발생하지 않고 항상 첫 번째 test() 함수로 선택되는 되는 것과 동일한 결과를 출력하고 있는 것이 확인됩니다. 정확히 어떻게 동작하는지는 잘 알지는 못하나, 정상적으로 치환 및 치환 무시가 되지 않으므로 사용할 때 주의해야 합니다.

 

Making SFINAE-Based Traits Predicate Traits

Predicate traits(조건자 특질)은 불리언 값을 반환하는 특질이며, std::true_type 또는 std::false_type으로부터 유도되는 값만 반환해야 합니다.

 

이를 가능하게 하려면 IsDefaultConstructibleT의 간접 정의가 필요하며, 특질 자체는 헬퍼 클래스의 Type을 상속받아야 합니다. 다행히, 이는 test() 오버로드 함수의 리턴 타입으로 베이스 클래스를 사용하면 됩니다.

template<typename U, typename = decltype(U())>
static std::true_type test(void*);
template<typename>
static std::false_type test(...);

이러한 방식으로 베이스 클래스의 멤버인 Type은 다음과 같이 선언될 수 있습니다.

using Type = decltype(test<FROM>(nullptr));

 

이렇게 개선된 구현은 다음과 같습니다.

#include <type_traits>
template<typename T>
struct IsDefaultConstructibleHelper {
private:
    template<typename U, typename = decltype(U())>
    static std::true_type test(void*);
    template<typename>
    static std::false_type test(...);

public:
    using Type = decltype(test<T>(nullptr));
};
template<typename T>
struct IsDefaultConstructibleT : IsDefaultConstructibleHelper<T>::Type {
};

 

SFINAE Out Partial Specializations

SFINAE 기반 특질을 구현하는 두 번째 방법은 부분 특수화를 사용하는 것 입니다. 이 방법을 사용하여 위에서 구현한 것과 같은 동일한 기능의 특질을 구현해보겠습니다.

 

기본 구현은 다음과 같습니다.

#include <type_traits>
// helper to ignore any number of template parameters:
template<typename...> using VoidT = void;

// primary template:
template<typename, typename = VoidT<>>
struct IsDefaultConstructibleT : std::false_type {};

// partial specialization (may be SFINAE'd away):
template<typename T>
struct IsDefaultConstructibleT<T, VoidT<decltype(T())>> : std::true_type {};

먼저 std::false_type을 상속받는 일반적인 경우를 정의했습니다. 흥미로운 부분은 헬퍼인 VoidT의 타입을 기본값으로 갖는 두 번째 템플릿 인자입니다. 이를 사용하면 임의의 수의 컴파일-시간 타입 생성을 사용하는 부분 특수화를 제공할 수 있습니다.

 

이번 구현에서는 T에 대한 기본 생성자가 유효한지 검사할 때 아래와 같이 단 한 번만 생성하면 됩니다.

decltype(T())

특정 T에 대해 유효한 생성자가 없다면 SFINAE는 전체 부분 특수화를 버리고 기본(primary) 템플릿을 대신 사용합니다. 그렇지 않다면 부분 특수화는 유효하면 이를 더 선호하게 됩니다.

 

C++17에서는 이에 대응하는 특질을 표준 라이브러리에서 제공합니다 (std::void_t<>).

 

SFINAE-Friendly Traits

일반적으로 타입 특질은 특정 쿼리에 대해 답을 하면서도 프로그램의 형태를 망치지 않아야 합니다. SFINAE 기반의 특질을 사용하면 SFINAE 문맥 내에서 문제가 될 만한 부분을 부정적인 결과의 에러로 바꾸어 해결합니다.

 

예를 들어, 아래의 PlusResultT 특질은 에러가 있는 경우 제대로 동작하지 않습니다.

#include <utility>

template<typename T1, typename T2>
struct PlusResultT {
    using Type = decltype(std::declval<T>() + std::declval<T2>());
};

template<typename T1, typename T2>
using PlusResult = typename PlusResult<T1, T2>::Type;

위 정의에서는 SFINAE로 보호받지 못하는 곳에서 +를 사용합니다. 따라서, + 연산자가 없는 타입에 대해서도 PlusResultT로 평가하려고 하고, 이 때문에 에러가 발생합니다.

 

PlusResultT를 사용하는 예제를 통해 이 문제를 살펴보겠습니다. 여기서는 서로 관련이 없는 타입인 A와 B를 요소로 갖는 배열을 받아 더했을 때의 결과 타입을 선언하고자 합니다.

template<typename T>
class Array {
    ...
};

// declare + for arrays of different element types:
template<typename T1, typename T2>
Array<typename PlusResultT<T1, T2>::Type>
operator+(Array<T1> const&, Array<T2> const&);

여기서 PlusResultT<>를 사용하면 배열 요소에 대한 + 연산자가 정의되어 있지 않기 때문에 분명히 에러가 발생할 것 입니다.

 

class A {};
class B {};

void addAB(Array<A> arrayA, Array<B> arrayB) {
    auto sum = arrayA + arrayB;
    ...
}

실질적인 문제는 위와 같이 잘못 짜여진 코드(A의 배열과 B의 배열은 더할 수 없음) 때문에 컴파일 에러가 발생하는 것이 아니라 PlusResultT<A, B>를 인스턴스화하는 도중에 operator+를 위한 템플릿 인자를 추론하는 동안 컴파일 에러가 발생한다는 점입니다.

 

이 결과는 상당히 놀라운데, 이는 A와 B 배열을 더하는 다른 오버로딩을 추가하더라도 컴파일에 실패할 수 있다는 것을 의미합니다. 이는 다른 오버로딩이 더 나을 때, 함수 템플릿 내의 어떤 타입이 실제로 인스턴스화될 것인지 여부를 C++에서 명시하고 있지 않기 때문입니다.

// declare generic + for arrays of different element types:
template<typename T1, typename T2>
Array<typename PlusResultT<T1, T2>::Type>
operator+(Array<T1> const&, Array<T2> const&);

// overload + for concrete types:
Array<A> operator+(Array<A> const&, Array<B> const&);

void addAB(Array<A> const& arrayA, Array<B> const& arrayB) {
    auto sum = arrayA + arrayB; // ERROR? : depends on whether the compiler instantiates PlusResultT<A,B>
    ...
}

만약 컴파일러가 첫 번째 operator+ 선언의 추론과 치환을 수행하지 않고 두 번째 operator+ 선언이 더 낫다고 결정할 수 있다면, 위 코드는 가능합니다. 하지만, 함수 템플릿의 후보를 추론하고 치환하는 동안, 클래스 템플릿 정의의 인스턴스화에서 발생하는 모든 것들은 그 함수 템플릿의 직접적인 문맥 내에 속하지 않고, 이로 인해 잘못된 타입이나 표현식을 만들었을 때에도 SFINAE가 코드를 보호해주지 못합니다. 따라서 PlusResultT<> 내에서 A와 B 타입의 두 요소에 대해 operator+를 호출하려고 했기 때문에 함수 템플릿 후보를 버리지 않고 에러를 발생시킵니다.

 

이 문제를 해결하려면 PlusResultT가 SFINAE 친화적(SFINAE-friendly)이어야 합니다. 이렇게 해야 decltype 표현식이 잘못 만들어지더라도 적절하게 정의될 수 있어 코드가 더욱 강건해집니다.

 

이를 해결하기 위해 먼저 주어진 타입에 대해 적절한 + 연산이 있는지 검사하는 HasPlusT 특질을 정의해보겠습니다.

// primary template:
template<typename, typename, typename = std::void_t<>>
struct HasPlusT : std::false_type {};

// partial specialization (may be SFINAE'd away):
template<typename T1, typename T2>
struct HasPlusT<T1, T2,
    std::void_t<decltype(std::declval<T1>() + std::declval<T2>())>> : std::true_type {};

이전 PlusResultT의 결과가 참이라면 상관없지만, 거짓이라면 PlusResultT에 대한 안전한 기본값이 필요합니다. 특질에 대한 가장 좋은 기본값은 템플릿 인자 집합에 대해 의미없는 결과를 가져야 하므로 Type 멤버를 제공하지 않는 것이 좋습니다. 이런 방식으로 특질이 SFINAE 문맥 내에서 사용되면 Type이라는 멤버가 없기 때문에 템플릿 인자 추론이 실패합니다.

이와 같이 동작하는 PlusResultT는 다음과 같이 구현할 수 있습니다.

template<typename T1, typename T2, bool = HasPlusT<T1, T2>::value>
struct PlusResultT {    // primary template, used when HasPlusT yields true
    using Type = decltype(std::declval<T1>() + std::declval<T2>());
};

template<typename T1, typename T2>
struct PlusResultT<T1, T2, false> {};   // partial specialization, used otherwise

위 버전의 PlusResultT에서는 처음 두 개의 템플릿 파라미터를 통해 HasPlusT 특질을 사용하여 덧셈을 지원하는지 여부를 검사하는 세 번째 템플릿 파라미터를 추가했습니다. 그리고, 이 추가 파라미터에 대해 false 값을 갖는 부분 특수화를 구현합니다. 부분 특수화 정의에서는 멤버가 없기 때문에 앞서 본 문제가 발생하지 않습니다.

 

void addAB(Array<A> const& arrayA, Array<B> const& arrayB) {
    auto sum = arrayA + arrayB;
    ...
}

이제 다시 위 함수를 살펴보면 A와 B의 값은 서로 더할 수 없으므로 PlusResultT<A,B>의 인스턴스화에는 Type 멤버가 없습니다. 따라서 배열 oeprator+ 템플릿의 결과 타입은 유효하지 않고 SFINAE에 따라 이 함수 템플릿은 후보군에서 빠지게 됩니다. 그러면 Array<A>와 Array<B>에 대해 오버로딩한 operator+가 선택됩니다.

 

일반적인 설계 법칙에 의해서 특질 템플릿은 합리적인 템플릿 인자가 입력으로 주어졌을 때, 인스턴스화 시간에 절대 실패하면 안됩니다. 그리고 일반적으로 다음과 같이 두 번 검사하도록 수행됩니다.

  1. 연산이 유효한지 확인
  2. 그 결과를 계산

PlusResultT의 경우, operator+를 호출한 것이 유효한지 알아보기 위해 HasPlusT<>를 사용했습니다.

 

만약 컨테이너의 요소 타입을 알아내는 역할을 수행하는 특질을 구현할 때는위의 접근 방법을 사용하여 다음과 같이 구현할 수 있습니다. 여기서 컨테이너 타입이 value_type이라는 멤버 타입을 가진다는 것을 활용하므로 기본 템플릿은 컨테이너 타입이 value_type을 가질 때만 Type 멤버를 정의합니다.

template<typename C, bool = HasMemberT_value_type<C>::value>
struct ElementT {
    using Type = typename C::value_type;
};

template<typename C>
struct ElementT<C, false> {};

 


IsConvertibleT

SFINAE 기반 특질의 세부 사항을 살펴보면 실제 구현에서는 좀 더 복잡해질 수 있습니다. 이를 알아보기 위해 어떤 타입이 다른 타입(예를 들면, 특정 베이스 클래스나 파생 클래스 중 하나로)으로 변환될 수 있는지를 체크하는 특질을 구현해보면서 살펴보도록 하겠습니다.

아래에서 구현한 IsConvertibleT 특질은 첫 번째로 전달된 타입이 두 번째 타입으로 변환될 수 있는지 체크합니다.

#include <utility>      // for declval
#include <type_traits>  // for true_type, false_type, and void_t

template<typename FROM, typename TO>
struct IsConvertibleHelper {
private:
    // test() trying to call the helper aux(TO) for a FROM passed as F:
    static void aux(TO);
    template<typename F, typename = decltype(aux(std::declval<F>()))>
    static std::true_type test(void*);
    // test() fallback:
    template<typename>
    static std::false_type test(...);

public:
    using Type = decltype(test<FROM>(nullptr));
};

template<typename FROM, typename TO>
struct IsConvertibleT : IsConvertibleHelper<FROM, TO> {};

template<typename FROM, typename TO>
using IsConvertible = typename IsConvertibleT<FROM, TO>::Type;

template<typename FROM, typename TO>
constexpr bool isConvertible = IsConvertible<FROM, TO>::value;

여기서는 오버로딩을 활용하는 방법을 사용하여 특질을 구현했습니다. 헬퍼 클래스 내에서 서로 다른 리턴 타입을 갖는 test() 함수 템플릿 오버로딩 두 개를 선언하고, Type 멤버를 선언합니다.

 

여기서 첫 번째 test() 오버로딩 버전은 요청한 검사를 성공할 때만 일치하도록 설계되었고, 두 번째 오버로딩은 어떤 경우라도 항상 일치합니다. 따라서, FROM 타입을 TO 타입으로 변환할 수 있을 때에만 첫 번째 test() 오버로딩 함수가 유효해야 합니다. 이를 위해서 변환이 유효할 때만 생성될 수 있는 값으로 초기화되는 더미 템플릿 인자를 전달하는데, 이 파라미터는 추론될 수 없으며 명시적 템플릿 인자를 제공하지도 않습니다. 따라서 템플릿 치환이 실패하는 경우, 첫 번째 test()의 선언은 버려지게 됩니다.

 

참고로 내부 private 멤버 함수를 다음과 같이 정의하면 코드가 올바르게 동작하지 않습니다.

struct IsConvertibleHelper {
private:
    // test() trying to call the helper aux(TO) for a FROM passed as F:
    static void aux(TO);
    template<typename = decltype(aux(std::declval<FROM>()))>
    static std::true_type test(void*);
    ...
};

여기서 FROM과 TO는 메버 함수 템플릿이 파싱될 때 완전히 정해지고, 따라서 변환이 유효하지 않는 경우에 대해서 test()를 호출하기 전에 컴파일 에러가 발생합니다. 즉, SFINAE 문맥 밖에서 문제가 발생하게 됩니다. 이 때문에 이 멤버 함수에 템플릿 파라미터 F를 도입했습니다.

 

이 특질은 다음과 같이 사용할 수 있습니다.

#include <string>
int main()
{
    std::cout << IsConvertible<int, int>::value << std::endl;                 // 1
    std::cout << IsConvertible<int, std::string>::value << std::endl;         // 0
    std::cout << IsConvertible<char const*, std::string>::value << std::endl; // 1
    std::cout << IsConvertible<std::string, char const*>::value << std::endl; // 0
}

 

Handling Special Cases

위에서 구현한 IsConvertibleT는 다음의 세 가지 경우에 대해 올바르게 처리하지 못합니다.

  1. 배열 타입으로의 변환은 항상 false 이어야 하지만, aux()의 선언 내 타입 TO의 파라미터는 포인터 타입으로 형 소실되고, 이 때문에 일부 FROM 타입에 대해서 true로 결과를 내보낼 수 있습니다.
  2. 함수 타입으로의 변환은 항상 false 이어야 하지만, 배열과 같이 형 소실된 타입을 다루게 됩니다.
  3. void 타입(const/volatile 한정자가 있을 수 있음)으로의 변환은 항상 true 이어야 합니다. 하지만 파라미터 타입은 void 타입일 수 없기 때문에 위 구현에서는 TO가 void라면 인스턴스화조차 되지 못합니다.

위 세 가지 경우에 대해서 모두 부분 특수화를 추가해주어야 합니다. 하지만 발생할 수 있는 const와 volatile 한정자 조합마다 모든 특수화를 만드려면 꽤 일이 커지게 됩니다. 대신 헬퍼 클래스 템플릿에 다음과 같이 템플릿 파라미터를 추가하면 됩니다.

template<typename FROM, typename TO,
                    bool = std::is_void<TO>::value
                            || std::is_array<TO>::value
                            || std::is_function<TO>::value>
struct IsConvertibleHelper {
    using Type = std::integral_constant<bool,
                            std::is_void<TO>::value && std::is_void<FROM>::value>;
};

template<typename FROM, typename TO>
struct IsConvertibleHelper<FROM, TO, false> {
private:
    // Is ConvertibleHelper 이전 구현
    ...
};

세 번째 템플릿 파라미터를 추가하는 것만으로 위에서 언급한 세 가지 상황에 대해 모두 사용할 수 있게 됩니다.

배열이나 함수로 변환하려 하거나(std::is_void<TO>::value가 false 이므로), FROM이 void인데 TO가 void가 아니거나 둘 다 void라면 false_type을 도출합니다.

기존 구현에서 아래와 같이 사용하면 컴파일 에러가 발생하지만, 위와 같이 변경해주면 에러가 발생하지 않습니다.

int main()
{
    std::cout << IsConvertible<int, void>::value << std::endl;              // 0
    std::cout << IsConvertible<int, char(int, long)>::value << std::endl;   // 0
    std::cout << IsConvertible<int[], int*>::value << std::endl;            // 1
    std::cout << IsConvertible<int*, int[]>::value << std::endl;            // 0
}

 


Detecting Members

마지막으로 SFINAE 기반의 특질 하나를 더 살펴보겠습니다. 이번에 알아볼 특질은 주어진 타입 T에 X인 멤버(타입 또는 타입이 아닌 멤버)가 있는지 확인하는 특질입니다.

 

Detecting Member Types

주어진 타입 T에 size_type이라는 멤버 타입을 갖는지 체크하는 특질을 구현하면 다음과 같습니다.

#include <type_traits>  // for true_type, false_type, and void_t

// helper to ignore any number of template parameters:
template<typename...> using VoidT = void;

// primary template:
template<typename, typename = VoidT<>>
struct HasSizeTypeT : std::false_type {};

// partial specialization (may be SFINAE'd away):
template<typename T>
struct HasSizeTypeT<T, VoidT<typename T::size_type>> : std::true_type {};

여기서는 앞서 설명한 부분 특수화(partial specialization)를 사용하여 SFINAE 기법을 활용했습니다.

일반적인 경우에서는 std::false_type을 상속받도록 했고, 여기서는 기본적으로 타입에 size_type이라는 멤버가 없다고 가정합니다.

HasSizeTypeT의 부분 특수화에서는 T::size_type이라는 멤버 타입이 있을 때에만 유효하기 때문에 우리의 목적에 알맞습니다. 특정 T에서는 이 부분 특수화가 유효하지 않고, 그러면 SFINAE에 따라 해당 부분 특수화는 버려지게 되어 기본 템플릿이 사용됩니다. 유효하다면 부분 특수화도 유효하며, 이를 더 선호하게 됩니다.

 

이 특질을 사용하는 방법은 다음과 같습니다.

#include <iostream>

struct CX {
    using size_type = std::size_t;
};

int main()
{
    std::cout << HasSizeTypeT<int>::value << std::endl; // false
    std::cout << HasSizeTypeT<CX>::value << std::endl;  // true
}

 

Dealing with Reference Types

HasSizeTypeT와 같은 특질 템플릿에서 참조자 타입을 사용해보도록 하겠습니다.

struct CXR {
    using size_type = char&;
};

int main()
{
    std::cout << HasSizeTypeT<CXR>::value << std::endl;  // OK: true
}

위 코드는 true를 출력합니다. 하지만 아래 코드는 모두 false를 출력합니다.

int main()
{
    std::cout << HasSizeTypeT<CX&>::value << std::endl;  // prints false
    std::cout << HasSizeTypeT<CXR&>::value << std::endl; // prints false
}

참조자 타입은 그 자체로는 멤버를 갖지 않습니다. 하지만 참조자를 사용할 때 결과 표현식은 항상 underlying type(?)이 됩니다. 따라서, 이러한 상황에서는 underlying type으로 생각하는 것이 좋고, 이를 처리하기 위해서 참조자 타입을 위한 HasSizeTypeT의 부분 특수화를 구현해주면 좋습니다 (<type_traits>에 구현된 std::remove_reference 활용).

template<typename T>
struct HasSizeTypeT<T, VoidT<typename std::remove_reference<T>::type::size_type>> : std::true_type {};

 

Injected Class Names

HasSizeTypeT는 injected class name에 대해서도 true를 도출합니다.

struct size_type {};
struct Sizeable : size_type {};
static_assert(HasSizeTypeT<Sizeable>::value, "Compiler bug: Injected class name missing");

여기서는 size_type은 자신의 이름을 멤버 타입으로 도입했고, 그 이름은 상속되었으므로 static assert는 성공합니다.

 

Detecting Arbitary Member Types

방금 구현한 HasSizeTypeT와 같은 특질을 보면 어떤 임의의 멤버 타입 이름을 검사하는 특질을 어떻게 파라미터화할 수 있을까라는 의문이 생깁니다.

 

아쉽지만, 언어 메커니즘에서는 잠재적인 이름을 설명할 수 있는 방법이 없기 때문에, 매크로를 사용해서 구현할 수 있습니다. 매크로를 사용하지 않는 방법은 현재 generic lambda를 사용하는 방법뿐입니다.

 

아래와 같의 정의된 매크로는 잘 동작합니다.

#include <type_traits>  // for true_type, false_type, and void_t

#define DEFINE_HAS_TYPE(MemType)                                    \
    template<typename, typename = std::void_t<>>                      \
    struct HasTypeT_##MemType : std::false_type {};                 \
    template<typename T>                                            \
    struct HasTypeT_##MemType<T, std::void_t<typename T::MemType>>  \
        : std::true_type {}

DEFINE_HAS_TYPE을 사용할 때마다 새로운 HasType_MemberType 특질이 정의됩니다. 예를 들어, 해당 타입에 value_type이나 char_type이라는 멤버 타입이 있는지 알아보고 싶다면 다음과 같이 작성하면 됩니다.

#include <iostream>
#include <vector>

DEFINE_HAS_TYPE(value_type);
DEFINE_HAS_TYPE(char_type);

int main()
{
    std::cout << "int::value_type: " << HasTypeT_value_type<int>::value << std::endl;
    std::cout << "std::vector<int>::value_type: " << HasTypeT_value_type<std::vector<int>>::value << std::endl;
    std::cout << "std::iostream::value_type: " << HasTypeT_value_type<std::iostream>::value << std::endl;
    std::cout << "std::iostream::char_type: " << HasTypeT_char_type<std::iostream>::value << std::endl;
}

 

Detecting Nontype Members

특질을 살짝 수정하여 데이터 멤버와 (single) 멤버 함수를 검사할 수도 있습니다.

#define DEFINE_HAS_MEMBER(Member)                                    \
    template<typename, typename = std::void_t<>>                      \
    struct HasMember_##Member : std::false_type {};                 \
    template<typename T>                                            \
    struct HasMember_##Member<T, std::void_t<decltype(&T::Member)>>  \
        : std::true_type {}

여기서는 &T::Member가 유효하지 않을 때, 부분 특수화를 비활성화하도록 SFINAE를 사용합니다. 이렇게 생성하는 것이 가능하려면 아래의 조건들이 true 이어야 합니다.

  • Member는 반드시 모호하지 않고 식별할 수 있는 T의 멤버 이어야 합니다. 예를 들어, 오버로딩된 멤버 함수 이름이나 같은 이름을 갖는 다중 상속된 멤버의 이름이어서는 안됩니다.
  • 접근 가능한 멤버이어야 합니다.
  • 타입이나 열거형의 멤버이어서는 안됩니다. 그렇지 않으면 '&' 때문에 에러가 발생합니다.
  • T::Member가 static 데이터 멤버라면 &T::Member를 유효하지 않도록 만드는 operator&를 제공해서는 안됩니다.

 

이 템플릿은 다음과 같이 작성하여 사용할 수 있습니다.

#include <iostream>
#include <vector>
#include <utility>

DEFINE_HAS_MEMBER(size);
DEFINE_HAS_MEMBER(first);

int main()
{
    std::cout << "int::size: " << HasMember_size<int>::value << std::endl;
    std::cout << "std::vector<int>::size: " << HasMember_size<std::vector<int>>::value << std::endl;
    std::cout << "std::pair<int,int>::first: " << HasMember_first<std::pair<int,int>>::value << std::endl;
}

 

Detecting Member Functions

위에서 정의한 HasMember 특질은 찾고 있는 이름이 하나만 존재할 때만 검사합니다. 오버로딩된 멤버 함수가 있거나, 이름이 같은 멤버가 두 개 이상 있다면 이 특질은 실패하게 됩니다. 아래 코드는 실제로는 true이지만, false를 출력하게 됩니다.

DEFINE_HAS_MEMBER(begin);

int main()
{
    std::cout << HasMember_begin<std::vector<int>>::value << std::endl; // false
}

하지만 SFINAE 원칙 덕분에 함수 템플릿 선언 내에서 유효하지 않은 타입과 표현식을 만드는 시도에 대해 보호할 수 있고, 오버로딩 기법을 확장하여 임의의 표현식의 형태가 잘 이루어졌는지 테스트해볼 수 있습니다.

 

즉, 확인하고 싶은 함수를 특정 방식으로 호출할 수 있는지, 그리고 그 함수가 오버로딩되었을 때에도 그 방식으로 호출할 수 있는지 간단히 검사할 수 있습니다. 여기서 decltype 표현식 내에서 begin()을 호출할 수 있는지 검사하는 표현식을 만드는 기법을 사용합니다.

#include <utility>      // for declval
#include <type_traits>  // for true_type, false_type, and void_t

// primary template:
template<typename, typename = std::void_t<>>
struct HasBeginT : std::false_type {};

// partial specialization (may be SFINAE'd away)
template<typename T>
struct HasBeginT<T, std::void_t<decltype(std::declval<T>().begin())>>
    : std::true_type {};

#include <iostream>
#include <vector>

int main()
{
    std::cout << HasBeginT<std::vector<int>>::value << std::endl; // true
}

위 코드는 T라는 타입의 값/객체에 대해 멤버 함수 begin()을 호출하는 것이 유효한지 확인합니다.

 

Detecting Other Expressions

방금 설명한 기법을 통해 다른 표현식들, 심지어 여러 표현식을 조합한 것에도 적용할 수 있습니다. 예를 들어, T1과 T2 타입이 있을 때, 각 타입의 값들에 대해 적절한 < 연산자가 정의되어 있는지 검사할 수 있습니다.

#include <utility>      // for declval
#include <type_traits>  // for true_type, false_type, and void_t

// primary template:
template<typename, typename, typename = std::void_t<>>
struct HasLessT : std::false_type {};

// partial specialization (may be SFINAE'd away)
template<typename T1, typename T2>
struct HasLessT<T1, T2, std::void_t<decltype(std::declval<T1>() < std::declval<T2>())>>
    : std::true_type {};

지금까지 살펴본 것과 마찬가지로 검사할 조건에 대해 유효한 표현식을 정의하는 것이 포인트입니다. decltype을 사용하여 해당 표현식을 SFINAE 문맥 내에 둡니다. 이렇게 하면 검사할 표현식이 유효하지 않을 때 primary template이 default가 됩니다. 따라서, 표현식이 유효할 때만 true를 도출하게 되며, < 연산자가 모호하거나 없거나 접근할 수 없을 때에는 false를 반환합니다.

 

이 특질은 다음과 같이 사용할 수 있습니다.

#include <iostream>
#include <string>
#include <complex>

int main()
{
    std::cout << HasLessT<int, char>::value << std::endl;                                   // true
    std::cout << HasLessT<std::string, std::string>::value << std::endl;                    // true
    std::cout << HasLessT<std::string, int>::value << std::endl;                            // false
    std::cout << HasLessT<std::string, char*>::value << std::endl;                          // true
    std::cout << HasLessT<std::complex<double>, std::complex<double>>::value << std::endl;  // false
}

 

이러한 특질을 잘 활용하면 템플릿 파라미터 T가 '<' 연산자를 지원해야 한다는 요구사항을 추가할 수 있습니다.

template<typename T>
class C
{
    static_assert(HasLessT<T>::value, "Class C requires comparable elements");
    ...
};

 

 

또한, std::void_t의 특징 덕분에 하나의 특질 안에 여러 제약 사항을 함께 결합시킬 수도 있습니다.

#include <utility>      // for declval
#include <type_traits>  // for true_type, false_type, and void_t

// primary template:
template<typename, typename = std::void_t<>>
struct HasVariousT : std::false_type {};

// partial specialization (may be SFINAE'd away)
template<typename T>
struct HasVariousT<T, std::void_t<decltype(std::declval<T>().begin()), 
                        typename T::difference_type,
                        typename T::iterator>>
    : std::true_type {};

 

이처럼 문법의 유효성을 검출하는 특질은 꽤나 강력하며 유용합니다. 

댓글