모두의 코드
씹어먹는 C++ - <17 - 1. type_traits 라이브러리, SFINAE, enable_if>

작성일 : 2019-12-2 이 글은 19035 번 읽혔습니다.

이번 강좌에서는

  • type_traits 라이브러리

  • SFINAE

  • enable_ifvoid_t

에 대해서 다룹니다.

안녕하세요 여러분! 앞으로 총 5 강좌에 걸쳐서 C++ 11 이후에 추가된 여러가지 표준 라이브러리들에 대해서 다루어볼 예정입니다.

이번 강좌에서는 C++ 에서 타입 관련 연산을 위한 템플릿 메타 함수 들을 제공해주는 type_traits 라이브러리에 대해서 알아보도록 하겠습니다.

공포의 템플릿

아무래도 여기 까지 강좌를 보신 분들이라면 조금 복잡한 C++ 코드를 여러 경로에서 접해 보셨을 것입니다. 그렇다면 아마 아래와 같은 혐오스러운 템플릿 코드도 보셨을 테지요.

template <class _CharT, class _Traits, class _Yp, class _Dp>
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
operator<<(basic_ostream<_CharT, _Traits>& __os,
           unique_ptr<_Yp, _Dp> const& __p) {
  return __os << __p.get();
}

아마 위 코드를 보신 여러분들의 속마음은..

WTF

와 같겠죠. 아니 저게 도대체 뭐야!

위 코드는 libc++ 라이브러리에서 가져온 코드로, unique_ptr 의 주소값을 출력해주는 basic_ostreamoperator<< 연산자를 구현한 것입니다. 도대체 왜 C++ 개발자들은 저런 혐오스러운 코드를 작성하는 것일까요?

사실 type_traits 라이브러리들의 템플릿 메타 함수 (template meta function)들을 잘 이해만 한다면 위와 같은 코드는 무리없이 해석할 수 있습니다. 이 강좌 끝에 도달하게 된다면 여러분들 역시 위 코드를 보고서도 크게 무리 없이 이해할 수 있을 것입니다.

템플릿 메타 함수

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

예를 들어서 어떤 수가 음수인지 아닌지 판별하는 함수 is_negative 가 있다고 해봅시다. 그렇다면 이 함수는 아래 처럼 사용할겁니다.

if (is_negative(x)) {
  // Do something...
}

템플릿 메타 함수도 매우 비슷합니다. 예를 들어서 어떤 타입이 void 인지 아닌지 판단하는 is_void 함수가 있다고 해봅시다. 그렇다면 이 함수는 아래와 같이 사용하게 됩니다.

// 어떤 타입 T 가 있어서
if (is_void<T>::value) {
  // Do something
}

아래 예제를 통해 실제로 코드를 실행해보세요.

#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>();  // void 아님!

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

성공적으로 컴파일 하였다면

실행 결과

T 는 void 가 아니다. 
T 는 void ! 

보시다시피 일반적인 함수와 몇 가지 차이점이 있습니다. 가장 중요한 점은 템플릿 메타 함수들은 실제론 함수가 아니라는 점 입니다. 만일 함수였다면 () 를 통해서 호출을 했겠지요. 하지만 is_void 는 그렇지 않습니다. () 대신에 <> 를 통해 함수 인자가 아닌 템플릿 인자를 전달하고 있습니다. 실제로 is_void 는 클래스로 구현되어 있습니다.

is_void

기존에 템플릿 메타프로그래밍 강좌를 숙지하신 분들은 알고 계시겠지만, 템플릿 메타프로그래밍에서 if 문은 템플릿 특수화를 통해서 구현된다고 하였습니다. is_void 의 경우도 마찬가지 입니다.

#include <iostream>

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

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

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

