KoreanFoodie's Study

C++ 기초 개념 17-1 : type_traits 라이브러리, SFINAE, enable_if 본문

Tutorials/C++ : Beginner

C++ 기초 개념 17-1 : type_traits 라이브러리, SFINAE, enable_if

GoldGiver 2022. 5. 25. 17:51

모두의 코드를 참고하여 핵심 내용을 간추리고 있습니다. 자세한 내용은 모두의 코드의 씹어먹는 C++ 강좌를 참고해 주세요!

템플릿 메타 함수

템플릿 메타 함수는 사실 함수는 아니지만 마치 함수처럼 동작하는 템플릿 클래스들을 의미한다. 이들이 메타 함수인 이유는 보통의 함수들은 에 대해 연산을 수행하지만, 메타 함수는 타입에 대해 연산을 수행한다는 점이 조금 다르다.

예시를 보자.

// 음수인지 체크
if (is_negative(x))
{
  // Do something...
}

// 타입이 void 인지 체크
if (is_void<T>::value) 
{

}

아래 코드를 돌려보자.

#include <iostream>
#include <type_traits>

template<typename T>
void tell_type()
{
	if (std::is_void<T>::value)
	{
		std::cout << "T 는 void! \n";
	}
	else
	{
		std::cout << "T 는 void 가 아니다. \n";
	}
}

int main()
{
	tell_type<int>(); // T 는 void 가 아니다. 

	tell_type<void>(); // T 는 void!
}

템플릿 메타 함수들은 실제론 함수가 아니기 때문에, ( ) 대신 < > 를 통해 함수 인자가 아닌 템플릿 인자를 전달하고 있다. 실제로 is_void 는 클래스로 구현되어 있다.

 

is_void

템플릿 메타프로그래밍에서 if 문은 템플릿 특수화를 통해 구현된다. is_void 도 마찬가지인데, 예시를 보자.

template <typename T>
struct is_void
{
	static constexpr bool value = false;
};

template <>
struct is_void<void>
{
	static constexpr bool value = true;
};

 

마찬가지로, 정수 타입인지 확인해 주는 is_integral 을 이용한 함수를 만들어 보자.

template <typename T>
void only_integer(const T& N)
{
	static_assert(std::is_integral<T>::value);
	std::cout << "T is an integer : " << N << std::endl;
}

static_assert 는 C++ 11 에 추가된 키워드(함수 아님)로, 인자로 전달된 식이 참인지 아닌지를 컴파일 타임에 확인한다. 즉, bool 타입의 constexpr 만 static_assert 로 확인할 수 있고 그 이외의 경우에는 컴파일 오류가 발생한다.

이처럼, static_assert 와 type_traits 의 메타 함수들을 잘 조합해서 특정 타입만 받는 함수를 간단하게 작성할 수 있다.

 

 

is_class

type_traits 에 정의되어 있는 메타 함수들 중, 인자로 전달된 타입이 클래스인지 아닌지를 확인하는 메타 함수가 있다.

cppreference 에 구현되어 있는 코드를 보면,

namespace detail
{
	tempate <class T>
	char test(int T::*);
	struct two
	{
		char c[2];
	};

	template <class T>
	two test(...);
} // namespace detail

template <class T>
struct is_class
: std::integral_constant<bool, sizeof(detail::test<T>(0)) == 1 && !std::is_union<T>::value> {};

차근차근 위의 코드를 분석해보자.

 

먼저 std::integral_constant 에 대해 알아보자. integral_constant 는 std::integral_constant<T, T v> 로 정의되어 있는데, 그냥 v 를 static 인자로 가지는 클래스이다. 쉽게 말해 그냥 어떠한 값을 static 객체로 가지고 있는 클래스를 만들어주는 템플릿 이라고 생각하면 된다.

예를 들어 std::integral_constant<bool, false> 는 그냥 integral_constant<bool, false>::value 가 false 인 클래스이다. 따라서 만약

sizeof(detail::test<T>(0)) == 1 && !std::is_union<T>::value

이 부분이 false 라면, is class 는 그냥

template <class T>
struct is_class : std::integral_constant<bool, false> {};

