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

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

by 별준 2023. 11. 24.

References

  • Ch 19, C++ Templates The Complete Guide

Contents

  • If-Then-Else
  • Detecting Nonthrowing Operations
  • Traits Convenience
  • Type Classification
  • Policy Traits

 

If-Then-Else

아래와 같이 정의된 PlusResultT trait는 HasPlusT 라는 다른 traits에 따른 완전히 다른 구현을 가지고 있다.

template<typename, typename, typename = std::void_t<>>
struct HasPlusT : std::false_type {};
template<typename T1, typename T2>
struct HasPlusT<T1, T2, std::void_t<decltype(std::declval<T1>() + std::declval<T2>())>>
: std::true_type {};

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

이를 통해 두 가지 타입 파라미터 중 하나를 선택하는 if-then-else 동작을 공식화할 수 있다. 이렇게 구현된 IfThenElse는 두 타입 중 하나를 선택하기 위해 Boolean nontype 템플릿 파라미터를 사용하는 special type 템플릿이다.

// primary template: yield the second argument by default and rely on
//                   a partial specialization to yield the third argument if COND is false
template<bool COND, typename TrueType, typename FalseType>
struct IfThenElseT {
    using Type = TrueType;
};
// partial specialization: false yields third argument
template<typename TrueType, typename FalseType>
struct IfThenElseT<false, TrueType, FalseType> {
    using Type = FalseType;
};

template<bool COND, typename TrueType, typename FalseType>
using IfThenElse = typename IfThenElseT<COND, TrueType, FalseType>::Type;

이렇게 구현한 IfThenElse는 아래 예제 코드와 같이 주어진 값에 대해 가장 낮은 랭크의 정수 타입을 결정하는 타입 함수를 정의하는데 사용할 수 있다.

template<auto N>
struct SmallestIntT {
    using Type = 
        typename IfThenElseT<N <= std::numeric_limits<char>::max(), char,
            typename IfThenElseT<N <= std::numeric_limits<short>::max() , short,
                typename IfThenElseT<N <= std::numeric_limits<int>::max(), int,
                    typename IfThenElseT<N <= std::numeric_limits<long>::max(), long,
                        typename IfThenElseT<N <= std::numeric_limits<long long>::max(),
                            long long,  // then
                            void        // fallback
                        >::Type
                    >::Type
                >::Type
            >::Type
        >::Type;
};

여기서 일반적인 C++의 if-then-else 구문과 달리 "then"과 "else" branch에 대한 템플릿 인자는 branch가 선택되기 전에 평가된다. 따라서 어느 branch도 ill-formed code가 포함되어 있지 않거나 프로그램이 ill-formed일 가능성이 높다. 예를 들어, 주어진 signed type에 대응하는 unsigned type을 출력하는 traits를 고려해보자. C++의 std::make_unsigned trait는 이 변환을 수행하지만 전달된 타입이 signed integral이면서 bool은 아니어야 한다. 이외의 경우는 undefined behavior이다. 따라서, 가능하다면 전달된 타입에 대응하는 unsigned type을 생성하고 그렇지 않으면 전달된 타입을 그대로 생성하는 traits를 구현하는 것이 좋을 수 있다 (적절하지 않은 타입이 전달되어도 적절하게 동작하도록). 아래와 같은 단순한 구현은 제대로 동작하지 않는다.

// ERROR: undefined behavior if T is bool or no integral type
template<typename T>
struct UnsignedT {
    using Type = IfThenElse<std::is_integral<T>::value && !std::is_same<T, bool>::value,
                            typename std::make_unsigned<T>::type,
                            T>;
};

위 traits를 이용한 UnsignedT<bool>의 인스턴스화는 여전히 undefined behavior이며, 이는 컴파일러가 여전히 아래 코드로부터 타입을 생성하려고 시도하기 때문이다.

typename std::make_unsigned<T>::type

이 문제를 해결하려면 추가적인 간접 참조가 필요하며, IfThenElse 인자는 이들로 래핑하여 전달된다.