int main() {
  tell_type<int>();  // void 아님!

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

성공적으로 컴파일 하였다면

실행 결과

T 는 void 가 아니다. 
T 는 void ! 

와 같이 나옵니다.

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

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

위는 실제 std::is_void 의 코드를 가져온 것은 아니지만 (매우 비슷합니다), is_void 가 어떠한 원리로 작동하는지 보는데 충분하다고 생각합니다. 템플릿 강좌를 잘 들으신 분이라면,

template <typename T>
struct is_void {

는 일반적인 모든 타입 T 에 대해서 매칭이 되고,

template <>
struct is_void<void> {

void 에 대해 특수화 된 클래스이죠.

따라서 is_void<void> 를 하게 된다면, 바로 위 특수화 된 템플릿이 매칭이 되어서 valuetrue 가 되고, 그 외의 타입의 경우에는 맨 위의 일반적인 템플릿 클래스가 매칭이 되어서 valuefalse 가 될 것입니다. 따라서

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

위 부분에서 is_voidvaluetrue 일 때와 false 일 때 적절히 나눠서 처리할 수 있습니다.

C++ 표준 라이브러리 중 하나인 type_traits 에서는 is_void 처럼 타입들에 대해서 여러가지 연산을 수행할 수 있는 메타 함수들을 제공하고 있습니다. 한 가지 더 예를 들어보자면 정수 타입인지 확인해주는 is_integral 이 있습니다.

#include <iostream>
#include <type_traits>

class A {};

// 정수 타입만 받는 함수
template <typename T>
void only_integer(const T& t) {
  static_assert(std::is_integral<T>::value);
  std::cout << "T is an integer \n";
}

int main() {
  int n = 3;
  only_integer(n);

  A a;
  only_integer(a);
}

컴파일 하였다면

컴파일 오류

test2.cc: In instantiation of ‘void only_integer(const T&) [with T = A]’:
test2.cc:17:17:   required from here
test2.cc:8:3: error: static assertion failed
   static_assert(std::is_integral<T>::value);
   ^~~~~~~~~~~~~

와 같은 오류 메세지를 볼 수 있습니다.

static_assert(std::is_integral<T>::value);

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

만약에 static_assert 에 전달된 식이 이라면, 컴파일러에 의해 해당 식은 무시되고, 거짓 이라면 해당 문장에서 컴파일 오류를 발생시키게 됩니다.

따라서 static_assertstd::is_integral 을 잘 조합해서 T 가 반드시 정수 타입임을 강제할 수 있습니다. 위

int n = 3;
only_integer(n);

A a;
only_integer(a);

위와 같이 only_integern 을 전달한다면 Tint 로 추론되서 is_integralvalue 가 참이 되겠지만, 그냥 일반 클래스 객체인 a 를 전달한다면 false 가 되어서 위 처럼 static assertion failed 라는 컴파일 오류가 발생하겠지요.

이처럼 static_asserttype_traits 의 메타 함수들을 잘 사용한다면 특정 타입만 받는 함수를 간단하게 작성할 수 있습니다.

is_class

type_traits 에 정의되어 있는 메타 함수들 중에서 흥미로운 함수로 is_class 가 있습니다. 이 메타 함수는 인자로 전달된 타입이 클래스 인지 아닌지 확인하는 메타 함수 입니다.

지금 잠시 눈을 감고 어떤 타입 T 가 클래스 인지 아닌지 어떤식으로 확인할 것인지 생각해보세요. 그닥 방법이 떠오르지 않죠? 저도 그렇습니다. 실제로, is_class 가 구현된 방법은 매우 기괴합니다. cppreference 에서 가져온 코드를 살펴보자면;

namespace detail {
template <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> {};

흠. 아무래도 위에서 쓰인 사진을 다시 가져와야겠습니다.

WTF

위 코드를 이해하기 위해서는 먼저 std::integral_constant 가 뭘 하는 녀석인지 부터 알아야 합니다. integral_constantstd::integral_constant<T, T v> 로 정의되어 있는데, 그냥 vstatic 인자로 가지는 클래스 입니다. 쉽게 말해 그냥 어떠한 값을 static 객체로 가지고 있는 클래스를 만들어주는 템플릿 이라고 생각하면 됩니다.

예를 들어서 std::integral_constant<bool, false> 는 그냥 integral_constant<bool, false>::valuefalse 인 클래스 입니다. 따라서 만약에

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::valuefalse 가 될 것입니다. 반면에 해당 부분이 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

와 같이 나옵니다.

int A::*p_n

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

int A::*p_n = &A::n;

위에서 정의한 방식을 보았듯이 이제 p_n 을 역참조 하게 된다면 이는 마치 An 을 참조하는 식으로 사용할 수 있습니다. 따라서

std::cout << "a.n : " << a.n << std::endl;
std::cout << "a.*p_n : " << a.*p_n << std::endl;

a.n 이나 a.*p_n 이나 같은 문장이 됩니다. 이와 같은 형태의 포인터를 데이터 멤버를 가리키는 포인터라고 합니다. 그리고 여기에 한 가지 제한점이 있습니다. 바로 이 문법은 클래스 에만 사용할 수 있다는 것이지요.

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 번 후보인

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

와 2 번 후보인

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

사이에서 어떤 것으로 오버로딩 할지 결정을 해야 합니다. 이 경우 1 번이 좀더 구체적이므로 (인자가 명시되어 있음) 우선순위가 더 높기 때문에 1 번으로 오버로딩 됩니다. 따라서 test<T>(0) 의 리턴 타입은 char 이 되고 sizeof(char) 은 1 이므로 통과가 되겠네요.

반면에 T 가 클래스가 아니라면;

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

위 문법은 불가능한 문법입니다. 이 경우 컴파일 오류가 발생되는 것이 아니라 오버로딩 후보군에서 제외됩니다. (이 부분에 대해서 아래에서 좀 더 자세히 설명하겠습니다.) 따라서, 2 번이 유일한 후보군 이므로, detail::test<T>(0) 의 리턴 타입은 two 가 되겠지요. 이 때 twochar c[2] 이므로, sizeof 가 2 가 됩니다. 덕분에 is_classvaluefalse 로 연산이 되겠네요.

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

그렇다면 위 식의 앞부분은 T 가 클래스 일 때 참이 되고 클래스가 아니라면 거짓이 됨을 알 수 있었습니다. 참고로 C++ 에서 데이터 멤버를 가리키는 포인터가 허용되는 것은 클래스와 공용체(union) 딱 두 가지가 있습니다. 따라서 sizeof(detail::test<T>(0)) == 1T 가 공용체 일 때도 성립하기 때문에 확실히 클래스 임을 보이기 위해서는 추가적으로 is_union 을 통해 공용체가 아님을 확인해야 합니다.

참고로 is_union 이 어떻게 구현되어 있는지 궁금해 하시는 분들을 위해 안타까운 소식을 전하자면, C++ 에선 클래스와 공용체를 구별할 수 있는 방법이 없습니다. 따라서 is_union컴파일러에 직접 의존한 방식으로 구현되어 있기 때문에 자세한 내용은 생략하겠습니다.

아무튼 결과적으로 위 식은 T 가 클래스 일 때에만 참이 되고 나머지 경우에는 모두 거짓으로 연산됩니다.

치환 오류는 컴파일 오류가 아니다 (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);

위 문장의 경우 우리가 템플릿 인자로 A 를 전달하였으므로,

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";
}

위 두 함수들은 각각

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

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

로 치환되겠지요. 문제는 A::y 가 문법적으로 올바르지 않은 식이라는 점입니다. (클래스 Ay 라는 타입이 없습니다.) 그렇다면 컴파일러는 여기서 컴파일 오류를 발생시킬까요?

아닙니다! 바로 치환 오류는 컴파일 오류가 아니다 (Substitution Failure Is Not An Error) 흔히 줄여서 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); }

컴파일 하였다면

컴파일 오류

test2.cc: In instantiation of ‘void test(typename T::x) [with T = A; typename T::x = int]’:
test2.cc:22:13:   required from here
test2.cc:5:17: error: no type named ‘y’ in ‘struct A’
   typename T::y b;
                 ^

위와 같이 오류가 발생합니다.

만일 첫 번째 test 가 오버로딩 후보군에서 제외되었더라면, 템플릿 인자 유추가 실패하였다는 오류 메세지가 나와야 했을 것입니다. 하지만 위 경우, 템플릿 인자 유추는 성공 해서 첫 번째 test 를 사용하였지만 해당 함수 내부에 typename T::y b; 때문에 컴파일 할 수 없다는 의미 입니다.

이렇게 SFINAE 를 활용하게 된다면 원하지 않는 타입들에 대해서 오버로딩 후보군에서 제외할 수 있습니다. type_traits 에는 해당 작업을 손쉽게 할 수 있는 메타 함수를 하나 제공하는데, 바로 enable_if 입니다.

enable_if

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

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

이 때 B 부분에 우리가 확인하고픈 조건을 전달합니다. 만일 B 가 참으로 연산된다면 enable_if::type 의 타입이 T 가 되고, B 가 거짓이라면 enable_iftype 가 존재하지 않게 됩니다. 예를 들어서, 어떤 함수의 인자 T 가 정수 타입일 때만 오버로딩을 하고 싶다고 해봅시다. 그렇다면 해당 작업을 하는 enable_if 는 아래와 같이 쓸 수 있습니다.

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

위 처럼 B 자리에 원하는 조건인 std::integral<T>::value 를 전달한 것을 볼 수 있습니다.

실제 enable_if 를 사용한 함수는 아래와 같습니다.

#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 에 정수 타입이 아닌 객체를 전달할 경우

#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;
}