로 정의되고, 따라서 is_class::value 는 false 가 된다. 반면 해당 부분이 true 로 연산되면 is_class::value 역시 true 가 된다. 결과적으로 

sizeof(detail::test<T>(0)) == 1 && !std::is_union<T>::value

위 코드는 T 가 클래스라면 참이고 클래스가 아니라면 거짓이 될 것이다.

그렇다면 앞 부분인 sizeof(detail::test<T>(0)) == 1 은 왜 T 가 클래스일때만 1이 될까?

 

데이터 멤버를 가리키는 포인터 (Pointer to Data member)

template <class T>
char test(int T::*);

먼저 위 부분을 살펴보자. int T::* 는 T 의 int 멤버를 가리키는 포인터 라는 의미이다. 바로 예제를 보자.

#include <iostream>
#include <type_traits>

class A {
public:
	int n;

	A(int n) : n(n) {}
};

int main() {
	int A::*p_n = &A::n;

	A a(3);
	std::cout << "a.n : " << a.n << std::endl;
	std::cout << "a.*p_n : " << a.*p_n << std::endl;	
}

// 결과 :
// a.n : 3
// a.*p_n : 3

위에서 p_n 은 A 의 멤버를 가리킬 수 있는 포인터를 의미한다. 이 때 p_n 이 실제 존재하는 어떠한 객체의 int 멤버를 가리키는 것이 아니다!

위와 같은 형태의 포인터를 데이터 멤버를 가리키는 포인터라고 하는데, 이 문법은 클래스 에만 사용할 수 있다는 제한이 있다.

template <class T>
char test(int T::*);

따라서 위 문장은 T 가 클래스가 아니라면 불가능한 문장이다. 참고로 위 문장은 T 가 클래스라면 해당 클래스에 int 데이터 멤버가 없어도 유효한 문장이다. 다만 아무것도 가리키지 않겠지만, 어차피 T 가 클래스인지 아닌지 판별하는데에만 사용하므로 필요는 없다!

 

struct two {
  char c[2];
};
template <class T>
two test(...);

위 문장을 보자. 이 test 함수의 경우 사실 T 가 무엇이냐에 관계없이 항상 인스턴스화 될 수 있다. test 함수 자체도 이전에 가변 길이 템플릿 함수에서 다룬 것처럼 그냥 임의 개수의 인자를 받는 함수이다.

그렇다면 T 가 클래스라고 해 보자. detail::test<T>(0) 을 컴파일할때, 컴파일러는 1번 후보와 2번 후보 사이에서 어떤 것으로 오버로딩할지 결정해야 한다.

// (1) 번 후보
template <class T>
char test(int T::*);

// (2) 번 후보
struct two {
  char c[2];
};
template <class T>
two test(...);

이 경우 1 번이 좀 더 구체적이므로 (인자가 명시되어 있음) 우선순위가 더 높기 때문에 1 번으로 오버로딩된다. 따라서 test<T>(0) 의 리턴 타입은 char 이 되고 sizeof(char) 은 1 이므로 통과가 된다.

반면 T 가 클래스가 아니라면, 1 번 후보의 문법은 불가능하다. 이 경우 컴파일 에러가 발생하는 것이 아니라 오버로딩 후보군에서 제외가 된다. 따라서 2 번이 실행되어 detail::test<T>(0) 의 리턴 타입은 two 가 된다. two 는 char c[2] 이므로, sizeof 가 2가 된다. 덕분에 is_class 의 value 는 false 로 연산이 된다.

sizeof(detail::test<T>(0)) == 1 && !std::is_union<T>::value

따라서 위 식의 앞부분은 T 가 클래스일 때 참이 되고, 클래스가 아니라면 거짓이 된다는 것을 알 수 있다. 참고로 C++ 에서 데이터 멤버를 가리키는 포인터가 허용되는 것은 클래스와 공용체(union) 딱 두 가지가 있다. 따라서 is_union 을 사용해 T 가 공용체인지를 추가적으로 확인해 주고 있다.

참고로, C++ 에서는 클래스와 공용체를 구별할 수 있는 방법이 없다. 따라서 is_union 은 컴파일러에 의존한 방식으로 구현되어 있다.

 

 