// yield T when using member Type
template<typename T>
struct IdentityT {
    using Type = T;
};
// to make unsigned after IfThenElse was evaluated
template<typename T>
struct MakeUnsignedT {
    using Type = typename std::make_unsigned<T>::type;
};

template<typename T>
struct UnsignedT {
    using Type = typename IfThenElse<std::is_integral<T>::value && !std::is_same<T, bool>::value,
                                     MakeUnsignedT<T>,
                                     IdentityT<T>
                                    >::Type;
};

이를 사용하면 UnsignedT<bool>은 컴파일 에러를 생성하지 않고, 전달된 타입을 그대로 생성하게 된다.

방금 정의한 UnsignedT에서 IfThenElse의 타입 인자는 두 타입 함수의 인스턴스들이다. 그러나, 타입 함수는 IfThenElse가 하나를 선택하기 전에 평가되지 않는다. 대신, IfThenElse가 한 타입 함수 인스턴스를 선택한다 (MakeUnsignedT 또는 IdentityT 중 하나). 그리고 나서 ::Type은 선택된 타입 함수 인스턴스의 Type으로 평가된다.

 

여기서 IfThenElse contruct에서 선택되지 않는 warpper type이 완전히 인스턴스화되지 않는다는 사실에 의존한다는 점이 강조된다. 특히, 아래와 같은 코드는 동작하지 않는다.

template<typename T>
struct UnsignedT {
    using Type = typename IfThenElse<std::is_integral<T>::value && !std::is_same<T, bool>::value,
                                     MakeUnsignedT<T>,
                                     T
                                    >::Type;
};

MakeUnsignedT<T>에 대해 ::Type을 적용해야 하므로, else 분기의 T에 대해 ::Type을 적용하려면 IdentityT 라는 helper가 필요하다.

이런 맥락에서 아래와 같은 편의성을 위한 alias trait는 사용할 수 없다.

template<typename T>
using Identity = typename IdentityT<T>::Type;

이러한 alias trait는 유용하지만 IfThenElse와 같은 정의에서는 효과적으로 사용할 수 없으며, 이는 Identity<T>를 사용하면 IdentityT<T>가 완전히 인스턴스화되기 때문이다.

 

C++에서 IfThenElse는 std::conditional<>로 표준 라이브러리에서 제공한다. 이를 사용하여 UnsignedT trait는 아래와 같이 구현될 수 있다.

template<typename T>
struct UnsignedT {
    using Type = typename std::conditional_t<std::is_integral<T>::value && !std::is_same<T, bool>::value,
                                             MakeUnsignedT<T>,
                                             IdentityT<T>
                                            >::Type;
};

 


Detecting Nonthrowing Operations

특정 연산이 예외를 던지는지 확인하는 것이 유용할 때가 있다. 예를 들어, move constructor는 noexcept이어야 하는데, 이는 예외를 던지지 않는다는 것을 지칭한다. 그러나 특정 클래스의 move constructor가 예외를 던지는지에 대한 여부는 해당 클래스의 멤버나 베이스 클래스의 move constructor에 따라 달라지는 경우가 많다. 예를 들어 아래의 간단한 Pair 클래스에 대한 move constructor를 생각해보자.

template<typename T1, typename T2>
class Pair {
    T1 first;
    T2 second;
public:
    Pair(Pair&& other)
    : first(std::forward<T1>(other.first)),
      second(std::forward<T2>(other.second)) {}
};

Pair 클래스의 move constructor는 T1과 T2의 move operation이 예외를 던질 수 있다면 예외를 던지는 것이 가능하다.

예외를 던지지 않도록 확실하게 하기 위해서 주어진 타입에 대해 throw를 던지지 않는 move constructor가 있는지 확인하는 IsNothrowMoveConstructibleT trait으로 다음과 같이 사용할 수 있을 것이다.

