모두의 코드
씹어먹는 C ++ 토막글 3 - SFINAE 와 enable_if

작성일 : 2019-01-05 이 글은 1260 번 읽혔습니다.

이 글은 여기 에 실린 SFINAE and enable_if 글을 바탕으로 쓰였습니다.

템플릿과 함수의 오버로딩

C++ 에서 템플릿과 함수의 오버로딩을 같이 사용한다면, 한 가지 고려해야할 문제가 있습니다. 바로 템플릿이 생각보다 너무 똑똑해서, 함수의 오버로딩과 같이 사용했을 때 예상치 못한 결과를 나을 때가 종종 있습니다. 아래 예제를 보실까요.

void foo(unsigned int i) { std::cout << "unsigned " << i << "\n"; }

template <typename T>
void foo(const T& t) {
  std::cout << "template " << t << "\n";
}

여러분이 foo(42) 수행 시에 어떤 메세지가 호출될까요? 답은 바로 "template 42" 입니다. 왜냐하면 정수 리터럴의 경우 디폴트로 부호 있는 정수가 되기 때문에 (끝에 U 를 붙이지 않는 이상 부호 있는 정수가 됩니다), 컴파일러가 오버로딩 후보들을 살펴볼 때 첫 번째 버전은 타입 변환이 필요하지만 (unsigned 를 붙여야 되죠), 두 번째 버전은 타입 변환 없이 그냥 T 를 int 로 끼워넣으면 되기 때문에 결국 두 번째 후보가 채택됩니다.

SFINAE

컴파일러가 템플릿으로 선언된 오버로딩 후보들을 살펴볼 때, 템플릿 인자들의 타입들을 유추한 후에 이로 치환하는 과정에서 말도 안되는 코드를 생산할 때가 있습니다. 예를 들어서 아래의 예제를 살펴볼까요.

int negate(int i) { return -i; }

template <typename T>
typename T::value_type negate(const T& t) {
  return -T(t);
}

negate(42) 를 생각해봅시다. 컴파일러는 아마 첫 번째 오버로딩 후보를 택해서 -42 를 리턴할 것입니다. 하지만 컴파일러가 아래의 후보를 확인하는 과정에서 템플릿 인자 T 를 int 로 추론하게 되는데, 이 과정에서 아래와 같은 이상한 코드가 생성됩니다.

int ::value_type negate(const int& t) {
  /* ... */
}

위 코드는 당연하게도 잘못된 코드 입니다. int 에는 value_type 이라는 멤버가 없기 때문이죠. 그렇다면 이 경우 컴파일러가 컴파일 오류 메세지를 내뱉어야 할까요? 아닙니다. 그렇게 된다면 C++ 에서 템플릿을 사용하기 매우 어려울 것입니다. 실제로 C++ 표준에는 이와 같은 상황에 대해 컴파일러가 어떻게 동작해야할지 규칙을 정해놓았습니다.

C++ 11 표준의 14.8.2 조항에 따르면

템플릿 인자 치환에 실패할 경우 (위 같은 경우) 컴파일러는 이 오류를 무시하고, 그냥 오버로딩 후보에서 제외하면 된다 라고 명시되어 있습니다.

C++ 에선 흔히 이를 치환 실패는 오류가 아니다 - Substitution Failure Is Not An Error 혹은 줄여서 SFINAE 라고 합니다.

C++ 표준 문구를 정확히 읊어보자면 아래와 같습니다.

만일 템플릿 인자 치환이 올바르지 않는 타입이나 구문을 생성한다면 타입 유추는 실패합니다. 올바르지 않는 타입이나 구문이라 하면, 치환된 인자로 썼을 때 문법상 틀린 것을 의미 합니다. 이 때, 함수의 즉각적인 맥락(immediate context)의 타입이나 구문만이 고려되고, 여기에서 발생한 오류 만이 타입 유추를 실패시킬 수 있습니다. 그 이후에, 올바르지 않다고 여겨지는 여러가지 상황들을 확인하면서 (예컨대 클래스가 아닌 타입이나, void 의 레퍼런스를 생성한다든지 등등) 이를 오버로딩 후보 목록에서 제외시킵니다.

영문으로는

If a substitution results in an invalid type or expression, type deduction fails. An invalid type or expression is one that would be ill-formed if written using the substituted arguments. Only invalid types and expressions in the immediate context of the function type and its template parameter types can result in a deduction failure. And then goes on to list the possible scenarios that are deemed invalid, such as using a type that is not a class or enumeration type in a qualified name, attempting to create a reference to void, and so on.

근데 여기서 함수의 즉각적인 맥락(immediate context)이 무엇을 지칭하는 것일까요?