치환 오류는 컴파일 오류가 아니다 (Substitution failure is not an error - SFINAE)

그렇다면 컴파일 오류 시에 오버로딩 후보군에서 제외된다 라는 말은 무슨 의미일까? 아래 코드를 보자.

#include <iostream>

template <typename T>
void test(typename T::x a) {
	std::cout << "T::x \n";
}

template <typename T>
void test(typename T::y b) {
	std::cout << "T::y \n";
}

struct A {
	using x = int;
};

struct B {
	using y = int;
};

int main() {
	test<A>(33);

	test<B>(22);
}

// 실행결과 :
// T::x 
// T::y

템플릿 함수를 사용할 때, 컴파일러는 템플릿 인자의 타입들을 유추한 다음, 템플릿 인자들을 해당 타입으로 치환하게 된다. 여기서 문제는 템플릿 인자들을 유추한 타입으로 치환을 할 때 문법적으로 말이 안되는 경우들이 있다는 것이다.

즉, test<A>(33); 을 하면, 아래와 같이 오버로딩되는 test 함수 두개가 생긴다.

void test(A::x a) { std::cout << "T::x \n"; }
void test(A::y b) { std::cout << "T::y \n"; }

그런데 A 에는 y 라는 타입이 없다. 그렇다면 이건 컴파일 오류를 발생시킬까?

그렇지 않다! 바로 치환 오류는 컴파일 오류가 아니다 (Substitution Failure Is Not An Error) 흔히 줄여 SFINAE 라는 원칙 때문에, 템플릿 치환 후에 만들어진 식이 문법적으로 맞지 않는다면, 컴파일 오류를 발생시키는 대신 단순히 함수의 오버로딩 후보군에서 제외만 시키게 된다. (SFINAE 는 스피내 라고 읽는다)

따라서 위 경우, 두 번째 test 함수의 경우 가능한 오버로딩 후보군에서 제외된다!

여기서 중요한 점은, 컴파일러가 템플릿 인자 치환 시에 함수 내용 전체가 문법적으로 올바른지 확인하는 것이 아니라는 것이다. 컴파일러는 단순히 함수의 인자들과 리턴 타입만이 문법적으로 올바른지를 확인한다. 따라서, 함수 내부에서 문법적으로 올바르지 않은 내용이 있더라도 오버로딩 후보군에 남아 있게 된다.

#include <iostream>

// 오버로딩 되는데, 함수 내부 컴파일 에러
template <typename T>
void test(typename T::x a) {
  typename T::y b;
}

// 오버로딩 되지 않고 제외되어 에러 미발생
template <typename T>
void test(typename T::y b) {
  std::cout << "T::y \n";
}

struct A {
  using x = int;
};

int main() { test<A>(11); }

위의 코드에서, 두 번째의 test 는 SFINAE 규칙에 의해 에러를 발생시키지 않지만, 첫 번째 함수는 오버로딩이 되어 컴파일 에러를 초래한다.

 

 

enable_if

enable_if 는 SFINAE 를 통해 조건에 맞지 않는 함수들을 오버로딩 후보군에서 쉽게 뺄 수 있게 도와주는 간단한 템플릿 메타 함수이다. enable_if 는 다음과 같이 정의되어 있다.

template<bool B, class T = void>
struct enable_if {};

template<class T>
struct enale_if<true, T> { typedef T type; };

이 때 B 부분에 우리가 확인하고자 하는 조건을 전달한다. 만일 B 가 참이라면 enable_if::type 의 타입이 T 가 되고, B 가 거짓이라면 enable_if 에 type 가 존재하지 않게 된다. 예를 들어, 어떤 함수의 인자 T 가 정수 타입일 때만 오버로딩을 하고 싶다고 해보자. 그렇다면 해당 작업을 하는 enable_if 는 다음과 같이 쓸 수 있다.

std::enable_if<std::is_integral<T>::value>::type

실제 예시를 보자.

#include <iostream>
#include <type_traits>

template <typename T,
	typename = typename std::enable_if<std::is_integral<T>::value>::type>
void test(const T& t) {
	std::cout << "t : " << t << std::endl;
}

int main() {
	test(1);	// int
	test(false);	// bool
	test('c');	// char
}

