9월 11, 2019 · C++ developemnt

C++11 / C++17 함수 존재 확인 후 기본 행동 정의하기

C++11/17: How to detect the existence of a member functions in the given class and define default behavior

C++에서 다른 타입을 갖는 객체들을 같은 인터페이스로 접근 또는 활용하기 위해서 쉽게 접근할 수 있는 방법 중 하나는 상속을 사용한 클래스 다형성을 기반으로한 방법이다. 같은 클래스를 public 상속받은 다양한 타입의 클래스들은 같은 인터페이스를 지닐 수 있고, 가상 함수 테이블을 활용하여 각기 다른 로직을 작동시킬 수 있다. 그러나 상속을 사용하지 않는 방법도 존재한다. C++의 템플릿 기능을 사용하면 공통 부모가 없고, 심지어 다형성도 만족하지 않는 클래스 타입들의 객체도 일관되게 처리할 수 있다. 특정 attribute를 정의하고, 그것을 만족하는 클래스들에 대해 다양한 로직 패스로 처리할 수 있다. 특히 원하는 멤버함수가 존재하는지 여부도 확인하고 없을 경우 기본 로직을 사용하도록 설정할 수 있다.  이 글에서 템플릿 기능을 사용하여 어떻게 다른 클래스들을 같이 처리할 수 있는지에 대해 실제 gcc-c++ 의 glibcxx 헤더파일과 예시를 통해 보여주려고 한다.

iterator & iterator_tratis

템플릿을 통한 다양한 클래스의 공통된 로직의 활용을 우리가 가장 쉽게 접하는 부분은 STL의 컨테이너 클래스들의 iterator들 일 것이다. algorithm 헤더 등에 존재하는 iterator 기반 함수들은 iterator의 타입을 클래스 인자로 받아 처리하게 된다. 이 때, 각 iterator들에 대해 공통된 인터페이스를 제공하기 위해 iterator_traits이라는 클래스를 별도로 정의하게 된다. 물론 iterator들에 대해서 iterator_traits만 정의되는 것이 아니라 특정 성질을 만족해야 한다고 명시하고 있다. 다음은 iterator들, 특히 LegacyIterator에 대한 요구조건이다.

The type It satisfies LegacyIterator if
    The type It satisfies CopyConstructible, and
    The type It satisfies CopyAssignable, and
    The type It satisfies Destructible, and
    lvalues of type It satisfy Swappable, and
    std::iterator_traits<It> has member typedefs value_type, 
    difference_type, reference, pointer, and iterator_category , and 
Given
    r, an lvalue of type It. 
The following expressions must be valid and have their specified effects:
┌────────────┬─────────────┬─────────────────────┐
│ Expression │ Return Type │ Precondition        │
├────────────┼─────────────┼─────────────────────┤
│ *r         │ unspecified │ r is dereferenceable|
├────────────┼─────────────┼─────────────────────┤
│  ++r 	     │    It&      │ r is incrementable  │
└────────────┴─────────────┴─────────────────────┘

from https://en.cppreference.com/w/cpp/named_req/Iterator

특히, iterator_traits에 대해 필요한 사항에 대하 기술하고 있는데, 그 중 iterator_category를 통해 iterator의 성질을 구분하게 된다. 이를 이용한 대표적인 함수가 std::distance이다. iterator_category의 타입을 통해 현재 iterator 클래스가 임의접근이 가능한지 여부를 확인한다. 실제 GLIBCXX의 구현 헤더파일을 보면, random_access_iterator_tag를 지닌 iterator_traits일 때와 아닐 경우를 함수 오버로딩을 통해 알맞은 로직을 찾아가도록 구현하였다.

template<typename _InputIterator>
inline _GLIBCXX17_CONSTEXPR
typename iterator_traits<_InputIterator>::difference_type
distance(_InputIterator __first, _InputIterator __last)
{
  // concept requirements -- taken care of in __distance
  return std::__distance(__first, __last,
             std::__iterator_category(__first));
}

