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

[C++] Type Traits와 Type Utilities

by 별준 2022. 12. 7.

References

  • C++ Standard Library 2nd
  • cppreference (link)

Contents

  • Type Traits (<type_traits)
  • Reference Wrappers (std::reference_wrapper<>)
  • Function Type Wrappers

C++의 표준 라이브러리는 거의 모든 것이 템플릿 기반입니다. 이때, 프로그래머와 라이브러리 구현하는 사람들이 템플릿으로 프로그래밍할 수 있도록 메타프로그래밍(metaprogramming)이라고 불리는 템플릿 유틸리티가 제공됩니다.

 

Type traits는 TR1에서 처음 도입되었고 C++11에서 확장되었는데, 데이터 타입에 의존하는 동작을 정의하는 메커니즘을 제공합니다. 이는 타입에 따라 특별한 기능을 제공하는 코드를 최적화하는데 사용됩니다.

또한, referenc나 function wrapper도 도움이 됩니다.

 

Purpose of Type Traits

Type traits를 사용하면, 데이터 타입에 맞추어 동작시킬 수 있습니다. 템플릿은 컴파일 시간에 특정 타입이나 값을 전달된 템플릿 인자를 통해 추론할 수 있습니다. 우선 아래 예제 코드를 살펴보겠습니다.

template<typename T>
void foo(const T& val)
{
  if (std::is_pointer<T>::value) {
    std::cout << "foo() called for a pointer\n";
  }
  else {
    std::cout << "foo() called for a value\n";
  }
}

여기서 <type_traits>에 정의된 std::is_pointer trait를 사용하여 타입 T가 포인터 타입인지 체크합니다. 사실 is_pointer<>는 ::value가 true이거나 false인 true_type 또는 false_type이라는 데이터 타입을 반환합니다. 만약 전달된 파라미터 val이 포인터라면 foo() 함수는 아래와 같이 출력합니다.

 

하지만, 아래와 같은 코드는 불가능합니다.

template<typename T>
void foo(const T& val)
{
  std::cout << (std::is_pointer<T>::value ? *val : val) << std::endl;
}

이는 *val와 val 모두를 위한 코드가 생성되기 때문입니다. 만약 foo()의 인자로 int 타입을 전달하여 is_pointer<T>::value가 컴파일 시간에 false가 되면, 컴파일 시간에서 위 코드는 다음과 같이 확장됩니다.

std::cout << (false ? *val : val) << std::endl;

그리고 int 타입에 대한 *val는 유효하지 않은 표현식이므로 컴파일되지 않습니다.

하지만, 아래와 같이 코드를 작성할 수는 있습니다.

#include <iostream>
#include <type_traits>

template<typename T>
void foo_impl(const T& val, std::true_type)
{
  std::cout << "foo() called for pointer to " << *val << std::endl;
}
template<typename T>
void foo_impl(const T& val, std::false_type)
{
  std::cout << "foo() called for value " << val << std::endl;
}

template<typename T>
void foo(const T& val)
{
  foo_impl(val, std::is_pointer<T>());
}

int main()
{
  int* val = new int(5);
  foo(val);
  delete val;
}

위 코드에서 foo() 내부에서 사용된 표현식 std::is_pointer<T>()는 컴파일 시간에 std::true_type이나 std::false_type을 나타내며, 따라서, 각각에 맞는 foo_impl()이 인스턴스화됩니다.

 

참고 서적에서는 위 방식이 더 좋다고 언급하는데, 그렇다면 아래와 같이 그냥 각각의 타입에 대해 두 개의 오버로딩된 foo()를 구현하는 것보다 위의 방식이 왜 더 좋을까요?

template<typename T>
void foo(const T& val); // general implementation

template<typename T>
void foo<T*>(const T& val); // partial specialization for pointers

한 가지 이유는 때때로 너무 많은 오버로딩이 필요해지기 때문입니다. 일반적으로 type traits의 장점은 제너릭 코드를 위한 빌딩 블록이 된다는 것입니다. 이어지는 내용들을 통해서 이러한 강점에 대해 더 살펴보도록 하겠습니다.

 

Flexible Overloading for Integral Types

정수형과 부동소수점형의 인자에 대해서 다르게 구현되어야 하는 함수 foo()가 있다고 가정해봅시다. 일반적으로는 모든 가능한 정수형과 부동소수점형을 위해 foo() 함수를 오버로딩해야 할 것 입니다.

