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

[C++] Traits(특질) 구현 (1)

by 별준 2023. 1. 18.

References

  • Ch 19, C++ Templates The Complete Guide

Contents

  • Example: Accumulating a Sequence
  • Traits versus Policies and Policy Classes
  • Type Functions

템플릿을 활용하면 다양한 타입으로 클래스와 함수를 파라미터화할 수 있습니다. 템플릿을 타입이나 알고리즘에 맞춰 커스터마이징할 수 있도록 최대한 많은 템플릿 파라미터를 도입하도록 할 수도 있고, 클라이언트 코드의 정확한 니즈를 충족시키도록 템플릿화된 컴포넌트를 인스턴스화시킬 수도 있습니다. 다만, 실질적으로 수십 개의 템플릿 파라미터를 사용하는 것은 그리 좋지는 않습니다.

 

다행히 왠만한 경우, 추가하고 싶은 파라미터의 대부분은 기본값을 가지고 있는 경우가 많습니다. 어떤 경우에서는 추가 파라미터가 완전히 몇몇 주요 파라미터에 의해 결정되기도 하며, 이런 경우에 추가 파라미터들은 모두 생략될 수 있습니다.

 

특질(traits or traits templates)은 템플릿 설계 단계에서 제안된 C++ 프로그래밍 장치로, 이를 사용하여 추가 파라미터들을 쉽게 관리할 수 있습니다. 이번 포스팅에서는 자신만의 견고하고 강력한 장치를 작성하는 다양한 기법들과 이를 유용하게 사용할 수 있는 상황들에 대해서 살펴보도록 하겠습니다. 여기서 알아볼 대부분의 특질은 C++ 표준 라이브러리에서 어떤 형태로든 제공하고 있는데, 세부 사항은 생략하고 간단한 구현만 표현하도록 하겠습니다.

 


Example: Accumulating a Sequence

먼저 수열의 합을 구하는 예제를 살펴보겠습니다.

Fixed Traits

먼저 배열에 저장된 값들의 합을 구하길 원하고, 첫 번째 요소와 마지막 요소를 가리키는 포인터가 있다고 가정해봅시다. 최대한 많은 타입에서 동작하는 템플릿을 작성하길 원하므로, 현 단계에서 직관적으로 아래와 같이 작성할 수 있습니다.

template<typename T>
T accum(T const* beg, T const* end)
{
    T total{};  // assume this actually creates a zero value
    while (beg != end) {
        totla += *beg;
        ++beg;
    }
    return total;
}

여기서 결정하기 어려운 부분은 덧셈을 할 때 초기값을 어떻게 계산할 것인가 입니다. 위 코드에서는 값 초기화(value initialization)을 사용했습니다. 이를 사용하면 지역 객체인 total은 자신의 기본 생성자나 0(포인터면 nullptr, bool이면 false)으로 초기화될 것 입니다.

 

이렇게 구현한 첫 번째 특질 템플릿을 알아보기 위해, accum()을 사용하는 아래의 코드를 살펴봅시다.

#include <iostream>

int main()
{
    // create array of 5 integer values
    int num[] = { 1, 2, 3, 4, 5 };

    // print average value
    std::cout << "the average value of the integer values is "
              << accum(num, num+5) / 5
              << "\n";
    
    // create array of character values
    char name[] = "templates";
    int length = sizeof(name) - 1;

    // (try to) print average character value
    std::cout << "the average value of the characters in \""
              << name << "\" is "
              << accum(name, name+length) / length
              << "\n";
}

위 코드에서 처음에는 다섯 개의 정수 값을 더하기 위해 accum()을 사용했습니다. 이렇게 구한 합을 배열의 정수 갯수로 나누면 평균을 쉽게 얻을 수 있습니다.

 

그 뒤, templates라는 단어의 모든 문자를 더하고 평균을 구합니다. 펑균은 a(97)와 z(122)의 값 사이에 있을 것이라고 예상할 수 있지만 실행했을 때의 결과값은 다음과 같습니다.

위 템플릿에서는 char 타입으로 인스턴스화되었기 때문에 값의 범위가 좁다는 것이 문제입니다. 이는 변수 total에 대해 사용할 타입(및 리턴 타입)을 알려주는 부가적인 템플릿 파라미터를 도입하면 해결할 수 있습니다. 하지만 이 템플릿을 사용하면 사용자가 템플릿을 사용할 때마다 추가로 타입을 더 명시해야 합니다. 따라서 이를 추가하게 되면 아래와 같이 호출해야될 수 있습니다.

accum<int>(name, name+length);

그리 어려운 것은 아니지만 인자는 적으면 적을수록 좋습니다.

 

추가 파라미터에 대한 다른 접근 방법으로는 accum()이 호출되는 각 타입 T와 합을 저장하는 타입 사이의 관계를 생성하는 것이 있습니다. 이 연관 관계는 타입 T의 특성으로 볼 수 있는데, 따라서, 합이 계산되는 타입을 T의 특질(trait)라고 부르기도 합니다. 이러한 관계는 템플릿의 특수화를 통해 표현할 수 있습니다.

template<typename T>
struct AccumulationTraits;

template<>
struct AccumulationTraits<char> {
    using AccT = int;
};

template<>
struct AccumulationTraits<short> {
    using AccT = int;
};

template<>
struct AccumulationTraits<int> {
    using AccT = long;
};

template<>
struct AccumulationTraits<unsigned int> {
    using AccT = unsigned long;
};

template<>
struct AccumulationTraits<float> {
    using AccT = double;
};