template<typename _InputIterator>
inline _GLIBCXX14_CONSTEXPR
typename iterator_traits<_InputIterator>::difference_type
__distance(_InputIterator __first, _InputIterator __last,
           input_iterator_tag)
{
  // concept requirements
  __glibcxx_function_requires(_InputIteratorConcept<_InputIterator>)

  typename iterator_traits<_InputIterator>::difference_type __n = 0;
  while (__first != __last)
  {
    ++__first;
    ++__n;
  }
  return __n;
}

template<typename _RandomAccessIterator>
inline _GLIBCXX14_CONSTEXPR
typename iterator_traits<_RandomAccessIterator>::difference_type
__distance(_RandomAccessIterator __first, _RandomAccessIterator __last,
           random_access_iterator_tag)
{
  // concept requirements
  __glibcxx_function_requires(_RandomAccessIteratorConcept<
              _RandomAccessIterator>)
  return __last - __first;
}

위 함수를 std::vector 타입의 iterator에 대해 호출했을 경우, std::vector::iterator의 iterator_traits (또는 iterator 그 자체)가 random_access_iterator_tag를 지니고 있기 때문에 두 iterator의 뺄셈을 통해 distance값을 얻어낼 수 있다. 실제로 std::vector의 코드를 살펴보면 확인할 수 있다.

template<typename _Tp, typename _Alloc = std::allocator<_Tp> >
class vector : protected _Vector_base<_Tp, _Alloc>
{
  typedef _Vector_base<_Tp, _Alloc>                     _Base;
public:
  typedef _Tp                                           value_type;
  typedef typename _Base::pointer                       pointer;
  typedef __gnu_cxx::__normal_iterator<pointer, vector> iterator;
};

template<typename _Iterator, typename _Container>
class __normal_iterator
{
protected:
  _Iterator _M_current;
  typedef iterator_traits<_Iterator> __traits_type;
public:
  typedef typename __traits_type::iterator_category iterator_category;
};

template<typename _Tp>
struct iterator_traits<_Tp*>
{
  typedef random_access_iterator_tag  iterator_category;
};

STL에서는 이렇게 어떤 클래스들이 만족시켜야 할 성질을 기술한 requirement 그리고 그것을 지원하기 위한 traits, tags 등의 보조 타입들을 통해 여러 다른 클래스들을 다루고 있다.

allocator & allocator_traits

iterator에서 눈을 돌려 allocator에 주목하면 여기서는 다른 방식을 사용하는 것을 확인 할 수 있다. allocator_traits이 그 주역이다. STL 컨테이너들은 동적 메모리 할당이 필요할 때 이를 위한 전략의 다변화를 위해 allocator를 템플릿 형태로 인자로 받는다. 지정해 주지 않으면 std::allocator를 사용하지만 필요하다면 커스텀한 allocator를 사용할 수 있다. 이 때, 각 allocator들에 대해 같은 인터페이스를 사용하기 위해 필요한 것이 allocator_traits이다. iterator와 마찬가지로 allocator도 만족해야할 성질들이 있다. 링크에서 확인할 수 있다.

주목해야 할 것은, allocate, deallocate, construct, destroy 함수 4개이다. 각각 메모리를 할당, 해제하고 객체를 생성, 소멸을 담당하는 함수이다. 이 중 allocate와 deallocate만 필수로 존재해야하는 함수이고 construct와 destroy는 optional 함수이다. optional 함수 2개를 정의하지 않은 allocator들에 대해서도 우리는 STL 컨테이너가 올바르게 작동하는 것을 볼 수 있는데, 해당 함수가 존재하지 않을 경우 대신 수행할 default behavior가 정의되어 있기 때문이다. 그리고 그 역할을 allocator_traits이 담당한다. 다음 코드로부터 vector에서 공간을 할당하거나 객체를 생성할 때 allocator_traits를 사용하는 모습을 확인할 수 있다.