Pair(Pair&& other) noexcept(IsNothrowMoveConstructibleT<T1>::value &&
                                IsNothrowMoveConstructibleT<T2>::value)
    : first(std::forward<T1>(other.first)),
      second(std::forward<T2>(other.second)) {}

IsNothrowMoveConstructibleT 의 전체 구현은 아래와 같다. 여기서는 noexcept 연산자를 사용하여 구현하였으며, 이 연산자를 통해 주어진 표현식이 nothrowing이라는 것을 보장하는 지 확인한다.

template<typename T>
struct IsNothrowMoveConstructibleT
 : std::bool_constant<noexcept(T(std::declval<T>()))> {};

여기서 noexcept의 연산자 버전이 사용되었는데, 이는 주어진 표현식이 non-throwing 인지를 판단한다. 그 결과값은 boolean 값이므로 std::bool_constant<>의 베이스 클래스로 전달한다. 이 베이스 클래스로부터 std::true_type 또는 std::false_type이 결정된다.

 

그러나, 이 구현은 SFINAE-friendly하지 않기 때문에 개선될 여지가 있다. 만약 move 또는 copy constructor가 없는 타입에 대해서 이 trait이 인스턴스화될 때 컴파일 에러가 발생한다.

class E {
public:
    E(E&&) = delete;
};

int main()
{
    // compile-time ERROR
    std::cout << IsNothrowMoveConstructibleT<E>::value << std::endl;
}

SFINAE 친화적인 구현을 통해 우리는 위 표현식이 컴파일 에러가 아닌 false를 출력하도록 만들 수 있다.

이는 결과를 연산하는 표현식이 유효한지를 평가되기 전에 체크하는 방식으로 구현할 수 있다. 위의 경우, 우리는 move constructor가 noexcept 인지 확인하기 전에 move constructor가 유효한지를 알아내야 한다. 그러므로 std::void_t<>를 사용하여 기본값으로 void를 void를 가지는 버전과 move constructor가 유효할 때만 인자로 전달된 파라미터가 유효하도록 하는(std::void_t<...>를 사용하여) partial specialization 버전을 정의하면 된다.

template<typename T, typename = std::void_t<>>
struct IsNothrowMoveConstructibleT : std::false_type {};
template<typename T>
struct IsNothrowMoveConstructibleT<T, std::void_t<decltype(T(std::declval<T>()))>>
 : std::bool_constant<noexcept(T(std::declval<T>()))> {};

만약 partial specialization에서 std::void_t<...>의 대체가 유효하다면, 이 specialization이 선택되고, 베이스 클래스의 noexcept(...) 표현식이 안전하게 평가될 수 있다. 그렇지 않다면 partial specialization은 인스턴스화되지 않고 무시되며 primary template이 대신 인스턴스화되고 이는 std::false_type이라는 결과를 생성한다.

 

직접 move constructor를 호출하지 않고 move constructor가 예외를 던지는지 체크하는 방법은 없다. 즉, move constructor가 public이고 삭제되지 않은 걸로는 충분하지 않으며, 해당 타입이 추상 클래스가 아니어야 한다. 이러한 이유로 HasNothrowMoveConstructor라는 이름보다 IsNothrowMoveConstructible이라는 이름으로 지정되었다. 그외의 경우에는 컴파일러의 지원이 필요하다.


Traits Convenience

Type traits에 대한 일반적인 단점은 비교적 장황하다는 것이다. 이를 사용할 때마다 뒤따라오는 ::Type이 필요하고 종속적인 문맥에서는 typename 키워드가 필요하기 때문이다. 여러 type traits가 구성되면 일부 어색한 형식으로 보여질 수 있다. 예를 들면, 아래와 같이 Array 타입의 + 연산자가 구현되는 경우가 있다.

template<typename, typename, typename = std::void_t<>>
struct HasPlusT : std::false_type {};
template<typename T1, typename T2>
struct HasPlusT<T1, T2, std::void_t<decltype(std::declval<T1>()+ std::declval<T2>())>>
: std::true_type {};

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

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

 