struct A {};

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

컴파일 하였을 경우 아래와 같은 오류가 발생합니다.

컴파일 오류

test2.cc: In function ‘int main()’:
test2.cc:12:22: error: no matching function for call to ‘test(A)’
 int main() { test(A{}); }
                      ^
test2.cc:6:6: note: candidate: template<class T, class> void test(const T&)
 void test(const T& t) {
      ^~~~
test2.cc:6:6: note:   template argument deduction/substitution failed:
test2.cc:5:11: error: no type named ‘type’ in ‘struct std::enable_if<false, void>’
           typename = typename std::enable_if<std::is_integral<T>::value>::type>

위 처럼 test(A{}) 가 가능한 오버로딩이 없다고 나오게 됩니다.

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

아마 이제는 잘 이해 하시겠지만, 위 코드가 어떻게 동작하는지 다시 한 번 설명을 해보자면, std::integral<T>::value 가 참 일 때에만 std::enable_ifvalue 가 정의되어서 위 코드가 컴파일 오류를 발생시키지 않습니다.

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

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

사실 위 처럼 길게 쓰면 이해하기 힘든데, C++ 14 부터 enable_if<>::value 와 같이 자주 쓰이는 패턴에 대한 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 클래스의 제작자라고 해봅시다. 그렇다면 vector 의 생성자로 아래 두 가지 형태를 제공할 것입니다.

template <typename T>
class vector {
 public:
  // element 가 num 개 들어있는 vector 를 만든다.
  vector(size_t num, const T& element);

  // 반복자 start 부터 end 까지로 벡터 생성
  template <typename Iterator>
  vector(Iterator start, Iterator end);
};

첫 번째 생성자는 단순하게 원소가 num 개 들어있는 vector 를 만드는 생성자이고, 두 번째 생성자는 반복자 시작과 끝을 받는 생성자 입니다. 참고로 반복자의 경우, 딱히 클래스가 따로 정해져 있는 것이 아니라 그냥 start, end, ++ 등등의 함수만 들어있는 클래스라면 반복자 처럼 사용할 수 있습니다.

그렇다면 만약에 vector 클래스의 사용자가 아래와 같은 코드를 썼다면 어떤 식으로 해석되야 할까요?

vector<int> v(10, 3);

당연히도 사용자는 첫 번째 오버로드인 3 이 10 개 들어있는 벡터를 생성하기를 원했을 것입니다. 그런데 말이죠, 실제로 컴파일 해보면 아래와 같이 나옵니다.

#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); }

성공적으로 컴파일 하였다면

실행 결과

반복자를 이용한 생성자 호출

와 같이 나옵니다. 의외지요? 사실 이렇게 나온 이유는 간단합니다. 우리가 원했던 버전의 오버로딩은

vector(size_t num, const T& element) {

와 같이 생겼습니다. 여기서 주목할 점은 num 의 타입이 size_t 라는 점입니다. size_t 는 부호가 없는 정수 타입이지요. 문제는 v(10, 3) 을 했을 때 10 은 부호가 있는 정수라는 점입니다. 물론, C++ 컴파일러는 똑똑하기 때문에 이정도는 알아서 캐스팅 해줘서 넘어갈 수 도 있었습니다. 다만 더 나은 후보가 없다는 가정 하에 말이죠.

template <typename Iterator>
vector(Iterator start, Iterator end) {

문제는 이 친구가 Iteratorint 로 오버로딩 한다면 v(10, 3) 를 완벽하게 매칭 시킬 수 있다는 점입니다. 따라서 결과적으로 우리의 예상과는 다르게 반복자를 이용한 생성자 호출이 선택됩니다.

따라서 이 경우 Iterator 가 실제 반복자 임을 강제할 필요성이 있습니다. 그렇다면 만약에 is_iteartor 라는 메타 함수가 있다고 가정한다면, 위 코드를 아래와 같이 쓸 수 있습니다.

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

이 경우 Iterator 가 실제로 반복자 일 경우에만 해당 vector 생성자가 오버로딩 후보군에 들어가겠지요.

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

여태 까지 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; }
};

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

성공적으로 컴파일 하였다면

실행 결과

t.func() : 1

와 같이 잘 나옵니다. 만약에 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(B{}); }