template<typename _Tp, typename _Alloc>
struct _Vector_base
{
  pointer
  _M_allocate(size_t __n)
  {
    typedef __gnu_cxx::__alloc_traits<_Tp_alloc_type> _Tr;
    return __n != 0 ? _Tr::allocate(_M_impl, __n) : pointer();
  }
  void
  push_back(const value_type& __x)
  {
    if (this->_M_impl._M_finish != this->_M_impl._M_end_of_storage)
    {
      _Alloc_traits::construct(this->_M_impl, this->_M_impl._M_finish, __x);
      ++this->_M_impl._M_finish;
    }
    else
      _M_realloc_insert(end(), __x);
  }
};

여기서 핵심을 알기 위해서는 allocator_traits의 construct 함수가 construct함수가 정의되어있지 않은 allocator에 대해서 어떤 행동을 하는지를 파악해야한다. 해당 함수의 코드는 다음과 같다.

template<typename _Tp, typename... _Args>
using __has_construct
  = typename __construct_helper<_Tp, _Args...>::type;

template<typename _Alloc>
struct allocator_traits : __allocator_traits_base
{
  template<typename _Tp, typename... _Args>
  static _Require<__has_construct<_Tp, _Args...>>
  _S_construct(_Alloc& __a, _Tp* __p, _Args&&... __args)
  { __a.construct(__p, std::forward<_Args>(__args)...); }

  template<typename _Tp, typename... _Args>
  static
  _Require<__and_<__not_<__has_construct<_Tp, _Args...>>,
  is_constructible<_Tp, _Args...>>>
  _S_construct(_Alloc&, _Tp* __p, _Args&&... __args)
  { ::new((void*)__p) _Tp(std::forward<_Args>(__args)...); }
};

리턴타입을 살펴보면 _Require와 __has_construct라는 헬퍼 클래스를 활용하고 있는것으로 보인다. _Require는 std::enable_if의 aliasing으로, SFINAE을 활용해서 해당 멤버 함수가 있는지 확인하고 있다. __has_construct 헬퍼 클래스가 실제로 함수가 존재하는지 확인하는 역할을 한다. __has_construct에서 어떻게 함수의 존재를 확인하고 있는지 살펴보자.

using true_type = std::integral_constant<bool, true>;
using false_type = std::integral_constant<bool, false>;
template<typename _Tp, typename... _Args>
struct __construct_helper
{
  template<typename _Alloc2,
  typename = decltype(std::declval<_Alloc2*>()->construct(
      std::declval<_Tp*>(), std::declval<_Args>()...))>
  static true_type __test(int);

  template<typename>
  static false_type __test(...);

  using type = decltype(__test<_Alloc>(0));
};

그렇게 복잡하지 않다. 해당 클래스의 멤버변수가 호출되는지 여부를 확인할 뿐이다. std::declval을 통해 객체생성 없이 함수를 호출시도하고 인자도 역시 마찬가지로 std::declval을 통해 객체생성없이 전달시도를 해본다. 즉, 컴파일 시도를 해본다. std::declval<_Alloc2*>()->construct(std::declval<_Tp*>(), std::declval<_Args>()...) 가 바로 그 부분이다. _Alloc2 클래스에 construct라는 멤버함수가 없거나, 또는 존재하더라도 함수의 시그니처가 다르다면 시맨틱 에러가 발생할 것이고, SFINAE에 따라 이는 컴파일 에러로 이어지지 않고 다음 substitution을 찾게 된다. 이는 곧 아래줄의 false_type __test(...)에 해당한다. 종합적으로 살펴보면 __test<_Alloc>을 발견한 템플릿 엔진이 2개의 __test중 substitution을 시도해 볼 것이고, 성공한 함수만 사용할 것이다. 그 이후 성공한 함수의 리턴값이 컴파일 타임 상수를 나타내는 클래스이므로 이를 저장하게 된다.

_Require 또는 std::enable_if는 첫번째 템플릿 인자의 컴파일 타입 상수 불리언 값을 보고 true일 경우에만 유요한 클래스가 되므로, construct함수가 호출 가능할 경우와 불가능 할 경우를 구분할 수 있다. 불가능 할 경우 ::new를 사용하여 default behavior를 통해 객체생성을 하게 된다. 별도의 생성자 호출 가능 여부를 확인하는 코드가 있지만 핵심은 결국 __construct_helper이다.

SFINAE based default behavior