조금 간편하게 적용하기 위해서 (1) alias template을 사용하거나 (2) variable template을 사용하는 방법이 있다.

 

1번의 경우에는 using을 사용하여 아래와 같은 alias를 정의해두고 사용하는 것이다.

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

C++14에서는 type trait 구현의 suffix로 `_t`가 붙은 버전이 있는데, 이것이 바로 위와 같이 alias를 사용하여 정의되어 있다.

 

2번의 경우는 trait의 결과를 생성하는데 ::value가 필요한 경우에 해당한다. 예를 들어, std::is_same<>과 같은 trait를 다음과 같이 변수 템플릿을 만들어 사용할 수 있다.

template<typename T1, typename T2>
constexpr bool IsSame = std::is_same<T1, T2>::value;

C++17에서는 `_v` suffix가 붙은 type trait 버전을 도입했고, 위와 같이 정의되어 있다.


Type Classification

템플릿 파라미터가 built-in type, pointer type, 또는 class type 등 인지 확인하는 것이 유용할 때가 있다. 이번에는 주어진 타입의 다양한 속성을 결정할 수 있는 type traits에 대해서 살펴본다. 결과적으로 이를 사용하여 다음와 같은 코드를 작성할 수 있다.

if (IsClass<T>::value) {
    ...
}

또는 C++17의 compile-time if도 사용할 수도 있고, partial specialization을 활용하여 다음과 같이 클래스를 정의할 수도 있다.

template<typename T, bool = IsClass<T>::value>
class C { ... };

template<typename T>
class C<T, true> { ... };

 

또한, IsPointerT<T>::value와 같은 표현식은 boolean constant일 것이고, 이는 유효한 nontype temlate arguments가 된다. 결과적으로 이를 통해 더 정교하고 강력한 템플릿을 구성할 수 있도록 해준다. C++ STL에서는 std::is_pointer<T>와 같은 traits를 제공하고 있다.

Determining Fundamental Types

먼저 주어진 타입이 기본 타입인지 결정하는 템플릿을 만들 수 있다. 기본적으로 타입은 기본 타입이 아니라고 가정하고, 기본인 케이스에 대해서 템플릿을 specialization하면 된다.

template<typename T>
struct IsFundaT : std::false_type {};

// macro to specialize for fundamental types
#define MK_FUNDA_TYPE(T) template<> struct IsFundaT<T> : std::true_type {};

MK_FUNDA_TYPE(void)
MK_FUNDA_TYPE(bool)
MK_FUNDA_TYPE(char)
MK_FUNDA_TYPE(signed char)
MK_FUNDA_TYPE(unsigned char)
MK_FUNDA_TYPE(wchar_t)
MK_FUNDA_TYPE(char16_t)
MK_FUNDA_TYPE(char32_t)
MK_FUNDA_TYPE(signed short)
MK_FUNDA_TYPE(unsigned short)
MK_FUNDA_TYPE(signed int)
...

#undef MK_FUNDA_TYPE

이렇게 정의한 traits는 다음과 같이 사용할 수 있다.

template<typename T>
void test(T const&) {
    if (IsFundaT<T>::value) {
        std::cout << "T is a fundamental type\n";
    }
    else {
        std::cout << "T is not a fundamental type\n";
    }
}

int main()
{
    test(7);
    test("hello");
}

 

비슷한 방식으로 IsIntegralT나 IsFloatingT와 같은 traits를 구현할 수 있다. C++ STL에서는 std::is_integral<>과 std::is_fundamental<>과 같은 traits를 제공한다.

 

Determining Compound Types

Compound type은 다른 타입들로 구성된 타입이다. 단순한 compound type에는

  • pointer types
  • lvalue and rvalue reference types
  • pointer-to-member types
  • array types

이 있다. 이들은 하나 혹은 두 개의 underlying type으로 구성된다. Class types과 function types 또한 compound types이다. 하지만 이들의 구성은 임의의 갯수의 타입을 포함한다. 비록 여러 underlying types로 구성될 수는 없지만 Enumeration types 또한 nonsimple compound types로 간주된다.

 