컴파일 시 아래와 같은 오류가 발생합니다.

컴파일 오류

test2.cc: In function ‘int main()’:
test2.cc:16:11: error: no matching function for call to ‘test(B)’
   test(B{});
           ^
test2.cc:5:6: note: candidate: template<class T, class> void test(const T&)
 void test(const T& t) {
      ^~~~
test2.cc:5:6: note:   template argument deduction/substitution failed:
test2.cc:4:61: error: ‘struct B’ has no member named ‘func’
 template <typename T, typename = decltype(std::declval<T>().func())>

보시다시피, test(B{}) 를 오버로딩 하는 함수가 없다고 나와 있습니다. 왜냐하면 decltype(std::declval<T>().func()) 이 올바르지 않은 문장이기 때문에 오버로딩 후보군에서 제외되었기 때문이지요.

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

#include <iostream>
#include <type_traits>

// T 는 반드시 정수 타입을 리턴하는 멤버 함수 func 을 가지고 있어야 한다.
template <typename T, typename = std::enable_if_t<
                        std::is_integral_v<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 {
  char func() const { return 'a'; }
};

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

성공적으로 컴파일 하였다면

실행 결과

t.func() : 1
t.func() : a

와 같이 잘 나옵니다. 반면에 함수의 리턴 타입이 정수 타입이 아닌 경우

#include <iostream>
#include <type_traits>

template <typename T, typename = std::enable_if_t<
                        std::is_integral_v<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 C {
  A func() const { return A{}; }
};

int main() { test(C{}); }

컴파일 하였다면

컴파일 오류

test2.cc: In function ‘int main()’:
test2.cc:24:11: error: no matching function for call to ‘test(C)’
   test(C{});
           ^
test2.cc:6:6: note: candidate: template<class T, class> void test(const T&)
 void test(const T& t) {
      ^~~~
test2.cc:6:6: note:   template argument deduction/substitution failed:

역시나 위 처럼 test(C{}) 의 가능한 오버로딩이 없다고 나오게 됩니다.

그렇다면 만약에 func2 말고도 여러 개의 함수를 확인하고 싶다면 어떨까요? 예를 들어서 컨테이너의 모든 원소들을 출력하는 print 함수를 작성하고 싶다고 해봅시다. 물론 주어진 타입 T 가 컨테이너 인지 아닌지 쉽게 알 수 있는 방법은 없지만 적어도 원소들을 출력하기 위해선 beginend 가 정의되어 있다는 사실은 알고 있지요. 따라서, 우리의 print 함수는 최소한 T 에 beginend 가 정의되어 있는지 확인해야 할 것입니다.

#include <iostream>
#include <set>
#include <type_traits>
#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 ]

와 같이 잘 나옵니다. 반면에 beginend 둘 다 정의되어 있지 않은 클래스의 경우

#include <iostream>
#include <type_traits>

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";
}

struct Bad {
  void begin();
};

int main() { print(Bad{}); }

컴파일 하였다면

컴파일 오류

test.cc:21:3: error: no matching function for call to 'print'
  print(Bad{});
  ^~~~~
test.cc:7:6: note: candidate template ignored: substitution failure [with Cont = Bad, $1 = void]: no member named
      'end' in 'Bad'
void print(const Cont& container) {
     ^

위 처럼 print(Bad{}) 를 오버로딩 하는 함수가 없다는 오류가 발생하게 됩니다. 우리가 예상한 대로 다 잘 작동하는 것 처럼 보이지만 그래도 한 가지 개선할 여지가 있습니다.

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

바로 디폴트 템플릿 인자 typename = 이 너무 많아진다는 점입니다. 위 템플릿을 그냥 제 3 자 입장에서 보았을 때 Print 함수가 정확히 어떠한 템플릿 인자를 받는지 쉽게 알아보기 힘듭니다. 또한 디폴트 템플릿 인자가 1 개 였다면 그래도 그냥저냥 넘어갈 만 했지만, 2 개 이상 부터는 가독성이 너무 떨어집니다.

그래서 C++ 17 부터 void_t 라는 신기한 메타 함수가 추가되었습니다.

void_t

void_t 의 정의를 보면 놀랄 만큼 단순합니다.

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

즉 가변길이 템플릿을 이용해서 void_t 에 템플릿 인자로 임의의 개수의 타입들을 전달할 수 있고, 어찌 되었든 void_t 는 결국 void 와 동일합니다.

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

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

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

위 식은 아래와 같이 좀 더 깔끔하게 다시 쓸 수 있습니다.

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

void_t 에 전달된 decltype(std::declval<Cont>().begin()) 이나 decltype(std::declval<Cont>().end()) 중 하나라도 문법적으로 올바르지 않다면 SFINAE 에 의해서 해당 print 함수는 오버로딩 후보군에서 제외됩니다. 반면에 vector 처럼 두 코드가 문법적으로 성립하는 경우에는 print 가 잘 오버로딩 되겠네요.

물론 아직도 위 코드가 완벽한 것이 아닙니다. 만일 사용자가 실수로 템플릿 인자에 컨테이너 말고 인자를 한 개 더 전달했다고 해봅시다.

#include <iostream>
#include <type_traits>

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{});
}