AccumulationTratis 템플릿은 파라미터 타입에 대한 특질을 가지고 있으므로 특질 템플릿(traits template)라고 부릅니다. 이 템플릿의 일반적인 정의는 제공하지 않는데, 타입이 무엇인지 모를 때는 적절한 타입이 무엇인지 알기 어렵기 때문입니다.

 

accum() 템플릿은 다음과 같이 다시 작성합니다.

template<typename T>
auto accum(T const* beg, T const* end)
{
    // return type is traits of the element type
    using AccT = typename AccumulationTraits<T>::AccT;

    AccT total{}; // assume this actually creates a zero value
    while (beg != end) {
        total += *beg;
        ++beg;
    }
    return total;
}

이렇게 하면 앞서 작성한 메인 함수는 다음과 같이 원하는 대로 출력하게 됩니다.

알고리즘을 타입에 맞출 수 있는 메커니즘을 추가했음에도 바뀌는 것은 많지 않습니다. 또한, 새로운 타입에 대해 accum()을 사용할 때 해당 타입을 위한 AccumulationTratis 템플릿을 추가로 특수화해주면 적절한 AccT를 해당 타입에 연결시킬 수 있습니다.

 

Value Traits

방금까지는 메인 타입에 대한 부가적인 타입 정보를 제공하는 특질에 대해 알아봤습니다. 이번에는 상수나 다른 종류의 값들이 어떻게 타입에 연결될 수 있는지 살펴보겠습니다.

 

앞서 구현한 accum() 템플릿은 반환 값의 기본 생성자를 사용하여 결과 값 변수가 0에 해당하는 값으로 초기화된다고 가정합니다.

AccT total{}; // assume this actually a zero value
...
return total;

하지만 AccT()가 누적 루프를 시작할 좋은 값을 생성한다는 보장은 없습니다. 또한, AccT의 기본 생성자가 없을 수도 있습니다.

 

이때, 특질을 사용하면 해결할 수 있습니다. 아래 코드에서는 위에서 정의한 AccumulationTraits에 대해 새로운 값 특질을 추가합니다.

template<typename T>
struct AccumulationTraits;

template<>
struct AccumulationTraits<char> {
    using AccT = int;
    static AccT const zero = 0;
};

template<>
struct AccumulationTraits<short> {
    using AccT = int;
    static AccT const zero = 0;
};

template<>
struct AccumulationTraits<int> {
    using AccT = long;
    static AccT const zero = 0;
};

이 특질은 컴파일 타입에 계산되는 상수인 zero라는 요소를 제공하게 됩니다. 이에 따라 accum()은 다음과 같이 작성합니다.

template<typename T>
auto accum(T const* beg, T const* end)
{
    // return type is traits of the element type
    using AccT = typename AccumulationTraits<T>::AccT;

    AccT total = AccumulationTraits<T>::zero; // init total by trait value
    while (beg != end) {
        total += *beg;
        ++beg;
    }
    return total;
}

이 코드에서 합을 저장하는 변수를 초기화하는 방법은 매우 직관적입니다.

using AccT = typename AccumulationTraits<T>::AccT;

하지만 위와 같은 방식의 AccumulationTratis는 C++ 클래스 내의 정적 상수 데이터 멤버가 정수형이거나 열거형일 때에만 초기화할 수 있다는 단점이 있습니다. constexpr 정적 데이터 멤버는 약간 더 일반적이여서 부동소수점 타입이나 다른 리터럴 타입에 대해서도 허용됩니다.

template<>
struct AccumulationTraits<float> {
    using AccT = double;
    static AccT constexpr zero = 0.0;
};

하지만 리터럴이 아닌 타입은 const나 constexpr로도 초기화할 수 없습니다. 예를 들어, 아래와 같은 사용자 정의 타입은 리터럴이 아니며, 생성자가 constexpr이 아니라는 것 만으로도 리터럴 타입이 될 수 없습니다. 따라서 아래의 코드는 에러가 발생합니다.

class BigInt {
public:
    BigInt(long long);
    ...
};
...
template<>
struct AccumulationTratis<BigInt> {
    using AccT = BigInt;
    static constexpr BigInt zero = BigInt{0}; // ERROR: not a literal type
};

이와 같은 경우, 클래스 내 value trait를 선언만 하고 정의하지 않는 방법을 사용하면 가능하긴 합니다.

template<>
struct AccumulationTratis<BigInt> {
    using AccT = BigInt;
    static BigInt const zero; // declaration only
};

그리고 소스 코드 내에서 아래와 같이 초기화를 포함시킵니다.

BigInt const AccumulationTratis<BigInt>::zero = BigInt{0};

위 방식을 사용하면 동작하긴 합니다만, 코드가 장황해지며 잠재적으로 덜 효율적일 수 있습니다.

 

C++17에서는 위 문제를 변수를 인라인화하여 해결할 수 있습니다.

template<>
struct AccumulationTratis<BigInt> {
    using AccT = BigInt;
    inline static BigInt const zero = BigInt{0}; // OK since C++17
};

 

C++17 이전 버전에서도 동작하도록 하고 싶다면, 정수가 아닐 수 있는 value trait에 대해서는 인라인 멤버 함수를 사용하여 구현하는 편이 좋습니다. 멤버 함수가 리터럴 타입을 반환한다면 contexpr로 선언할 수 있습니다. 예를 들어, 아래와 같이 AccumulationTraits를 작성할 수 있습니다.

template<typename T>
struct AccumulationTraits;