Simple compound types는 partial specialization을 통해 분류할 수 있다.

Pointers

// primary template: by default not a pointer
template<typename T>
struct IsPointerT : std::false_type {};
// partial specialization for pointers
template<typename T>
struct IsPointerT<T*> : std::true_type {
    using BaseT = T;
};

Primary template은 모든 nonpointer types 케이스에 대한 구현이다. 이 구현의 value 값은 std::false_type으로 인해 false이다. Partial specialization 구현은 모든 종류의 포인터(T*)에 대한 구현이며 value의 값은 true 이다. 추가로 타입 멤버인 BaseT를 제공하는데, 이는 포인터가 가리키는 타입에 대응된다. 이 타입 멤버는 오직 주어진 타입이 포인터인 경우에만 가능하므로 SFINAE-friendly type trati라고 할 수 있다.

C++ 표준 라이브러리에서는 이에 해당하는 std::is_pointer<> trait를 제공한다.

References

포인터와 비슷하게 다음과 같이 lvalue reference type과 rvalue reference type을 식별할 수 있다.

template<typename T>
struct IsLValueReferenceT : std::false_type {};

template<typename T>
struct IsLValueReferenceT<T&> : std::true_type {
    using BaseT = T;
};

template<typename T>
struct IsRValueReferenceT : std::false_type {};

template<typename T>
struct IsRValueReferenceT<T&&> : std::true_type {
    using BaseT = T;
};

이 구현들을 함께 사용하여 IsReferenceT<> trait를 구현할 수 있다. std::conditional<> trait를 함께 사용했으며, lvalue 또는 rvalue reference를 선택하게 된다.

template<typename T>
class IsReferenceT
: public std::conditional<IsLValueReferenceT<T>::value,
                          IsLValueReferenceT<T>,
                          IsRValueReferenceT<T>
                         >::Type {};

 

C++ 표준 라이브러리에서는 이에 해당하는 std::is_lvalue_reference<>와 std::is_rvalue_reference<>, std::is_reference<>를 제공한다.

Arrays

배열인지 확인하는 trait은 partial specialization이 배열 사이즈에 대한 템플릿 파라미터 하나를 더 가지고 있도록 구현한다.

template<typename T>
struct IsArrayT : std::false_type {};

// partial specialization for arrays
template<typename T, std::size_t N>
struct IsArrayT<T[N]> : std::true_type {
    using BaseT = T;
    static constexpr std::size_t size = N;
};
// partial specialization for unbound arrays
template<typename T>
struct IsArrayT<T[]> : std::true_type {
    using BaseT = T;
    static constexpr std::size_t size = 0;
};

C++ 표준 라이브러리에서는 이에 해당하는 std::is_array<>를 제공한다. 추가적으로 std::rank<>와 std::extent<>를 제공하는데 이는 지정된 차원의 크기와 차원의 수를 쿼리하는데 사용된다.

Pointers to Members

동일한 기법을 사용하여 pointers to member를 처리할 수 있다.

template<typename T>
struct IsPointerToMemberT : std::false_type {};

template<typename T, typename C>
struct IsPointerToMemberT<T C::*> : std::true_type {
    using MemberT = T;
    using ClassT = C;
};

C++ 표준 라이브러리에서는 std::is_member_object_pointer<>, std::is_member_function_pointer<>, std::is_member_pointer<>라는 traits를 제공한다.

 

Identifying Function Types

함수 타입은 result type 이외에 임의의 갯수의 파라미터를 갖는다. 따라서, 함수 타입과 일치하는 partial specialization 내에서 모든 파라미터 타입을 캡처하기 위해 파라미터 팩(parameter pack)을 사용한다.

template<typename...>
class TypeList {};

template<typename T>
struct IsFunctionT : std::false_type {};