컴파일 하였다면

컴파일 오류

test2.cc: In instantiation of ‘void print(const Cont&) [with Cont = Bad; <template-parameter-1-2> = void]’:
test2.cc:18:36:   required from here
test2.cc:10:28: error: ‘const struct Bad’ has no member named ‘begin’
   for (auto it = container.begin(); it != container.end(); ++it) {
                  ~~~~~~~~~~^~~~~
test2.cc:10:53: error: ‘const struct Bad’ has no member named ‘end’
   for (auto it = container.begin(); it != container.end(); ++it) {
                                           ~~~~~~~~~~^~~

위와 같이 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 가 적용되는 부분이므로 동일한 효과를 낼 수 있습니다. 뿐만 아니라 템플릿 정의 부분에 불필요한 디폴트 인자가 들어가 있지 않으므로 사용자의 실수로부터 안전해졌습니다.

공포의 템플릿 다시 살펴보기

자 그럼 이제 맨 위에서 보았던 공포의 템플릿을 이해할 수 있는 능력치를 쌓은 것 같습니다.

template <class _CharT, class _Traits, class _Yp, class _Dp>
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
operator<<(basic_ostream<_CharT, _Traits>& __os,
           unique_ptr<_Yp, _Dp> const& __p) {
  return __os << __p.get();
}

위 코드는 표준 라이브러리에 들어가 있는 만큼 최대한 안전하게 설계되어야 합니다. 따라서 템플릿 디폴트 인자로 타입을 체크하는 대신 방금 우리가 소개한 방식 처럼 함수 리턴 타입을 통해서 타입을 체크하고 있습니다.

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

자 그렇다면 위 부분은 무슨 일을 하고 있는 것일까요? (참고로 __void_tstd::void_t 는 같은 함수 입니다.) 바로

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

가 문법 상 올바른 문장인지 확인하고 있는 것입니다. 다시 말해 basic_ostreamopreator<<unique_ptrpointer 타입 객체를 출력할 수 있는지 확인하고 있는 것이지요. 만일 해당 타입 객체를 출력할 수 있다면 위 __void_tvoid 로 연산될 것이고, 해당 문장이 문법 상 불가능 하다면 위 operator<< 는 오버로딩 목록에서 제외될 것입니다.

만약에 basic_ostreamunique_ptrpointer 타입을 출력할 수 있다고 해봅시다. 그렇다면

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_sametype_traits 에 정의되어 있는 메타 함수로 인자로 전달된 두 타입이 같으면 valuetrue 아니면 valuefalse 가 되는 메타 함수 입니다. 위 경우 두 타입이 void 로 같기 때문에 is_same<void, void>::valuetrue 가 됩니다.

따라서 위 식은

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_sameenable_if 를 활용해서 리턴 타입을 바꿔준 것이라 볼 수 있습니다.

자 그렇다면 이번 강좌를 여기서 마치도록 하겠습니다. 이번 강좌를 통해서 type_traits 라이브러리와 복잡한 템플릿 정의 코드를 이해할 수 있는 능력을 키울 수 있었으면 좋겠습니다.

다음 강좌에서는 C++ 의 표준 라이브러리들 중 하나인 정규 표현식 라이브러리 (<regex>) 에 대해 살펴보도록 하겠습니다.

뭘 배웠지?

  • type_traits 에 정의되어 있는 메타 함수들이 무엇인지 이해하였습니다.

  • C++ 에서 템플릿 인자 치환 시 문법적으로 올바르지 않은 코드가 생성될 경우 컴파일 오류를 출력하는 대신 해당 함수를 오버로딩 후보군에서 제외합니다. 이 때, 컴파일러가 모든 코드를 치환 하는 것이 아니라 함수의 타입, 인자 정의 템플릿 인자 정의 부분만 살펴봅니다. 이와 같은 규칙을 SFINAE 라고 합니다.

  • enable_if 를 통해서 원하는 타입만 받는 함수를 작성할 수 있습니다.

  • void_t 를 통해서 원하는 타입만 받는 함수를 작성할 수 있습니다.

강좌를 보다가 조금이라도 궁금한 것이나 이상한 점이 있다면 꼭 댓글을 남겨주시기 바랍니다. 그 외에도 강좌에 관련된 것이라면 어떠한 것도 질문해 주셔도 상관 없습니다. 생각해 볼 문제도 정 모르겠다면 댓글을 달아주세요.

현재 여러분이 보신 강좌는 <씹어먹는 C++ - <17 - 1. type_traits 라이브러리, SFINAE, enable_if>> 입니다. 이번 강좌의 모든 예제들의 코드를 보지 않고 짤 수준까지 강좌를 읽어 보시기 전까지 다음 강좌로 넘어가지 말아주세요
댓글이 21 개 있습니다!
프로필 사진 없음
강좌에 관련 없이 궁금한 내용은 여기를 사용해주세요

    댓글을 불러오는 중입니다..