template<>
struct AccumulationTraits<char> {
    using AccT = int;
    static constexpr AccT zero() {
        return 0;
    }
};

template<>
struct AccumulationTraits<short> {
    using AccT = int;
    static constexpr AccT zero() {
        return 0;
    }
};

template<>
struct AccumulationTraits<int> {
    using AccT = long;
    static constexpr AccT zero() {
        return 0;
    }
};

template<>
struct AccumulationTraits<unsigned int> {
    using AccT = unsigned long;
    static constexpr AccT zero() {
        return 0;
    }
};

template<>
struct AccumulationTraits<float> {
    using AccT = double;
    static constexpr AccT zero() {
        return 0;
    }
};

그리고 BigInt를 위해 아래와 같이 확장할 수 있습니다.

template<>
struct AccumulationTraits<BigInt> {
    using AccT = BigInt;
    static BigInt zero() {
        return BigInt{0};
    }
};

그러면 어플리케이션 코드에서는 단순히 멤버 변수 사용을 함수 호출 문법으로 변경해주기만 하면 됩니다.

AccT total = AccumulationTraits<T>::zero();

 

Parameterized Traits

위에서 구현한 accum()에서 특질의 사용은 'fixed'라고 부릅니다. 특질이 고정되어 있기 때문에 알고리즘 내에서 변경할 수 없습니다. 하지만 오버라이딩을 원하는 경우가 있을 수 있습니다. 예를 들어, float 값은 변수와 같은 타입에 안전하게 더할 수 있으므로 그렇게 하는 것이 조금 더 효율적입니다.

 

이는 특질 템플릿에 따라 기본값이 결정되는 특질을 위한 템플릿 파라미터 AT를 추가하여 이 문제를 해결할 수 있습니다.

template<typename T, typename AT = AccumulationTraits<T>>
auto accum(T const* beg, T const* end)
{
    typename AT::AccT total = AT::zero();
    while (beg != end) {
        total += *beg;
        ++beg;
    }
    return total;
}

위와 같이 구현하면 대부분의 경우 추가 템플릿 인자를 생략할 수 있으며, 예외적인 상황에서 값의 타입을 미리 정하도록 명시할 수 있습니다.

 


Traits versus  Policies and Policy Classes

위의 예제에서는 accumulation을 summation이라는 의미로 사용했습니다. 하지만 주어진 값들의 곱이나 문자열을 연결하는 등의 다른 의미로 사용할 수도 있습니다. 또는, 수열에서 가장 큰 값을 찾는 것도 accumulation 문제로 볼 수 있습니다. 이런 경우, accum() 연산에서 변경되어야 할 부분은 사실 '+= *beg'일 뿐입니다. 이 연산은 축적 과정의 정책(policy)이라고 합니다.

 

아래 예제 코드는 accum() 함수 템플릿에서 어떻게 이러한 정책을 추가하는지 보여줍니다.

template<typename T,
         typename Policy = SumPolicy,
         typename Traits = AccumulationTraits<T>>
auto accum(T const* beg, T const* end)
{
    using AccT = typename Traits::AccT;
    AccT total = Traits::zero();
    while (beg != end) {
        Policy::accumulation(total, *beg);
        ++beg;
    }
    return total;
}

위의 구현에서 SumPolicy는 정책 클래스(policy class)이며, 합의된 인터페이스를 통해 알고리즘을 위한 하나 또는 그 이상의 정책을 구현합니다. SumPolicy의 구현은 다음과 같습니다.

class SumPolicy {
public:
    template<typename T1, typename T2>
    static void accumulate(T1& total, T2 const& value) {
        total += value;
    }
};

 

값을 축적하는 다른 방법으로 곱셈 정책을 추가할 수도 있습니다. 예를 들어, 값들의 곱을 구하는 코드는 아래와 같이 구현할 수 있습니다.

class MultiPolicy {
public:
    template<typename T1, typename T2>
    static void accumulate(T1& total, T2 const& value) {
        total *= value;
    }
};

int main()
{
    // create array of 5 integer values
    int num[] = { 1, 2, 3, 4, 5 };

    // print product of all values
    std::cout << "the product of the integer values is "
              << accum<int, MultiPolicy>(num, num+5)
              << "\n";
}

하지만 아마 위 프로그램의 출력은 다음과 같을 것 입니다.

여기서 문제는 초깃값인데, 덧셈에서는 0이 초기값이지만 곱셈에서는 0이면 안됩니다. 이처럼 특질(traits)과 정책(policies)은 서로 상호작용하여 동작하며, 따라서 템플릿의 설계가 매우 중요합니다.

 

축적 계산 루프의 초기화를 축적 정책의 일부라고 생각할 수 있습니다. 이러한 정책에서는 zero() 특질을 사용할 수도 있고 사용하지 않을 수도 있습니다. 다른 대안도 있다는 것을 항상 염두해야 하며, 모든 것을 특질과 정책으로만 해결해야 하는 것은 아닙니다. 예를 들어, C++ 표준 라이브러리의 accumulate() 함수는 세 번째 인자를 통해 초깃값을 전달 받습니다.

 

Traits and Policies

정책(policies)는 특질(traits)의 특별한 케이스라고 볼 수 있고, 역으로 특질은 단순히 정책을 인코딩한다고 표현할 수 있습니다. 일반적으로 정책은 특질과 유사하지만 타입보다는 동작에 조금 더 초점을 맞춘다고 합니다. 단, 두 용어 사이의 경계선은 뚜렷하지 않으며, C++ 표준 라이브러리의 문자 특질의 경우에는 비교, 이동, 찾기 등의 기능적인 동작도 정의하고 있습니다.

 

