본문 바로가기

진리는어디에/C++

[C++] type traits 기초

들어가며

type traits는 C++ 템플릿 메타 프로그래밍에서 꽤나 유용하게 쓰이는 기술중에 하나다. 여러분은 type traits를 이용하여 '타입'의 다양한 속성들에 대해 조사하거나, 타입의 프로퍼티를 변경할 수 있다.

예를 들어 제네릭타입 T가 있다고 가정하자. T는 int도 될 수 있고, bool, std::vector 또는 다른 어떠한 타입도 가능하다.

template <class T>
class Widget 
{
    // ...
};

여러분은 type traits를 이용하여 제네릭 템플릿 인자로 넘어온 타입 T가 int형인지, 함수인지, 포인터인지 아니면 클래스인지, 클래스라면 소멸자를 가졌는지, 복사가 가능한지, 예외를 던지는지 아닌지 등등 다양한 것들을 조사 할 수 있다. 이런  타입에 대한 정보들은 조건부 컴파일(조건에 따라 컴파일 되는 코드가 달라지는 것. 여기서는 타입에 따른 컴파일 패스 분기를 말한다)에 매우 유용하다. 

type traits는 타입을 변형하는데도 사용된다. 특정 타입 T가 있다고 했을때 여러분은 T에 const 한정자를 붙이거나 제거 할 수 있다. 또는 타입을 레퍼런스로 만들거나 포인터로 만들 수도 있으며, signed/unsigned 타입으로도 변경이 가능하다. 

이런 테크닉의 가장 큰 장점이자 단점은 모든 것이 컴파일 타임에 완료 된다는 것이다. 타입에 관련된 연산들은 런타임에 아무런 실행 시간의 패널티 없이 동작한다. 대신 컴파일 타임이 미칠 듯이 길어지기도 한다. 이것이 바로 메타 프로그래밍이다.

이번 포스트에서는 이런 것을 가능하게 도와주는 type traits에 대해 살펴 보는 시간을 가지도록 하겠다. 이 포스트에서는 수많은 type traits들의 개별 용도에 대해 일일이 설명하기 보다 type traits의 개념과 공통 사용법에 대해 다루도록 하겠다.

참고로 type traits는 '템플릿 특화'를 이용해 구현된다. 만일 템플릿 특화에 익숙하지 않은 분이라면 본 포스트를 읽기 전에[C++] 템플릿 특화(Template specialize) 완벽 가이드를 먼저 읽어 보시는 편이 본 포스트를 이해하는데 훨씬 도움이 될 것이다.

type traits란 무엇인가?

type traits는 타입에 대한 상수 정보를 가진 구조체다

type traits는 단순한 템플릿 구조체다. 다만 그 안에 우리가 앞에서 살펴 보았던 타입의 프로퍼티들에 대한 정보를 상수 멤버 변수에 담고 있는 것이 다를뿐이다. 보다 쉬운 설명을 위해 <type_traits> 헤더에 정의되어 있는 많은 type traits 중에 하나인 std::is_floating_point를 살펴보자.

#include <type_traits>

template<typename T> 
struct is_floating_point;

is_floating_point type traits는 타입 T가 부동 소수점 타입인지 그렇지 않은지 알려준다. 내부에 정의된 value 상수 멤버 변수는 T에 따라 true 또는 false로 셋팅되어 우리가 타입에 대한 정보를 얻을 수 있도록 한다.

반면에 std::remove_reference는 타입 T의 레퍼런스를 제거해준다.

template<typename T>
struct remove_reference;

remove_reference의 type 상수 멤버는 레퍼런스가 제거된 T의 타입을 가지게 된다. 이게 전부다. type traits를 어렵게 생각하지 말자.

type traits는 어떻게 사용하는가?

단순히 당신이 원하는 타입을 템플릿 인자로 넘겨주는 것만으로 type traits를 사용은 끝이다. 예를 들어, 당신이 특정 타입이 부동 소수점 타입(float)인지 아닌지를 알고 싶다고 가정하자.

#include <iostream>
#include <type_traits>

class Class {};

int main() 
{
    std::cout << std::is_floating_point<Class>::value << '\n';
    std::cout << std::is_floating_point<float>::value << '\n';
    std::cout << std::is_floating_point<int>::value << '\n';
}

위 프로그램의 실행 결과는 아래와 같다.

0
1
0

그래서 type traits는 정확히 어떻게 동작하는 것인가?

앞의 샘플 코드에서 우리는 세 가지 다른 타입(사용자 정의 클래스, float, int)을 템플릿 구조체인 std::is_floating_point에게 넘겨 주었다. 컴파일러는 타입에 따라 아래와 같이 세 가지 클래스를 생성한다(정확히 아래와 같진 않다. 개념적으로 아래와 같이 만들어 낸다고 이해하자). 

struct is_floating_point_Class {
    static const bool value = false;
};

struct is_floating_point_float {
    static const bool value = true;
};