// 출력값
// t : 1
// t : 0
// t : c

하지만 만일 test 에 정수 타입이 아닌 객체를 전달할 경우, 가능한 오버로딩이 없다고 하며 컴파일 에러가 뜬다.

struct A {};
int main() { test(A{}); }

 

이제 아래 코드의 동작을 다시 살펴보자.

template <typename T,
	typename = typename std::enable_if<std::is_integral<T>::value>::type>

위 코드는 std::integral<T>::value 가 참일 때에만 std::enable_if 에 value 가 정의되어서 위 코드가 컴파일 오류를 발생시키지 않는다.

그리고 typename = 부분은 템플릿에 디폴트 인자를 전달하는 부분인데, 원래 typename U = 처럼 템플릿 인자를 받지만 위의 경우 저 식 자체만 필요하므로 굳이 인자를 정의할 필요가 없다.

그리고 std::enable_if 앞에 추가적으로 typename 이 붙는 이유는 std::enable_if<>::type 이 의존 타입 이기 떄문이다.

아래같은 alias 들도 참고로 확인해 보자.

template <typename T, typename = std::enable_if_t<std::is_integral_v<T>>>
void test(const T& t) {
  std::cout << "t : " << t << std::endl;
}

template <bool B, class T = void>
using enable_if_t = typename enable_if<B, T>::type;  // C++ 14 부터 사용 가능

template <class T>
inline constexpr bool is_integral_v =
  is_integral<T>::value;  // C++ 17 부터 사용 가능.

 

enable_if 의 또 다른 예시

아래와 같이 두 종류의 vector 생성자가 있다고 가정해 보자.

#include <iostream>

template <typename T>
class vector {
public:
	
	vector(size_t num, const T& element) {
		std::cout << element << "를 " << num << " 개 만들기" << std::endl;
	}

	template <typename Iterator>
	vector(Iterator start, Iterator end) {
		std::cout << "반복자를 이용한 생성자 호출" << std::endl;
	}
};

int main() {
	vector<int> v(10, 3);
}

// 출력값
// 반복자를 이용한 생성자 호출

출력값으로 왜 반복자 버전의 생성자가 호출될까?

사실 size_t 는 부호 없는 정수이지만, 우리가 첫 인자로 준 10 은 부호가 존재하는 정수이다. 따라서 Iterator 를 int 로 오버로딩하면 v(10 , 3) 을 완벽하게 매칭할 수 있다. 따라서 반복자 버전의 생성자가 호출되는 것이다!

만약 is_iterator 라는 메타 함수가 있다고 가정하면, 위 코드를 다음과 같이 고칠 수 있다.

template <typename Iterator,
    typename = typename std::enable_if_t<is_iterator<Iterator>::value>>
vector(Iterator start, Iterator end) {
    std::cout << "반복자를 이용한 생성자 호출" << std::endl;
}

위와 같이 고치게 되면, 생성자는 Iterator 가 반복자 타입일 경우에만 오버로딩될 것이다!

 

 

특정 멤버 함수가 존재하는 타입만을 받는 함수

여태까지 enable_if 와 여러가지 메타 함수로 할 수 있었던 것들은 어떤어떤 조건을 만족하는 타입을 인자로 받는 함수를 만들고 싶다 였다.

하지만 만약 이러이러한 멤버 함수가 있는 타입을 인자로 받는 함수를 만들고 싶다 는 어떨까? 예를 들어서 멤버 함수로 func 이라는 것이 있는 클래스만 받고 싶다고 가정해 보자.

#include <iostream>
#include <type_traits>

template <typename T, typename = decltype(std::declval<T>().func())>
void test(const T& t){ 
	std::cout << "t.func() : " << t.func() << std::endl;
}

struct A {
	int func() const { return 1; }
};

struct B {};

int main() {
	test(A{});
	// 컴파일 에러!
	// test(B{}); 
}

// 출력값
// t.func() : 1

위의 코드에서, 만약 func 가 정의되어 있지 않은 클래스의 객체를 전달한다면 컴파일 시 오류가 발생한다!

만약 func( ) 의 리턴 타입까지 강제하고 싶다면 아래와 같이 enable_if 를 활용하면 된다.