Member Templates versus Template Template Parameters

위의 accum() 예제에서 정책을 구현할 때 SumPolicy와 MultiPolicy를 멤버 템플릿을 가지는 일반 클래스로 구현했습니다. 다른 방법으로 클래스 템플릿을 사용하여 policy 클래스 인터페이스를 설계할 때 템플릿 템플릿 인자를 활용할 수도 있습니다. 예를 들어, SumPolicy를 다음과 같이 작성할 수도 있습니다.

template<typename T1, typename T2>
class SumPolicy {
public:
    static void accumulate(T1& total, T2 const& value) {
        total += value;
    }
};

그리고 accum()의 인터페이스도 템플릿 템플릿 파라미터를 사용하도록 변경합니다.

template<typename T,
         template<typename,typename> class Policy = SumPolicy,
         typename Traits = AccumulationTraits<T>>
auto accum(T const* beg, T const* end)
{
    using AccT = typename Traits::AccT;
    AccT total = Traits::zero();
    while (beg != end) {
        Policy<AccT,T>::accumulate(total, *beg);
        ++beg;
    }
    return total;
}

특질 파라미터에도 동일한 방법이 적용될 수도 있습니다.

 

정책 클래스를 템플릿 템플릿 파라미터로 액세스하면 템플릿 파라미터에 종속된 타입을 갖는 상태 정보를 정책 클래스 내에 가질 수 있다는 장점이 있습니다. 하지만 이 방법은 정책 클래스가 템플릿이기 때문에 인터페이스에 따라 정확한 파라미터를 지정해주어야 한다는 단점이 있습니다. 따라서 코드 작성이 지저분해질 수 있습니다.

 

Combining Multiple Policies and/or Traits

지금까지 특질과 정책을 도입했지만, 여러 템플릿 파라미터를 사용해야 했습니다. 하지만, 파라미터의 수를 사용자가 충분히 관리할 수 있도록 감소시키는 것이 가능합니다. 이는 여러 파라미터들을 어떤 방식으로 나열하는가에 달려있을 수 있습니다.

 

간단한 방법으로는 기본값이 선택될 확률에 따라 파라미터를 정렬할 수 있습니다. 일반적으로 특질 파라미터는 정책 파라미터 뒤에 따라오는데, 정책 파라미터가 사용자 코드에서 조금 더 자주 덮어써지기 때문입니다.

 

Accumulation with General Iterators

C++ 표준 라이브러리에서는 iterator를 위한 특질(std::iterator_traits)을 제공하기 때문에 accum()을 포인터로도 호출할 수 있습니다. 따라서, 다음과 같이 구현하면 일반화된 이터레이터를 사용할 수 있습니다.

template<typename Iter>
auto accum(Iter start, Iter end)
{
    using VT = typename std::iterator_traits<Iter>::value_type;

    VT total{}; // assume this actually creates a zero value
    while (start != end) {
        total += *start;
        ++start;
    }
    return total;
}

 

참고로 std::iterator_traits는 이터레이터 관련 속성들을 모두 캡슐화합니다. 또한 포인터를 위한 부분 특수화도 존재하므로 이러한 특질은 일반 포인터에서도 쉽게 사용될 수 있습니다. 포인터 타입에 대한 표준 라이브러리의 구현은 다음과 같습니다.

namespace std {
  template<typename T>
  struct iterator_traits<T*> {
    using difference_type   = ptrdiff_t;
    using value_type        = T;
    using pointer           = T*;
    using reference         = T&;
    using iterator_category = random_access_iterator_tag;
  };
}

 


Type Functions

지금까지는 특질을 사용하여 타입에 따라서 동작을 정의하는 방법을 알아봤습니다. 전통적으로 C와 C++에서 일반적으로 정의하는 함수는 정확히 말하자면 값 함수(value function)입니다. 이들은 특정 값을 인자로 받아서 다른 값을 결과로 반환합니다. 하지만 템플릿을 사용하게 되면 타입 함수(type function)을 정의할 수 있는데, 타입 함수는 타입 인자를 받아서 타입이나 상수를 결과로 생성합니다.

 

내장된 함수 중 가장 유용한 타입 함수에는 sizeof 가 있습니다. 이 함수는 주어진 타입 인자의 바이트 크기를 나타내는 상수 값을 반환합니다. 클래스 템플릿을 이러한 타입 함수로 사용할 수 있습니다. 템플릿 파라미터는 이러한 타입 함수의 파라미터가 되고, 그 결과는 멤버 타입이나 멤버 상수로부터 얻을 수 있습니다. 예를 들어, sizeof 연산을 사용하여 다음과 같은 인터페이스를 구현될 수 있습니다.

#include <cstddef>
#include <iostream>

template<typename T>
struct TypeSize {
    static std::size_t const value = sizeof(T);
};

int main()
{
    std::cout << "TypeSize<int>::value = "
            << TypeSize<int>::value << "\n";
}

사실 sizeof를 그냥 그대로 사용하면 되기 때문에 위와 같은 코드는 별로 유용한 것은 아닙니다. 여기서는 TypeSize<T>가 타입이므로 클래스 템플릿 인자로 전달될 수 있다는 점을 알아두면 좋습니다.

 

Element Types

배열뿐만 아니라 std::vector<>와 std::list<>와 같은 다양한 컨테이너 템플릿이 있다고 가정해봅시다. 여기서 우리는 컨테이너 타입이 주어졌을 때, 요소의 타입을 알려주는 타입 함수가 필요할 수 있습니다. 이러한 타입 함수는 부분 특수화를 통해 구현할 수 있습니다.