void foo(short); // provide integral version
void foo(unsigned short);
void foo(int);
...
void foo(float); // provide floating-point version
void foo(double);
void foo(long double);

하지만, 이렇게 반복하는 것은 지루하고 새로운 정수형이나 부동소수점형에 대해서는 동작하지 않는다는 문제점이 있습니다. 이미 새로운 표준에서는 long long과 같은 새로운 데이터 타입이 추가되었고, 사용자가 새로운 데이터 타입을 정의할 수도 있습니다.

 

이때, type traits를 사용하면 다음과 같은 방식으로 위와 동일한 기능을 제공할 수 있습니다.

template<typename T>
void foo_impl(T val, true_type); // provide integral version

template<typename T>
void foo_impl(T val, false_type); // provide floating-point version

template<typename T>
void foo(T val)
{
  foo_impl(val, std::is_integral<T>());
}

여기서는 정수형을 위한 구현과 부동소수점을 위한 구현을 각각 제공하여 각 데이터 타입에 대한 std::is_integral<> 값에 따라 알맞은 구현을 선택할 수 있습니다.

 

Processing the Common Type

또 다른 예제로, 두 개 이상의 타입으로부터 'common type'을 처리해야 할 때도 type traits가 유용합니다. 여기서 common type은 두 개의 서로 다른 데이터 타입의 값을 처리해야 할 때 사용할 수 있는 데이터 타입을 지칭합니다. 예를 들어, 서로 다른 데이터 타입의 두 값을 더하거나 더 작은 값을 구할 때, 그 결과값을 위한 적절한 데이터 타입이 바로 common type이 됩니다. 이런 common type이 없다면, 모든 가능한 타입의 조합으로부터 더 작은 쪽을 반환하는 함수를 만들어 어떤 데이터 타입을 반환해야 하는지 알려주어야 합니다.

template<typename T1, typename T2>
??? min(const T1& x, const T2& y);

예를 들면, 위와 같이 두 개의 다른 타입으로부터 최솟값을 반환할 때 어떤 타입으로 반환할지 알려주어야 합니다.

 

type traits를 사용하면 반환할 데이터 타입을 선언할 때 간단히 std::common_type<>을 사용하면 됩니다.

template<typename T1, typename T2>
typename std::common_type<T1,T2>::type min(const T1& x, const T2& y);

예를 들어, 만약 두 인자가 int와 long인 경우에 표현식 std::common_type<T1,T2>::type은 int이고, 두 인자 중 하나가 std::string(문자열)이고 다른 하나가 const char*(문자열 리터럴)이라면 std::common_type<T1,T2>::type은 std::string 입니다.

 

std::common_type<>::value는 어떻게 결정되는 것일까요 ?

사실 간단하게 구현되어 있는데, 두 피연산자의 데이터 타입에 따라 결과 데이터 타입이 결정되도록 연산자 ?:를 사용하여 구현되어 있습니다. 간단히 표현하면 다음과 같이 구현되어 있다고 볼 수 있습니다.

template<typename T1, typename T2>
struct common_type<T1,T2> {
  typedef decltype(true ? declval<T1>() : declval<T2>()) type;
};

여기서 decltype은 C++11에서 새로 도입된 키워드이며, 표현식의 데이터 타입을 나타냅니다. declval<>은 전달된 데이터 타입을 평가하지 않고(rvalue references를 생성) 선언된 값을 제공하는 auxiliary trait 입니다.

 

따라서, 연산자 ?:을 사용하여 common type을 찾을 수 있다면 그 값을 common_type<>이 사용합니다. 찾을 수 없더라도 common_type<>에 대한 오버로딩을 제공할 수 있습니다 (chrono 라이브러리에서 durations을 결합하기 위해서 오버로딩합니다).

 

Type Traits in Detail

Type traits는 보통 <type_traits>에 정의되어 있습니다. 어떤 type traits를 제공하는지 세부적으로 살펴보도록 하겠습니다.

 

(Unary) Type Predicates

위에서 살펴본 것처럼 type predicates(타입 조건자)는 지정한 속성이 적용 가능하면 std::true_type을, 그렇지 않다면 std::false_type으로 나타냅니다. std::true_type과 std::false_type은 헬퍼인 std::integral_constant의 특수화이며, 각각의 value 멤버는 true나 false를 나타냅니다.