#include <iostream>
#include <type_traits>

// T 는 반드시 정수 타입을 리턴하는 멤버 함수 func 을 갖고 있어야 한다.
template <typename T, typename = std::enable_if_t<std::is_integral<decltype(std::declval<T>().func())>::value>>
void test(const T& t){ 
	std::cout << "t.func() : " << t.func() << std::endl;
}

struct A {
	int func() const { return 1; }
};

struct B {
	char func() const { return 'a'; }
};

struct C {
	A func() const { return A{}; }
};

int main() {
	test(A{});
	test(B{});

	// 컴파일 에러!
	// test(C{});
}

// 출력값
// t.func() : 1
// t.func() : a

 

만약 func2 말고도 여러 개의 함수를 확인하고 싶다면 어떨까? 예를 들어 컨테이너의 모든 원소들을 출력하는 print 함수를 작성하고 싶다고 해보자. 주어진 타입 T 가 컨테이너인지 아닌지 쉽게 알 수 있는 방법은 없지만, 적어도 원소들을 출력하기 위해선 begin 과 end 가 정의되어 있어야 한다는 것은 알고 있다. 따라서 print 함수는 최소한 T 에 begin 과 end 가 정의되어 있는지 확인해야 한다.

#include <iostream>
#include <type_traits>
#include <set>
#include <vector>

template <typename Cont, typename = decltype(std::declval<Cont>().begin()),
	typename = decltype(std::declval<Cont>().end())>
void print(const Cont& container) {
	std::cout << "[ ";
	for (auto it = container.begin(); it != container.end(); ++it) {
		std::cout << *it << " ";
	}
	std::cout << "]\n";
}


int main() {
	std::vector<int> v = {1, 2, 3, 4, 5};
	print(v);

	std::set<char> s = {'a', 'b', 'f', 'i'};
	print(s);
}

// 출력값
// [ 1 2 3 4 5 ]
// [ a b f i ]

하지만 이런 식으로 typename 을 계속 늘려가다 보면 가독성이 떨어진다는 단점이 생긴다. 이를 개선하기 위해 C++ 17 부터는 void_t 라는 메타 함수가 추가되었다!

 

 

void_t

먼저 void_t 의 정의를 보자.

template <class...>
using void_t = void;

void_t<A, B, C, D> // --> 결국 void

즉, 가변 길이 템플릿을 이용해 void_t 의 템플릿 인자로 임의의 개수의 타입들을 전달할 수 있다. 결국 void_t 는 void 와 동일하다.

그런데 void_t 에 전달된 템플릿 인자들 중 문법적으로 올바르지 못한 템플릿 인자가 있다면 해당 void_t 를 사용한 템플릿 함수의 경우 void 가 되는 대신 SFINAE 에 의해 오버로딩 목록에서 제외가 될 것이다.

따라서, 다음과 같이 코드를 고쳐볼 수 있다.

// Original
template <typename Cont, typename = decltype(std::declval<Cont>().begin()), 
	typename = decltype(std::declval<Cont>().end())>
    