#include <vector>
#include <list>

template<typename T>
struct ElementT;    // primary template

template<typename T>
struct ElementT<std::vector<T>> {   // partial specialization for std::vector
    using Type = T;
};

template<typename T>
struct ElementT<std::list<T>> { // partial specialization for std::list
    using Type = T;
};

template<typename T, std::size_t N>
struct ElementT<T[N]> { // partial specialization for arrays of known bounds
    using Type = T;
};

template<typename T>
struct ElementT<T[]> { // partial specialization for arrays of unknown bounds
    using Type = T;
};

모든 가능한 배열 타입에 대해서 부분 특수화를 제공해야 합니다.

이렇게 구현한 타입 함수는 다음과 같이 사용할 수 있습니다.

#include <vector>
#include <iostream>
#include <typeinfo>

template<typename T>
void printElementType(T const& c)
{
    std::cout << "Container of "
            << typeid(typename ElementT<T>::Type).name()
            << " elements.\n";
}

int main()
{
    std::vector<bool> s;
    printElementType(s);
    int arr[42];
    printElementType(arr);
}

 

일반적으로 적용되는 타입과 타입 함수를 함께 설계하고, 타입 함수를 간단하게 구현합니다. 예를 들어, 표준 컨테이너와 같이 컨테이너 타입이 value_type이라는 멤버 타입을 정의한다면 다음과 같이 타입 함수를 구현할 수 있습니다.

template<typename C>
struct ElementT {
    using Type = typename C::value_type;
};

이는 기본 구현이 될 수도 있고, 멤버 타입으로 value_type이 정의되어 있지 않는 컨테이너 타입에 대해서도 특수화될 수 있습니다.

그럼에도 클래스 템플릿 타입 파라미터에 대한 멤버 타입 정의를 제공하는 것을 권하며, 그래야 일반 코드에서 조금 더 쉽게 접근할 수 있습니다. 이 아이디어를 코드로 표현하면 다음과 같습니다.

template<typename T1, typename T2>
class X {
public:
    using ... = T1;
    using ... = T2;
    ...
};

이러한 클래스 템플릿은 어떻게 유용하게 사용할 수 있을까요?

타입 함수는 템플릿을 컨테이너 타입에 따라 파라미터화할 수 있도록 하면서, 요소의 타입을 위한 파라미터나 다른 특징들을 요구하지 않습니다. 예를 들어, 컨테이너 내의 요소의 합을 구하는데 요소의 타입을 명시하도록 아래 구현의 sumOfElements<int>(list) 같은 방식 대신

template<typename T, typename C>
T sumOfElements(C const& c);

다음과 같이 작성할 수 있습니다.

template<typename C>
typename ElementT<C>::Type sumOfElements(C const& c);

위 코드에서 요소의 타입은 타입 함수로 결정합니다.

 

특질은 존재하는 타입을 확장하여 구현할 수 있습니다. 따라서 기본 타입이나 닫혀있는 타입에 대해서도 타입 함수를 정의할 수 있습니다.

바로 전에 본 코드에서 ElementT를 특질 클래스(traits class)라고 하는데, 주어진 컨테이너 타입 C의 특질에 접근하기 때문입니다. 즉, 특질 클래스는 컨테이너의 파라미터뿐만 아니라 어떠한 종류의 'main parameters'의 특성을 설명하는 데도 사용됩니다.

 

편하게 사용하려면 타입 함수에 대해 별칭 템플릿을 만들 수도 있습니다.

template<typename T>
using ElementType = typename ElementT<T>::Type;

 

Transformation Traits

특질을 통해 메인 파라미터 타입에 대한 정보를 제공하는 것 외에도 타입을 변환하는 작업도 특질을 통해 수행할 수 있습니다. 예를 들어, 참조자를 추가하거나 제거하거나, const와 volatile 한정자를 추가하거나 제거할 수 있습니다.

 

Removing References

예를 들어, 참조자 타입을 실제 객체나 함수 타입으로 바꾸고, 참조자가 아닌 타입은 그대로 두는 RemoveReferenceT 특질을 구현해보겠습니다.

template<typename T>
struct RemoveReferenceT {
    using Type = T;
};

template<typename T>
struct RemoveReferenceT<T&> {
    using Type = T;
};

template<typename T>
struct RemoveReferenceT<T&&> {
    using Type = T;
};

표준 라이브러리에서 이 기능을 제공하는 std::remove_reference<>가 있습니다. 구현은 아래와 같습니다.

 

Adding References

위와 유사한 방식으로 현재 타입에서 lvalue나 rvalue 참조자를 만들 수 있습니다.

template<typename T>
struct AddLValueReferenceT {
    using Type = T&;
};

template<typename T>
struct AddRValueReferenceT {
    using Type = T&&;
};

위 코드에서는 reference collapsing이 적용됩니다. 예를 들어, AddLValueReference<int&&>가 호출되면 int& 타입을 생성합니다. 따라서, T&&에 대해 부분 특수화를 구현할 필요가 없습니다.

 

AddLValueReferenceT와 AddRValueReferenceT를 별칭 ㅌ메플릿으로 간단하게 만들 수 있습니다.

template<typename T>
using AddLValueReferenceT = T&;

template<typename T>
using AddRValueReferenceT = T&&;