namespace std {
  template<typename T, T val>
  struct integral_constant {
    static constexpr T value = val;
    typedef T value_type;
    typedef integral_constant<T,val> type;
    constexpr operator value_type() {
      return value;
    }
  };
  typedef integral_constant<bool,true> true_type;
  typedef integral_constant<bool,false> false_type;
}

 

데이터 타입에 대해 제공되는 type predicates는 아래와 같습니다. cppreference에서 캡처한 정보입니다.

bool과 모든 문자 데이터 타입(char, char16_t, char32_t, wchar_t)은 정수형으로 취급되며, std::nullptr_t는 기본 데이터 타입으로 취급됩니다.

 

항상 그런 것은 아니지만, 대부분의 traits들은 하나의 인자만을 받습니다. 즉, 단 하나의 템플릿 인자를 사용한다는 의미이며, 예를 들어, is_const<>는 전달된 데이터 타입이 const인지 검사합니다.

is_const<int>::value;                // false
is_const<const volatile int>::value; // true
is_const<int* const>::value;         // true
is_const<const int*>::value;         // false
is_const<const int&>::value;         // false
is_const<int[3]>::value;             // false
is_const<const int[3]>::value;       // true
is_const<int[]>::value;              // false
is_const<const int[]>::value;        // true

참고로 상수형에 대한 상수가 아닌 포인터나 레퍼런스는 상수가 아니며, 상수형 요소를 갖는 일반 배열은 상수형입니다.

 

복사와 이동을 위한 traits checking은 해당 표현식이 가능한지만을 검사한다는 점에 유의합니다. 예를 들어, 상수 인자를 받는 복사 생성자는 있지만 이동 생성자는 없는 경우, 여전히 이동 생성은 가능합니다.

 

is_nothrow...와 같은 type traits는 noexcept 명세를 만들 때만 사용됩니다 (자세한 내용은 저도 아직 파악하지 못했습니다).

 

Traits for Type Relations

아래는 데이터 타입 간의 관계를 검사할 때 사용할 수 있는 type traits 입니다. 여기에는 해당 클래스에서 어떤 생성자와 할당 연산자가 제공되는지 검사하는 부분도 포함되어 있습니다.

int와 같은 데이터 타입은 lvalue 또는 rvalue를 표현합니다. 따라서, 아래와 같이

42 = 77;

할당할 수 없기 때문에 첫 번째 데이터 타입으로 클래스가 아닌 데이터 타입이 들어온다면 항상 is_assignable<>은 false_type을 나타냅니다.

 

하지만, 첫 번째 데이터 타입이 클래스인 경우, 자신의 데이터 타입을 첫 번째 데이터 타입으로 전달할 수 있습니다. 클래스 타입의 rvalue의 멤버 함수는 호출할 수 있다는 재밌고 오래된 규칙 때문인데, 예제로 보면 조금 더 이해가 잘 될 것 같습니다.

is_assignable<int,int>::value;                 // false
is_assignable<int&,int>::value;                // true
is_assignable<int&&,int>::value;               // false
is_assignable<long&,int>::value;               // true
is_assignable<int&,void*>::value;              // false
is_assignable<void*,int>::value;               // false
is_assignable<const char*,std::string>::value; // false
is_assignable<std::string,const char*>::value; // true

 

Type Modifiers

아래의 type traits는 데이터 타입을 수정할 때 사용되는 것들 입니다.

모든 modifier traits는 데이터 타입에 해당 속성이 없다면 추가하고, 속성이 이미 존재한다면 제거합니다. 예를 들어, int 타입에 대해서는 확장만 가능합니다.

typedef int T;
add_const<T>::type;             // const int
add_lvalue_reference<T>::type;  // int&
add_rvalue_reference<T>::type;  // int&&
add_pointer<T>::type;           // int*
make_signed<T>::type;           // int
make_unsigned<T>::type;         // unsigned int
remove_const<T>::type;          // int
remove_reference<T>::type;      // int
remove_pointer<T>::type;        // int

반면에 const int&에 대해서는 줄어들 수도 있고 확장될 수도 있습니다.

typedef const int& T;
add_const<T>::type;             // const int&
add_lvalue_reference<T>::type;  // const int&
add_rvalue_reference<T>::type;  // const int& (lvalue remains lvalue)
add_pointer<T>::type;           // const int*
make_signed<T>::type;           // undefined behavior
make_unsigned<T>::type;         // undefined behavior
remove_const<T>::type;          // const int&
remove_reference<T>::type;      // const int
remove_pointer<T>::type;        // const int&