아래와 같은 함수를 살펴보도록 합시다.

template <typename T>
void negate(const T& t) {
  typename T::value_type n = -t();
}

만일 value_type 을 멤버로 가지지 않는 어떤 타입에 대해 위 템플릿의 타입 유추를 수행하였다고 해봅시다. 예를 들어 negate('c') 의 경우 Tchar 가 되겠네요. 아무튼, 이를 컴파일 해보면, SFINAE 덕분에 컴파일 오류가 나타나지 않는 것이 아니라, 함수 내부의 T::value_type 때문에 컴파일 오류가 발생합니다.

왜 이 경우 SFINAE 가 적용되지 않았을까요? 왜냐하면 T::value_type 는 함수 타입과 템플릿 타입 인자의 즉각적인 맥락 바깥에 있기 때문입니다. 따라서 표준 규정에 따라 이는 SFINAE 의 적용 범위를 넘어섭니다.

따라서 만약에 우리가 특정한 타입들에게만 작동하는 템플릿을 작성하고 싶다면 (위 경우 value_type 이 멤버로 있는 타입들을 가리키는 것이겠지요?) 함수의 선언부에 올바르지 않은 타입을 넣어서 타입 치환 오류를 발생시켜야 합니다. 이를 통해 컴파일러는 해당 함수를 오버로딩 후보군에서 제외시킬 것이고 쓸데없는 컴파일 오류를 발생시키기 않게 됩니다.

enable_if - 템플릿들을 위한 컴파일 타임 스위치

SFINAE 를 잘 활용하는 툴들 중 가장 널리 쓰이는 것이 바로 enable_if 입니다. enable_if 는 아래와 같이 정의될 수 있습니다.

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

template <typename T>
struct enable_if<true, T> {
  typedef T type;
};

그리고 우리는 아래와 같은 일들을 할 수 있습니다.

template <class T, typename std::enable_if<std::is_integral<T>::value,
                                           T>::type* = nullptr>
void do_stuff(T& t) {
  std::cout << "do_stuff integral\n";
  // 정수 타입들을 받는 함수 (int, char, unsigned, etc.)
}

template <class T,
          typename std::enable_if<std::is_class<T>::value, T>::type* = nullptr>
void do_stuff(T& t) {
  // 일반적인 클래스들을 받음
}

SFINAE 가 어떻게 여기서 활용되는지 보도록 합시다. 우리가 do_stuff(int 변수) 와 같이 함수를 호출한다면 컴파일러는 첫 번째 오버로딩을 고르게 됩니다. 왜냐하면 std::is_integral<int> 가 참이기 때문이죠. 이 때 두 번째 오버로딩은 후보군에서 제외되는데 왜냐하면 std::is_class<int>false 이므로 내부에 type 이 정의되지 않는 일반적인 형태의 struct enable_if 가 선택되서 치환 오류가 발생하기 때문입니다.

enable_ifBoost 라이브러리에서 오랜 기간동안 사용되어 왔으며, C++ 11 에서 정식으로 표준 라이브러리에 std::enable_if 의 형태로 포함되었습니다. 또한 C++ 14 에서는 편의를 위해 아래와 같은 alias 를 제공합니다.

template <bool B, typename T = void>
using enable_if_t = typename enable_if<B, T>::type;

이를 통해 위 예제를 약간 더 간단히 나타낼 수 있습니다.

template <class T,
          typename std::enable_if_t<std::is_integral<T>::value>* = nullptr>
void do_stuff(T& t) {
  // 정수 타입들을 받는 함수 (int, char, unsigned, etc.)
}

template <class T,
          typename std::enable_if_t<std::is_class<T>::value>* = nullptr>
void do_stuff(T& t) {
  // 일반적인 클래스들을 받음
}

enable_if 사용 예시들

enable_if 는 매우 유용한 도구 입니다. C++ 11 표준 라이브러리 여러 곳곳에서 널리 사용되고 있습니다. enable_if 없이는 템플릿 오버로딩 함수들이 무분별하게 오버로딩 되서 꾀나 골치아플 것입니다.

예를 들어서 std::vector 에서 enable_if 가 어떻게 사용되는지 살펴봅시다.

// Create the vector {8, 8, 8, 8}
std::vector<int> v1(4, 8);

// Create another vector {8, 8, 8, 8}
std::vector<int> v2(std::begin(v1), std::end(v1));

// Create the vector {1, 2, 3, 4}
int arr[] = {1, 2, 3, 4, 5, 6, 7};
std::vector<int> v3(arr, arr + 4);

위와 같이 vector 의 경우 두 가지 형태의 두 개의 인자를 받는 생성자를 사용할 수 있습니다. 할당자를 무시한다면, 위 생성자들은 아래와 같이 정의될 것입니다.