template<typename R, typename... Params>
struct IsFunctionT<R(Params...)> : std::true_type {
    using Type = R;
    using ParamsT = TypeList<Params...>;
    static constexpr bool variadic = false;
};

template<typename R, typename... Params>
struct IsFunctionT<R(Params..., ...)> : std::true_type {
    using Type = R;
    using ParamsT = TypeList<Params...>;
    static constexpr bool variadic = true;
};

함수 타입의 각 부분들이 노출되는 것에 주목하자. Result type은 Type으로, 모든 파라미터를 캡처하는 하나의 TypeList를 ParamsT로, 그리고 C-style의 가변 파라미터를 사용하는 함수 타입을 가리키기 윈한 variadic 변수를 제공한다.

 

아쉽지만, 위 구현은 모든 함수 타입을 처리하지는 못한다. 왜냐하면 함수 타입은 const나 volatile 한정자뿐만 아니라 lvalue(&), rvalue(&&) reference도 가질 수 있기 때문이다. C++17 이후에는 noexcept 한정자도 가질 수 있다.

 

이러한 함수 타입은 nonstatic member  함수에서만 의미가 있지만 그럼에도 함수 타입이긴 하다. 게다가 const가 붙은 함수 타입은 실제로 const 타입이 아니므로 std::remove_const로 const를 제거할 수도 없다. 따라서, 한정자가 있는 함수 타입을 인식하려면 한정자의 모든 조합을 커버하는 partial specialization을 추가해주어야 한다. 예를 들어, 다음과 같이 구현할 수 있다.

template<typename R, typename... Params>
struct IsFunctionT<R(Params...) const> : std::true_type {
    using Type = R;
    using ParamsT = TypeList<Params...>;
    static constexpr bool variadic = false;
};

template<typename R, typename... Params>
struct IsFunctionT<R(Params..., ...) volatile> : std::true_type {
    using Type = R;
    using ParamsT = TypeList<Params...>;
    static constexpr bool variadic = true;
};

template<typename R, typename... Params>
struct IsFunctionT<R(Params...) const volatile> : std::true_type {
    using Type = R;
    using ParamsT = TypeList<Params...>;
    static constexpr bool variadic = false;
};

template<typename R, typename... Params>
struct IsFunctionT<R(Params..., ...) &> : std::true_type {
    using Type = R;
    using ParamsT = TypeList<Params...>;
    static constexpr bool variadic = true;
};

...

C++ 표준 라이브러리에서는 std::is_function<>을 제공한다.

 

Determining Class Types

지금까지 다루었던 다른 compound type과는 달리, 클래스 타입과 구체적으로 일치하는 partial specialization은 없다. 기본 타입과 마찬가지로 모든 클래스 타입을 열거하는 것도 불가능하다. 대신 모든 클래스 타입에 유효한 일종의 타입이나 표현식을 통해 클래스 타입을 식별하는 간접적인 방법을 사용해야 한다.

 

이러한 경우에 클래스 타입에서 가장 편리한 속성은 클래스 타입만 pointer-to-member 타입의 basis로 사용할 수 있다는 것이다. 즉, X Y::*과 같은 타입 구성에서 Y는 클래스 타입만 가능하다. 아래 구현한 IsClass<>는 이 속성을 활용한다.

template<typename T, typename = std::void_t<>>
struct IsClassT : std::false_type {};

template<typename T>
struct IsClassT<T, std::void_t<int T::*>> : std::true_type {};

C++ 언어는 람다 표현식의 타입을 "unique, unnamed non-union class type"으로 지정한다. 이러한 이유로 람다 표현식은 IsClassT의 결과로 true를 생성한다. 위 구현에서 int T::* 표현식은 union 타입에 대해 유효하다.

 

C++ 표준 라이브러리에서는 std::is_class<>와 std::is_union<>을 제공한다. 그러나 class와 struct 타입을 union 타입과 구분하는 것이 현재 standard core lanugage technique로는 불가능하므로 이 traits는 특별한 컴파일러의 지원이 필요하다.

 