따라서, 클래스 템플릿을 인스턴스화하지 않고 인스턴스화될 수 있습니다. 하지만 특정 케이스에서 이 템플릿을 특수화하고 싶을 수 있는데, 이는 위험합니다. 예를 들어, 이 템플릿에 void를 템플릿 인자로 사용할 수 없습니다. void를 인자로 받고 싶다면 다음과 같이 명시적 특수화가 필요합니다 (AddRValueReferenceT는 생략).

template<>
struct AddLValueReferenceT<void> {
    using Type = void;
};

template<>
struct AddLValueReferenceT<void const> {
    using Type = void;
};

template<>
struct AddLValueReferenceT<void volatile> {
    using Type = void;
};

template<>
struct AddLValueReferenceT<void const volatile> {
    using Type = void;
};

C++ 표준 라이브러리에는 위에 해당하는 타입 특질로 std::add_lvalue_reference<>와 std::add_rvalue_reference<>를 제공합니다.

 

Remove Qualifiers

유사하게 참조자뿐만 아니라 모든 종류의 조합된 타입을 쪼개거나 만들어낼 수 있습니다. 아래 예제 코드 참고바랍니다.

template<typename T>
struct RemoveConstT {
    using Type = T;
};

template<typename T>
struct RemoveConstT<T const> {
    using Type = T;
};

template<typename T>
using RemoveConst = typename RemoveConstT<T>::Type;

동일한 방식으로 RemoveVolatileT도 구현할 수 있습니다.

template<typename T>
struct RemoveVolatileT {
    using Type = T;
};

template<typename T>
struct RemoveVolatileT<T volatile> {
    using Type = T;
};

template<typename T>
using RemoveVolatile = typename RemoveVolatileT<T>::Type;

 

덧붙여 변환 특질을 결합할 수 있는데, 예를 들어, const와 volatile을 모두 제거하는 RemoveCVT 특질을 만들 수도 있습니다.

template<typename T>
struct RemoveCVT : RemoveConstT<typename RemoveVolatileT<T>::Type> {
};

template<typename T>
using RemoveCV = typename RemoveCVT<T>::Type;

 

C++ 표준 라이브러리에는 이 동작을 위한 std::remove_volatile<>, std::remove_const<>, std::remove_cv<>를 제공합니다.

 

Decay (형 소실)

마지막 변환 특질로, 인자를 값으로 전달했을 때 발생하는 타입 변환을 흉내내는 특질을 구현해보겠습니다. C로부터 유래된 형 소실에서는 인자로 전달된 배열 타입을 포인터 타입으로, 함수 타입은 함수에 대한 포인터로 바뀌며, top-level 타입의 const, volatile, 참조자 한정자는 제거됩니다.

 

값으로 전달의 효과는 아래 프로그램에서 살펴볼 수 있습니다. 아래 예제 코드는 컴파일러가 특정 타입에 대해 형 소실시킨 후, 생성된 파라미터 타입을 출력합니다.

#include <iostream>
#include <typeinfo>
#include <type_traits>

template<typename T>
void f(T)
{}

template<typename A>
void printParameterType(void (*)(A))
{
    std::cout << "Parameter type: " << typeid(A).name() << "\n";
    std::cout << "- is int:       " << std::is_same<A,int>::value << "\n";
    std::cout << "- is const:     " << std::is_const<A>::value << "\n";
    std::cout << "- is pointer:   " << std::is_pointer<A>::value << "\n";
}

int main()
{
    printParameterType(&f<int>);
    printParameterType(&f<int const>);
    printParameterType(&f<int[7]>);
    printParameterType(&f<int(int)>);
}

위 코드의 출력은 다음과 같습니다.

프로그램의 출력을 살펴보면, int는 변하지 않지만, int const, int[7], int(int)는 형 소실되어 각각 int, int*, int(*)(int)로 바뀝니다.

 

이제 이렇게 값으로 인자를 전달했을 때 발생하는 형 변환과 같은 동작을 수행하는 특질을 구현해보겠습니다. C++ 표준 라이브러리에는 이와 같은 동작의 특질인 std::decay<>를 제공하는데, 이 이름에 맞춰 DecayT로 구현합니다. 여기서는 방금 위에서 살펴본 여러 구현들을 섞어서 사용합니다.

먼저 배열도 아니고 함수도 아닌 경우 경우에 간단히 const와 volatile 한정자를 제거합니다.

template<typename T>
struct DecayT : RemoveCVT<T> {
};

다음으로 배열에서 포인터로의 변환을 처리하는데, 부분 특수화를 사용하여 배열 타입인지 알아냅니다 (크기를 모르는 경우도 커버).

template<typename T>
struct DecayT<T[]> {
    using Type = T*;
};

template<typename T, std::size_t N>
struct DecayT<T[N]> {
    using Type = T*;
};

마지막으로 함수에서 포인터로의 형 소실을 처리합니다. 이때, 함수의 반환형이나 파라미터의 수와 관계없이 모든 함수형에 일치해야 하므로 가변 템플릿을 사용합니다.

template<typename R, typename... Args>
struct DecayT<R(Args...)> {
    using Type = R(*)(Args...);
};

template<typename R, typename... Args>
struct DecayT<R(Args..., ...)> {
    using Type = R(*)(Args..., ...);
};

위 코드에서 두 번째 부분 특수화는 C의 가변 인자(vararg)를 갖는 함수를 위한 것 입니다. 이렇게 구현한 DecayT 템플릿을 가지고 아래와 같이 파라미터 타입의 형 소실을 구현할 수 있습니다.