// Revised
template <typename Cont,
	typename = std::void_t<
    decltype(std::declval<Cont>().begin(),
    decltype(std::declval<Cont>().end())>>

즉, void_t 에 전달된 인자 중 하나라도 문법적으로 올바르지 않다면 SFINAE 에 의해 해당 함수는 오버로딩 후보군에서 제외된다.

하지만 아직 위의 코드는 완벽하지 않다. 만약 사용자가 실수로 템플릿 인자에 컨테이너 말고 인자를 하나 더 전달했다고 가정해 보자.

#include <iostream>
#include <type_traits>
#include <vector>

template <typename Cont,
	typename = std::void_t<
		decltype(std::declval<Cont>().begin()),
		decltype(std::declval<Cont>().end())>>
void print(const Cont& container) {
  std::cout << "[ ";
  for (auto it = container.begin(); it != container.end(); ++it) {
    std::cout << *it << " ";
  }
  std::cout << "]\n";
}

struct Bad {};

int main() {
	// 위 print 는 오버로딩 후보군에서 제외되지 않음!
	print<Bad, void>(Bad{});
}

위와 같이 print 가 오버로딩 후보군에서 제외되지 않았음을 확인할 수 있다. 왜냐하면 사용자가 실수로 print 의 템플릿 인자로 Cont 의 타입을 체크하는 자리에 void 라는 인자를 전달하였기 때문에 디폴트 인자가 사용되지 않았기 때문이다. 이 때문에 타입 체크를 생략하게 된다.

 

만약 위 print 함수가 표준 라이브러리 함수들처럼 여러 사용자들을 고려해야 하는 상황이라면, 위와 같이 사용자가 실수 했을 때도 정상적으로 작동하도록 설계해야 한다. 이를 위해선 타입 체크하는 부분을 다른 곳으로 빼야 한다.

template <typename Cont>
std::void_t<
	decltype(std::declval<Cont>().begin()),
	decltype(std::declval<Cont>().end())>
print(const Cont& container)

위처럼, 타입을 체크하는 부분을 템플릿의 디폴트 인자에서 함수의 리턴 타입으로 옮겨보자. 이전에도 언급했지만, 함수의 리턴 타입 역시 SFINAE 가 적용되는 부분이므로 동일한 효과를 낼 수 있다. 또한 템플릿 정의 부분에 불필요한 디폴트 인자가 들어가 있지 않으므로 사용자의 실수로부터 안전해졌다.

 

 

공포의 템플릿 분석하기

이제 다음의 템플릿을 분석해 보자. 아래 코드는 unique_ptr 의 주소값을 출력해주는 basic_ostream 의 operator<< 연산자를 구현한 것이다.

위 코드 또한 템플릿 디폴트 인자로 타입을 체크하는 대신 함수 리턴 타입을 통해서 타입을 체크하고 있다. 하나 하나 살펴보자.

__void_t<decltype((declval<basic_ostream<_CharT, _Traits>&>()
                    << declval<typename unique_ptr<_Yp, _Dp>::pointer>()))>::type

위 부분은 (__void_t 와 std::void_t 는 같은 함수) < > 안의 타입이 문법상 올바른 문장인지 확인하는 것이다. 즉, basic_ostream 의 operator<< 가 unique_ptr 의 pointer 타입 객체를 출력할 수 있는지를 확인하는 것이다. 만일 해당 타입 객체를 출력할 수 있다면 위 __void_t 는 void 로 연산될 것이고, 해당 문장이 문법상 불가능하다면 위 operator<< 는 오버로딩 목록에서 제외된다.

만약 basic_ostream 이 unique_ptr 의 pointer 타입을 출력할 수 있다고 해보자. 그렇다면

typename enable_if<
  is_same<void, typename __void_t<decltype(
                  (declval<basic_ostream<_CharT, _Traits>&>() << declval<
                     typename unique_ptr<_Yp, _Dp>::pointer>()))>::type>::value,
  basic_ostream<_CharT, _Traits>&>::type

위의 코드는 

typename enable_if<is_same<void, void>::value,
                   basic_ostream<_CharT, _Traits>&>::type

로 바뀔 것이다. 참고로 is_same 은 type_traits 에 정의되어 있는 메타 함수로, 인자로 전달된 두 타입이 같으면 true, 아니면 false 로 value 를 설정한다. 위 경우는 true 가 될 것이다.

따라서 위 식은 

typename enable_if<true, basic_ostream<_CharT, _Traits>&>::type operator<<(
  basic_ostream<_CharT, _Traits>& __os, unique_ptr<_Yp, _Dp> const& __p) {
  return __os << __p.get();
}

가 되서 결과적으로 enable_if 에 의해

basic_ostream<_CharT, _Traits>& operator<<(basic_ostream<_CharT, _Traits>& __os,
                                           unique_ptr<_Yp, _Dp> const& __p) {
  return __os << __p.get();
}

가 되어, 원하는 함수가 된다.

상당히 복잡해 보이지만, 사실 위에서 컨테이너를 사용한 예제와 큰 차이는 없다. 다만 위 예제의 경우 리턴값이 void 였던 대신에 operator<< 는 basic_ostream<_CharT, _Traits>& 를 리턴해야 하므로 is_same 과 enable_if 를 활용해 리턴 타입을 바꿔준 것이라 볼 수 있다.

 

Comments