Determining Enumeration Types

지금까지 살펴본 type traits로 분류되지 않는 타입은 enumeration type이다. Enumeration type을 테스트하는 것은 integral type, 명시적으로 기본 타입이 아닌 타입, class type, reference type, pointer type, pointer-to-member type으로의 명시적 변환을 체크하는 SFINAE-based trait를 작성하여 직접 수행될 수 있다. 이 모든 type traits를 만족하지 않는다면 해당 타입은 enumeration type이다. 따라서, 다음과 같이 구현될 수 있다.

template<typename T>
struct IsEnumT {
    static constexpr bool value = !IsFundaT<T>::value &&
                                  !IsPointerT<T>::value &&
                                  !IsReferenceT<T>::value &&
                                  !IsArrayT<T>::value &&
                                  !IsPointerToMemberT<T>::value &&
                                  !IsFunctionT<T>::value &&
                                  !IsClassT<T>::value;
};

C++ 표준 라이브러리에서는 std::is_enum<>을 제공한다. 보통 컴파일 성능을 향상시키기 위해서 컴파일러가 직접 이 기능을 지원한다 (위와 같이 구현하는게 아니라).


Policy Traits

지금까지 살펴본 traits 템플릿 예제들은 템플릿 파라미터의 속성을 결정하는데 사용되었다. 즉, 이들이 나타내는 타입의 종류, 해당 타입의 값에 적용되는 연산자의 result type 등을 결정하는데 사용되었다. 이러한 특정을 property traits 라고 부른다.

 

이와 대조적으로, 어떤 traits는 몇몇 타입이 어떻게 처리되는지를 정의한다. 이를 policy traits 라고 한다. 이는 policy classes라는 개념을 연상시킨다 (policy classes와 policy traits의 구분이 명확하지는 않다). 하지만, policy traits는 템플릿 파라미터와 관련한 고유한 속성인 경향이 있다. 반면 policy classes는 일반적으로 다른 템플릿 파라미터와 무관한 경향이 있다.

Read-Only Parameter Types

C와 C++에서 함수 호출 인자는 기본적으로 값으로 전달된다. 이는 caller에 의해 계산되는 인자의 값이 callee에 의해 제어되는 공간으로 복사된다는 의미이다. 대부분 큰 구조체가 이렇게 복사되면 비용이 크다는 것을 알고 있고 이러한 경우 reference-to-const (또는 pointer-to-const in C)로 인자는 전달하는 것이 적절하다고 알고 있다. 크기가 작은 구조체에서 이는 항상 명확하지는 않으며, 성능 관점에서 봤을 때 가장 좋은 메커니즘은 코드가 작성되는 정확한 아키텍처에 따라 다르다. 대부분의 경우 그다지 중요하지 않지만 크기가 작은 구조체라도 주의해서 다루어야 한다.

 

물론, 템플릿을 사용하면 상황은 조금 더 복잡해진다. 템플릿 파라미터가 대체되는 타입이 큰지 미리 알 수 없다. 게다가, 이러한 결정은 단지 크기에만 고려되는 것이 아니다. 크기가 작은 구조체라도 reference-to-const로 read-only 파라미터를 전달하는 복사 생성자가 비용이 클 수 있다.

이 문제는 type function인 policy traits 템플릿을 사용하여 편리하게 처리된다. 이 함수는 의도한 인자 타입 T를 최적의 파라미터 타입 T 또는 T const&로 매핑한다. 예를 들어, primary template이 두 포인터보다 크기가 크지 않은 타입에 대해서는 값으로 전달을 사용하고 그 이외의 경우에서는 reference-to-const를 사용하도록 할 수 있다.

template<typename T>
struct RParam {
    using Type = typename std::conditional<sizeof(T) <= 2 * sizeof(void*),
                                           T,
                                           T const&>::type;
};

반면, sizeof가 작은 값을 반환하는 컨테이너 타입에는 비용이 큰 복사 생성자가 포함될 수 있으므로 다음과 같이 여러 specialization or partial specialization이 필요할 수 있다.