template<typename T>
void printParameterType()
{
    using A = typename DecayT<T>::Type;
    std::cout << "Parameter type: " << typeid(A).name() << "\n";
    std::cout << "- is int:       " << std::is_same<A,int>::value << "\n";
    std::cout << "- is const:     " << std::is_const<A>::value << "\n";
    std::cout << "- is pointer:   " << std::is_pointer<A>::value << "\n";
}

int main()
{
    printParameterType<int>(&f<int>);
    printParameterType<int const>();
    printParameterType<int[7]>();
    printParameterType<int(int)>();
}

앞서 본 결과가 동일한 결과를 출력하는 것을 확인할 수 있습니다.

 

Predicate Traits

방금 전까지 타입 하나에 대한 타입 함수를 구현했습니다. 여기서는 타입이 하나 주어지면 이와 관련된 타입이나 상수를 제공하는 방식을 사용합니다. 하지만, 일반화시켜서 여러 인자에 종속된 타입 함수도 만들 수 있습니다. 이러한 형태의 타입 함수를 조건자 특질(predicate traits)이라고 합니다.

 

IsSameT

IsSameT 특질을 사용하여 두 타입이 동일한지 확인할 수 있습니다.

template<typename T1, typename T2>
struct IsSameT {
    static constexpr bool value = false;
};

template<typename T>
struct IsSameT<T, T> {
    static constexpr bool value = true;  
};

위 구현에서 기본 템플릿은 서로 다른 템플릿 인자가 전달되었을 때를 정의하는데, 이때 value의 값은 false 입니다. 하지만 부분 특수화를 사용하여 전달받은 두 타입이 동일하면 value는 true가 됩니다.

 

상수값을 생성하는 특질에 대해서는 별칭 템플릿으로 제공할 수는 없지만, 같은 역할을 하는 constexpr 변수 템플릿은 제공할 수 있습니다.

template<typename T1, typename T2>
constexpr bool isSame = IsSameT<T1, T2>::value;

C++ 표준 라이브러리에서는 이와 동일한 동작을 수행하는 std::is_same<>을 제공합니다.

 

true_type and false_type

IsSameT의 가능한 결과인 true와 false에 대해 서로 다른 타입을 제공하도록 IsSameT의 정의를 개선할 수 있습니다. 만약 TrueType과 FalseType으로 인스턴스화되는 BoolConstant 클래스 템플릿을 다음과 같이 선언한다면,

template<bool val>
struct BoolConstant {
    using Type = BoolConstant<val>;
    static constexpr bool value = val;
};
using TrueType = BoolConstant<true>;
using FalseType = BoolConstant<false>;

IsSameT에서 두 타입이 일치하는지 여부에 따라 TrueType 또는 FalseType 중 하나를 상속받도록 정의할 수 있습니다.

template<typename T1, typename T2>
struct IsSameT : FalseType
{};

template<typename T>
struct IsSameT<T, T> : TrueType
{};

그럼 이제 아래 코드에서

IsSameT<T, int>

결과 타입은 기본 클래스인 TrueType이나 FalseType 중 하나로 변환되어 올바른 값을 value 멤버로 제공하며, 컴파일 과정에 다른 함수 구현이나 부분 클래스 템플릿 특수화를 사용할 수도 있습니다. 아래 예제 코드를 살펴보도록 하겠습니다.

#include <iostream>

template<bool val>
struct BoolConstant {
    using Type = BoolConstant<val>;
    static constexpr bool value = val;
};
using TrueType = BoolConstant<true>;
using FalseType = BoolConstant<false>;

template<typename T1, typename T2>
struct IsSameT : FalseType
{};

template<typename T>
struct IsSameT<T, T> : TrueType
{};

template<typename T>
void fooImpl(T, TrueType)
{
    std::cout << "fooImpl(T, true) for int called\n";
}

template<typename T>
void fooImpl(T, FalseType)
{
    std::cout << "fooImpl(T, false) for other type called\n";
}

template<typename T>
void foo(T t)
{
    fooImpl(t, IsSameT<T, int>{}); // choose impl. depending on whether T is int
}

int main()
{
    foo(42);    // calls fooImpl(42, TrueType)
    foo(7.7);   // calls fooImpl(7.7, FalseType)
}

참고로 이런 기법을 tag dispatching이라고 부릅니다.

 

이렇게 구현한 BoolConstant에 Type이라는 멤버가 있기 때문에 IsSameT에 대해 별칭 템플릿을 제공할 수 있습니다.

template<typename T>
using IsSame = typename IsSameT<T>::Type;

 

일반적으로 불리언 값을 도출하는 특질을 그 타입을 TrueType과 FalseType 같은 타입에서 상속받아 tag dispatching을 지원할 수 있습니다. 하지만 최대한 제너럴한 코드가 되려면 각 라이브러리마다 불리언 상수에 대해 자신만의 타입을 정의하는 것이 아닌 true와 false를 표현하는 타입이 딱 하나씩 있는게 좋습니다.

 

다행히 C++ 표준 라이브러리에서는 C++11부터 <type_traits>에 std::true_type과 std::false_type이라는 타입을 제공합니다.

참고로 C++11과 C++14에서는 아래와 같이 정의되어 있고,

C++17부터는 아래와 같이 정의되어 있습니다.

 

Result Type Traits

여러 타입들을 다루는 다른 타입 함수로는 결과형 특질(result type traits)가 있습니다. 이는 연산자 템플릿을 만들 때 특히 유용합니다. 어떤 점에서 유용한지 살펴보기 위해 두 개의 Array 컨테이너를 서로 더하는 함수 템플릿을 구현해보겠습니다.