마찬가지로 상수형에 대한 레퍼런스는 상수가 아니라는 점에 유의합니다. make_signed<>나 make_unsigned<>는 인자가 bool을 제외한 정수형이나 열거형이어야 하기 때문에 레퍼런스를 전달하면 정의되지 않은 동작이 발생합니다.

또한, add_lvalue_reference<>는 rvalue 레퍼런스를 lvalue 레퍼런스로 바꿔주는데, add_rvalue_reference<>는 lvalue 레퍼런스를 rvalue 레퍼런스로 바꾸지 않습니다. 따라서, lvalue에서 rvalue 레퍼런스로 바꾸고 싶다면 다음과 같이 작성해야 합니다.

add_rvalue_reference<remove_reference<T>::type>::type

 

 

Other Type Traits

나머지 type traits는 아래와 같습니다.

이들은 특별한 속성을 쿼리하거나, 데이터 타입 관계를 검사하거나 조금 더 복잡한 데이터 타입 변환을 제공합니다.

 

Reference Wrappers

std::reference_wrapper<> 클래스는 <functional>에 선언되어 있으며, 처음에는 파라미터를 값으로 받는 함수 템플릿에 레퍼런스를 전달하기 위해 사용되었습니다. 이 클래스는 주어진 데이터 타입 T를 암묵적으로 T&로 변환하는 ref()와 암묵적으로 const T&로 변환하는 cref()를 제공합니다. 이를 통해 특수화없이 함수 템플릿이 레퍼런스에 대해서도 동작할 수 있습니다.

 

예를 들어, 아래와 같이 선언한 후

template<typename T>
void foo(T val);

아래와 같이 코드를 작성하면, T는 int&가 되지만

int x;
foo(std::ref(x));

다음과 같이 사용하면 T는 const int&가 됩니다.

int x;
foo(std::cref(x));

 

이와 같은 기능은 C++ 표준 라이브러리 내에서 다음과 같이 다양하게 사용됩니다.

  • make_pair()는 이를 사용하여 레퍼런스의 pair<>를 생성
  • make_tuple()은 이를 사용하여 레퍼런스의 tuple<>을 생성
  • Binders는 이를 사용하여 레퍼런스를 바인딩할 수 있음
  • Threads는 이를 사용하여 인자를 레퍼런스로 전달

 

reference_wrapper 클래스를 사용하면 레퍼런스를 일급 객체(first-class objects), 즉, 배열이나 STL 컨테이너의 요소로 사용할 수 있습니다.

std::vector<MyClass&> coll;                        // ERROR
std::vector<std::reference_wrapper<MyClass>> coll; // OK

 

Function Type Wrappers

<functional>에 선언된 std::function<> 클래스는 함수 포인터라는 개념을 일반화하는 polymorphic wrappers를 제공합니다. 이 클래스는 callable objects (functions, member functions, function objects, lambdas)를 일급 객체로 사용할 수 있습니다. 즉, 일반 객체러럼 쓸 수 있습니다.

 

다음 예제 코드를 살펴봅시다.

void func(int x, int y);

// initialize collections of tasks
std::vector<std::function<void(int,int)>> tasks;
tasks.push_back(func);
tasks.push_back([](int x, int y) {
                  ...
                });

// call each task
for (std::function<void(int,int)> f : tasks) {
  f(33, 66);
}

각 태스크를 호출할 때, for문에서 간결하게 auto를 사용해도 됩니다.

 

멤버 함수를 사용할 때, 객체는 첫 번째 인자가 멤버 함수의 객체이어야만 합니다.

class C {
public:
  void memfunc(int x, int y) const;
};

std::function<void(const C&,int,int)> mf;
mf = &C::memfunc;
mf(C(),42,77);

 

람다를 반환하는 함수를 선언하는 것도 이를 사용하는 또 다른 예제입니다.

또한, 호출할 대상 없이 함수 호출을 하면 std::bad_function_call 예외가 발생하게 됩니다.

std::function<void(int,int)> f;
f(33, 66); // throw std::bad_function_call

'프로그래밍 > C & C++' 카테고리의 다른 글

[C++] Iterator Traits와 User-Defined Iterators  (0) 2022.12.11
[C++] Clocks and Timers  (0) 2022.12.08
[C++] Numeric Limits  (0) 2022.12.06
[C++] Pairs and Tuples  (0) 2022.12.06
[C++] C++11에서 추가된 기능 (2)  (0) 2022.12.04

댓글