이를 참고하여 특정 멤버 함수에 대해서 검출 및 default behavior를 설정할 수 있다. 다음은 Func이라는 이름의 멤버 함수가 존재하는지 여부를 확인하는 보조 클래스이다.

template<class T, class... Args>
struct DetectFunctionHelper {
    template<class T2,
    typename = decltype(std::declval<T2*>()->Func(std::declval<Args>()...))>
    static std::integral_constant<bool, true> test(int);

    template<typename>
    static std::integral_constant<bool, false> test(...);

    using type = decltype(test<T>(0));
};

template<class T, class... Args>
using HaveFunc = typename DetectFunctionHelper<T, Args...>::type;

이 보조 클래스를 활용하여 Func함수가 있을 경우와 없을 경우에 대한 예제 코드이다. Func 라는 이름의 멤버 함수가 주어진 인자를 이용하여 호출가능 할 경우 올바르게 호출하며, 그 외의 경우 default behavior를 수행한다. 클래스 A의 경우 Func 라는 이름의 함수가 인자가 없을 경우(void)에만 호출 가능하므로, Wrapper::Func(a, 0) 의 경우 호출 불가능하므로 default behavior가 호출된다.

struct A {
    void Func() {
        std::cout << "A::Func" << std::endl;
    }
};

struct B {
};

template<class T>
struct Wrapper {
private:
    template<class U, class... Args>
    static
    typename std::enable_if<HaveFunc<U, Args...>::value>::type
    __FuncHelper(U& u, Args&& ...args) {
        u.Func(std::forward<Args>(args)...);
        std::cout << "Specified Func is called." << std::endl;
    }
    template<class U, class... Args>
    static
    typename std::enable_if<!HaveFunc<U, Args...>::value>::type
    __FuncHelper(U&, Args&& ...) {
        std::cout << "Default behavior is called." << std::endl;
    }
public:
    template<class... Args>
    static auto Func(T& t, Args&& ...args) -> decltype(__FuncHelper(t, args...)) {
        __FuncHelper(t, std::forward<Args>(args)...);
    }
};

int main () {
    A a;
    B b;
    int i;
    Wrapper<decltype(a)>::Func(a); 
    Wrapper<decltype(a)>::Func(a, 0);
    Wrapper<decltype(b)>::Func(b);
    Wrapper<decltype(i)>::Func(i);
}

/*         output          */
/// A::Func
/// Specified Func is called.
/// Default behavior is called.
/// Default behavior is called.
/// Default behavior is called.

이 보조 클래스에서 함수의 리턴을 처리하기 위해서는 추가적인 작업이 필요하다. C++14에서는 auto 를 통한 함수 리턴 타입 추론이 가능하지만, C++11일 경우 decltype을 통해 지정해 주어야 하기 때문에 추가적인 작업이 필요하다. 다음 코드는 함수 리턴이 void일 경우에도 일괄로직을 작동하기 위한 변경한 코드이다. 이 시나리오를 처리하기 위해서는 다음과 같은 3가지 경우의 수를 고려했다. 1) Func 함수가 존재할 경우, 그리고 리턴 타입이 void가 아닐 경우; 2) Func 함수가 존재하지만 리턴 타입이 void일 경우; 3) Func 함수가 존재하지 않을 경우. 이러한 경우를 고려하여 처리한 코드는 다음과 같다.

struct A {
    int Func() {
        std::cout << "A::Func" << std::endl;
        return 0;
    }
};

struct B {
};

struct SomeReturnType {};

template<class T>
struct Wrapper {
private:
    /// 1) Func 함수가 존재할 경우, 그리고 리턴 타입이 void가 아닐 경우
    template<class U, class... Args>
    static
    typename std::enable_if<
        HaveFunc<U, Args...>::value
        && !std::is_void<
            decltype(std::declval<U*>()->Func(std::declval<Args>()...))
            >::value,
    decltype(std::declval<U*>()->Func(std::declval<Args>()...))>::type
    __FuncHelper(U& u, Args&& ...args) {
        std::cout << "Specified Func is called." << std::endl;
        return u.Func(std::forward<Args>(args)...);
    }
    