struct is_floating_point_int {
    static const bool value = false;
};

여기서 중요한 부분은 컴파일러에 의해 생성된 value 멤버 변수에 우리가 원하는 값이 들어있고, 컴파일러가 이것을 런타임이 아닌 컴파일 타임에 채워준다는 것이다.

조건부 컴파일

이제 type traits의 기본 개념을 파악했으므로 실제 시나리오에서 type traits 사용하는 법을 살펴 보겠다. 만일 동일한 알고리즘을 가지고 있는 두개의 함수가 있다고 가정하자. 하나는 부호 있는 정수(int)에 대해 작동하고, 다른 하나는 부호 없는 정수(unsiged int)에 대해 엄청난 최적화를 구현해 놓았다. 우리는 int가 인자로 전달 될 때 컴파일러가 signed에 관련된 알고리즘 함수를 선택하고, unsigned int가 전달 될때는 unsigned에 최적화된 함수를 선택하여 컴파일 하기를 원한다. 이것이 앞에 언급된 조건부 컴파일(conditional compile)이다.

이 작업을 위해 우리는 세 가지가 필요하다.

  • if constexpr 구문 : C++17에서 부터 추가된 constexpr은 컴파일 시간에 if에 대한 판단을 할 수 있다.
    constexpr 조건문에 대한 자세한 설명은 [여기]를 확인하자.
  • static_assert : 이름에서 알 수 있듯이 조건이 충족되지 않으면 컴파일 타임에 assert를 떨어뜨리는 C++11에 추가된 함수
  • std::is_signed와 std::is_unsigned. 두 개의 type traits

코드는 아래와 같다.

void algorithm_signed  (int i)      { /*...*/ } 
void algorithm_unsigned(unsigned u) { /* 뭔가 unsigned int에 최적화된 알고리즘 */ } 

template <typename T>
void algorithm(T t)
{
    if constexpr(std::is_signed<T>::value)
    {
        algorithm_signed(t);
    }
    else
    {
        if constexpr (std::is_unsigned<T>::value)
        {
            algorithm_unsigned(t);
        }
        else
        {
            static_assert(std::is_signed<T>::value || std::is_unsigned<T>::value, "Must be signed or unsigned!");
        }
    }
}

위 코드에서 템플릿 함수 algorithm은 디스패쳐 역할을 한다. 컴파일시 컴파일러는 타입 T에 따라 적절한 함수를 찾아 내어 컴파일한다. 만인 int인 경우 algorithm_signed가 컴파일에 포함 될 것이며, unsigned int인 경우 algorithm_unsigned가 대신 포함 될 것이다. 이도 저도 아니면 static_assert에서 컴파일 예외를 던진다.

위 코드의 사용은 아래와 같다.

algorithm(3);       // T 가 int, algorithm_signed()이 컴파일에 포함된다.

unsigned x = 3;
algorithm(x);       // T 가 unsigned int, algorithm_unsigned()가 컴파일에 포함된다.

algorithm("hello"); // T 가 string, 빌드 에러!!

타입 변경하기

type traits는 타입을 변환하는데도 사용된다. 이 마법 같은 일의 기본적인 사용법은 C++ 스탠다드 라이브러리의 std::move(타입 T를 rvalue 레퍼런스 타입인 T&&로 변경해주는 유틸리티 함수)로 부터 시작 된다. 이것은 move semantics의 기본이 되는 아주 중요한 연산이다.

std::move는 &를 인자로 넘어온 타입에서 제거하고 &&가 붙은 깔끔한 T를 리턴하기 위해 내부적으로 std::remove_reference 타입 트레이츠를 사용한다.

template <typename T>
typename remove_reference<T>::type&& move(T&& arg)
{
  return static_cast<typename remove_reference<T>::type&&>(arg);
}

※ rvalue 레퍼런스에 대한 자세한 내용은 [여기]에서 확인 가능하다.

깔끔하게 type traits 사용하기

중요한것은 아니지만, type traits를 사용할 때 매번 ::value, ::type을 사용하는 것은 코드작성을 번거롭게 하거나 코드를 읽기 힘들게 만들기도 한다(순전히 개인적인 취향이므로 존중하도록 하자). C++14부터는 각 type traits에 _v나 _t를 붙여 코드를 단순화 할 수 있다.

std::is_signed<T>::value;     /* ---> */   std::is_signed_v<T>;
std::remove_const<T>::type;   /* ---> */   std::remove_const_t<T>;

마치며..

필자는 최초 C++을 접할 때 type traits를 매우 어렵게 생각했다. 하지만 알고 보면 단순히 구조체에 우리가 찾고자하는 정보가 상수 멤버 변수로 들어있는 구조체일 뿐이다. 그것을 이용하여 우리는 조건부 컴파일을 할 수 있다. 이것이 type traits의 시작이며 끝이라고 봐도 충분하다.

너무 어렵게 생각하지 말도록 하자.

부록 1. 같이 읽으면 좋은 글

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!