template<typename T>
struct RParam<Array<T>> {
    using Type = Array<T> const&;
};

C++에서는 이런 타입이 흔하기 때문에 값 타입으로 간단한 복사 및 이동 생성자를 갖는 작은 타입만 마크하고 성능 고려 사항이 해당할 때 선택적으로 다른 클래스 타입을 추가하는 것이 더 안전할 수 있다. C++의 표준 라이브러리에서 제공하는 std::is_trivially_copy_constructible과 std::is_trivially_move_constructible을 사용하여 구현할 수 있다.

template<typename T>
struct RParam {
    using Type = typename std::conditional<sizeof(T) <= 2 * sizeof(void*)
                                            && std::is_trivially_copy_constructible<T>::value
                                            && std::is_trivially_move_constructible<T>::value,
                                           T,
                                           T const&>::type;
};

 

어느 방법을 사용하든 이제 policy는 trait template definition에 집중될 수 있고, 이를 활용하여 좋은 효과를 얻을 수 있다. 예를 들어, 두 개의 클래스가 있고 한 클래스는 read-only 인자에 대해 값으로 호출하는 것이 더 좋다고 지정하는 방법을 살펴보자. 코드는 아래와 같다.

class MyClass1 {
public:
    MyClass1() {}
    MyClass1(MyClass1 const&) {
        std::cout << "MyClass1 copy constructor called\n";
    }
};

class MyClass2 {
public:
    MyClass2() {}
    MyClass2(MyClass2 const&) {
        std::cout << "MyClass2 copy constructor called\n";
    }
};

// pass MyClass2 objects with RParam<> by value
template<>
class RParam<MyClass2> {
public:
    using Type = MyClass2;
};

// function that alows parameter passing by value or by reference
template<typename T1, typename T2>
void foo(typename RParam<T1>::Type p1,
         typename RParam<T2>::Type p2)
{
    //...
}

int main()
{
    MyClass1 mc1;
    MyClass2 mc2;
    foo<MyClass1, MyClass2>(mc1, mc2);
}

이 코드를 실행시켜보면 'MyClass2 copy constructor called'만 출력되는 것을 확인할 수 있다 (MyClass1은 참조로 전달되었기 때문에 복사 생성자가 호출되지 않았고, MyClass2는 값으로 전달되었기 때문에 복사 생성자가 호출됨).

 

하지만, RParam을 사용할 때 몇 가지 단점이 있다. 첫째, 함수 선언이 훨씬 더 복잡해진다. 둘째, 템플릿 매개변수가 함수 매개변수의 한정자에만 나타나므로 foo()와 같은 함수에 대한 argument deduction이 불가능하다. 따라서, 호출하는 측에서는 명시적으로 템플릿 인수를 지정해주어야 한다.

 

이를 어느 정도 해결하려면 perfect forwarding을 제공하는 inline wrapper 함수 템플릿을 사용하면 된다.

// function that alows parameter passing by value or by reference
template<typename T1, typename T2>
void foo_core(typename RParam<T1>::Type p1,
              typename RParam<T2>::Type p2)
{
    //...
}
// wrapper to avoid explicit template parameter passing
template<typename T1, typename T2>
void foo(T1&& p1, T2&& p2) {
    foo_core<T1, T2>(std::forward<T1>(p1), std::forward<T2>(p2));
}
int main()
{
    MyClass1 mc1;
    MyClass2 mc2;
    foo(mc1, mc2); // same_as foo_core<MyClass1, MyClass2>(mc1, mc2)
}
위 코드를 실행한 결과, MyClass1과 MyClass2가 모두 참조로 전달된 것으로 확인된다. 이전과 달리 MyClass2의 복사 생성자가 호출되지 않았는데, 이 예제가 말하고자 하는 것이 무엇인지 정확히 파악되지는 않는다 (인자 추론이 가능하다는 것 이외의 의미).

 

댓글