    /// 2) Func 함수가 존재하지만 리턴 타입이 void일 경우
    template<class U, class... Args>
    static
    typename std::enable_if<
        HaveFunc<U, Args...>::value
        && std::is_void<
            decltype(std::declval<U*>()->Func(std::declval<Args>()...))
            >::value,
    SomeReturnType>::type
    __FuncHelper(U& u, Args&& ...args) {
        std::cout << "Specified Func is called." << std::endl;
        u.Func(std::forward<Args>(args)...);
        return SomeReturnType();
    }
    
    /// 3) Func 함수가 존재하지 않을 경우
    template<class U, class... Args>
    static
    typename std::enable_if<!HaveFunc<U, Args...>::value,
    SomeReturnType>::type
    __FuncHelper(U&, Args&& ...) {
        std::cout << "Default behavior is called." << std::endl;
        return SomeReturnType();
    }
public:
    template<class... Args>
    static auto Func(T& t, Args&& ...args)
    -> decltype(__FuncHelper(std::declval<T&>(), std::declval<Args>()...)) {
        return __FuncHelper(t, std::forward<Args>(args)...);
    }
};

template<class T>
void ProcessReturnValue(T&&) {
    std::cout << "Valid return type." << std::endl;
}

template<>
void ProcessReturnValue<SomeReturnType&>(SomeReturnType&) {
    std::cout << "Invalid return type." << std::endl;
}

template<>
void ProcessReturnValue<SomeReturnType&&>(SomeReturnType&&) {
    std::cout << "Invalid return type." << std::endl;
}

int main () {
    A a;
    B b;
    int i;
    {
        auto&& ret = Wrapper<decltype(a)>::Func(a);
        ProcessReturnValue(ret);
    }
    std::cout << "====================" << std::endl;
    {
        auto&& ret = Wrapper<decltype(a)>::Func(a, 0);
        ProcessReturnValue(ret);
    }
    std::cout << "====================" << std::endl;
    {
        auto&& ret = Wrapper<decltype(b)>::Func(b);
        ProcessReturnValue(ret);
    }
    std::cout << "====================" << std::endl;
    {
        auto&& ret = Wrapper<decltype(i)>::Func(i);
        ProcessReturnValue(ret);
    }
}

/*         output          */
/// Specified Func is called.
/// A::Func
/// Valid return type.
/// ====================
/// Default behavior is called.
/// Invalid return type.
/// ====================
/// Default behavior is called.
/// Invalid return type.
/// ====================
/// Default behavior is called.
/// Invalid return type.

C++17

위 예제 코드는 C++11에서 돌아가는 것을 보장하는 코드이다. C++17에서는 if constexpr기능이 도입이 되었고, enable_if 대신 if constexpr를 사용할 수 있다. 아래 코드는 if constexpr을 활용하여 같은 동작을 수행한다. enable_if와 SFINAE을 사용하기 위한 추가적인 템플릿 함수가 필요가 없어서 더 간결하게 작성할 수 있다. 또한, C++14에서 추가된 리턴 타입 추론과 함께 함수의 리턴 처리도 더 간편해졌다.

template<class T>
struct Wrapper {
public:
    template<class... Args>
    static auto Func(T& t, Args&& ...args) {
        if constexpr(HaveFunc<T, Args...>::value) {
            std::cout << "Specified Func is called." << std::endl;
            return t.Func(std::forward<Args>(args)...);
        } else if (!HaveFunc<T, Args...>::value) {
            std::cout << "Default behavior is called." << std::endl;
            return SomeReturnType();
        }
    }
};

정리

우리는 C++에서 어떻게 컴파일 타임에 특정 멤버 변수가 존재하는지 확인하고, 존재하지 않았을 경우 우회가능한 로직을 정의하는 방법을 알아보았다. C++11에서는 SFINAE을 사용한 템플릿 추론을 통한 방법이 있었고 C++17까지 허용한다면 컴파일 타임 조건문(if constexpr)을 통한 방법을 통해 더 간편하게 코드를 작성 할 수 있었다.