template <typename T>
class vector {
  vector(size_type n, const T val);

  template <class InputIterator>
  vector(InputIterator first, InputIterator last);

  ...
}

위 두 생성자 모두 두 개의 인자를 받는데, 두 번째의 경우 인자로 같은 타입인 두 객체가 전달된다면 오버로딩 됩니다. 해당 생성자는 반복자를 받기 위해 만들어진 것이기에, 템플릿 인자 이름이 InputIterator 으로 되어 있지만 실제로는 어떠한 의미도 가지지 않습니다. (그냥 그 자리에 T 가 와도 동작하는 방식은 똑같을 것입니다.)

문제는 v1(4, 8) 과 같이 생성자를 호출하였을 경우, 프로그래머는 첫 번째 생성자를 의도한 것이기겠지만, 실제로는 두 번째 생성자가 호출됩니다. 왜냐하면 size_type 이 대개 unsigned 로 정의되어 있지만, 4 의 경우 그냥 signed 이므로 더 잘 맞는 후보군은 두 번째 것이 되기 때문이죠.

따라서 라이브러리 제작자들은 이와 같은 상황을 피하기 위해 enable_if 를 이용해서 InputIterator 가 정말로 반복자일 때만 오버로딩 될 수 있도록 제한하였습니다.

아래는 실제로 해당 생성자가 어떻게 구현되어 있는지 나타냅니다.

template <class _InputIterator>
vector(
  _InputIterator __first,
  typename enable_if<__is_input_iterator<_InputIterator>::value &&
                     !__is_forward_iterator<_InputIterator>::value &&
                     /* ... more conditions... */ _InputIterator>::type __last);

위 생성자의 경우 InputIterator 가 입력 반복자(input iterator) 이고 정방향 반복자(forward iterator)가 아닐 때 오버로딩 됩니다. 반복자가 정방향 반복자일 경우 다른 오버로딩이 존재하는데, 왜냐하면 반복자가 정방향 반복자일 경우 vector 생성을 좀 더 효율적으로 할 수 있기 때문이죠.

앞에서도 말했듯이 enable_ifC++ 11 표준 라이브러리 여기저기에서 사용되고 있습니다. stringappend 함수 역시 위 예시와 매우 비슷한 용도로 사용되고 있습니다.

다른 방식으로 enable_if 를 사용하는 예시로 std::signbit 함수를 들 수 있습니다. 이 함수는 모든 수 타입들(정수, 부동 소수점)에 대해 부호를 리턴하는 함수 입니다. 이 함수가 cmath 헤더에 어떻게 정의되어 있는지 간단히 보자면;

template <class T>
typename std::enable_if<std::is_arithmetic<T>, bool>::type signbit(T x) {
  // implementation
}

만일 enable_if 를 사용하지 않았더라면, 라이브러리 제작자들은 모든 수 타입들에 대해 일일히 오버로딩을 만들었어야만 했을 것입니다. 이는 상당히 귀찮은 일이지요. 또한 만약에 enable_if 없이 그냥 템플릿 함수로 만들었다면, 모든 타입에 대해 오버로딩이 가능하므로 골치아픈 오류를 만들었을 수 도 있습니다. 하지만 enable_if 덕분에 귀찮은 코드 양을 크게 줄일 수 있었습니다.

enable_if 보다 나은 것은 없는지

솔직히 말해서 std::enable_if 를 사용하는 코드를 보면 그리 깔끔하지 않습니다. aliasenable_if_t 를 써도 말이지요. 만일 여러분이 enable_if 를 사용하게 된다면, 함수의 선언 부분과 리턴 타입을 쉽게 알아보기 힘들 것입니다.

하지만 std::enable_if 는 코드 상에서 함수의 정의 부분 외에는 거의 사용되지 않습니다. 따라서, 사용이 복잡하더라고 하더라도 전체 코드 퀄리티에는 크게 영향을 주지 않습니다. 또한 enable_if 는 꾀나 간단합니다. 이 보다 더 복잡하게 만든다면 코드를 읽는 사람의 입장에서 매우 고통스러울 수도 있을 것입니다. 마지막으로, C++ 표준 라이브러리에서는 비록 enable_if 가 코드를 좀 더 난잡하게 만들더라도, 좀 더 복잡하지만 깔끔하게 나타내는 방식 대신에 알아보기 쉬운 enable_if 를 적극 사용하고 있습니다. 저 역시 이 방향이 올바르다고 생각합니다.

댓글이 1 개 있습니다!
프로필 사진 없음
강좌에 관련 없이 궁금한 내용은 여기를 사용해주세요