template<typename T>
Array<T> operator+(Array<T> const&, Array<T> const&);

위와 같은 구현도 괜찮지만, C++ 내에서 char 값과 int 값은 서로 더할 수 있으므로 배열의 요소 타입이 약간 다르더라도 더할 수 있도록 해주는 것이 좋습니다. 문제는 결과 템플릿의 반환형이 무엇이 되어야 하느냐는 점 입니다.

template<typename T1, typename T2>
Array<???> operator+(Array<T1> const&, Array<T2> const&);

여기서 바로 '???'에 들어갈 타입이 무엇이냐 인데, 여기서 결과형 템플릿을 사용하면 이 부분을 채울 수 있습니다.

template<typename T1, typename T2>
Array<typename PlusResultT<T1, T2>::Type>
operator+(Array<T1> const&, Array<T2> const&);

여기서 사용하는 PlusResultT 특질은 두 타입의 값을 + 연산자로 더했을 때 만들어지는 타입을 결정합니다.

template<typename T1, typename T2>
struct PlusResultT {
    using Type = decltype(T1() + T2());
};

template<typename T1, typename T2>
using PlusResult = typename PlugResultT<T1, T2>::Type;

이 특질 템플릿은 T1() + T2()라는 표현식의 타입을 알아내기 위해 decltype을 사용합니다. 따라서 결정하기 어려운 결과 타입을 컴파일러가 맡도록 합니다 (타입 승격과 오버로딩된 연산자들을 모두 고려해줍니다).

 

하지만 예제 코드의 목적(두 Array 컨테이너의 덧셈)에 비해 decltype은 너무 많은 정보를 가지고 있습니다. 예를 들어, PlusResultT를 사용할 때 참조자형이 도출될 수도 있습니다. 물론 대부분의 Array 클래스 템플릿은 참조자형을 처리하도록 구현되어 있지 않을 것입니다. 조금 더 현실적으로 생각해보면 오버로딩된 operator+가 const 클래스 타입을 반환할 수도 있다는 문제점이 있습니다.

class Integer { ... };
Integer const operator+(Integer const&, Integer const&);

두 Array<Integer> 값을 더하면 Integer const의 배열이 만들어질텐데, 아마 이것을 원하지는 않을 것입니다. 아마도 우리가 원했던 것은 앞서 살펴본 것과 같이 참조자와 한정자를 제거하는 것입니다.

template<typename T1, typename T2>
Array<RemoveCV<RemoveReference<PlusResult<T1, T2>>>>
operator+(Array<T1> const&, Array<T2> const&);

위와 같이 특질을 중첩시키는 것은 템플릿 라이브러리에서 흔하며, 메타프로그래밍을 할 때 흔히 사용됩니다. 참고로, 다중 중첩 템플릿을 사용할 때 별칭 템플릿을 사용하면 코드를 조금 더 깔끔하게 작성할 수 있습니다 (::Type과 같은 멤버에 접근하기 위한 코드를 작성하지 않아도 됨).

 

이렇게 구현하면 이제 Array의 + 연산자가 서로 다를 수 있는 요소 타입의 두 배열을 더한 후, 사용할 결과 타입을 계산할 수 있습니다. 하지만 PlusResultT를 사용하기 때문에 요소 타입 T1과 T2에 원치 않는 제약 조건이 발생합니다. 즉, T1() + T2()는 T1과 T2 타입에 대해 갑 초기화를 시도하므로 두 타입 모두 접근 가능해야 하고/삭제 되지 않고/기본 생성자를 가져야 합니다. Array 클래스 자체는 요소 타입의 값 초기화가 필요하지 않기 때문에 불필요한 제약 조건입니다.

 

declval

다행히 주어진 타입 T의 값을 생성하는 함수를 사용하면 생성자 없이 + 표현식의 값을 쉽게 생성할 수 있습니다. 이를 위해 C++ 표준에서는 std::declval<>을 제공하고 있습니다. 이는 <utility>에 정의되어 있습니다.

 

declval<T>()는 기본 생성자를 요구하지 않고 오로시 타입 T의 값만을 생성합니다.

 

이 함수 템플릿은 의도적으로 정의되지 않은 채로 남겨져 있는데, 이는 오직 decltype, sizeof나 이외에 어떠한 정의도 필요하지 않은 문맥에서만 사용되는 것이 목적이기 때문입니다. 이 함수는 이외에도 흥미로운 속성이 두 가지 있습니다.

  • 참조 가능한 타입인 경우, 반환형은 항상 그 타입에 대한 rvalue 참조자입니다. 따라서 추상 클래스 타입이나 배열 타입과 같이 함수에서는 일반적으로 반환될 수 없는 타입도 declval에서 다룰 수 있습니다. T에서 T&&으로의 변환은 declval<T>()의 동작에 어떠한 실질적인 영향도 미치지 못합니다.
  • noexecpt 예외 명세가 있으므로 declval 자체가 예외를 던질 수 있다고 간주되는 표현식을 만들지 않습니다. noexcept 연산자의 문맥 내에 declval을 사용할 때 더 유용합니다.

이를 고려하여 PlusResultT를 다시 구현하면 불필요한 값 초기화를 제거할 수 있습니다.

#include <utility>

template<typename T1, typename T2>
struct PlusResultT {
    using Type = decltype(std::declval<T1>() + std::declval<T2>());
};

template<typename T1, typename T2>
using PlusResult = typename PlusResultT<T1, T2>::Type;

 

 

 

댓글