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

[C++] 메타프로그래밍

by 별준 2023. 12. 16.

Reference

  • Ch 23, C++ Templates The Complete Guide

Contents

  • The State of Modern C++ Metaprogramming
  • The Dimensions of Reflective Metaprogramming
  • The Cost of Recursive Instantiation
  • Enumeration Values versus Static Constants

메타프로그래밍(metaprogramming)이란 '프로그램을 프로그래밍하는 것'이다. 즉, 실제로 원하는 기능을 구현하는 새로운 코드를 생성하도록 실행되는 프로그래밍 시스템을 작성하는 것이다. 일반적으로 메타프로그래밍이라는 용어는 반사적(reflexive) 속성을 내포한다. 메타프로그래밍 컴포넌트는 일부 코드를 생성하기 위한 프로그램의 일부이다.

 

메타프로그래밍을 왜 사용할까? 다른 프로그래밍 기법들처럼 목적은 최소한의 노력으로 더 많은 기능을 구현하려는 것이다. 여기서 노력이란 코드 크기, 유지보수 비용 등을 의미한다. 메타프로그래밍의 특징은 일부 사용자 정의 연산이 번역 시간(translation time)에 일어난다는 것이다. 이는 일반적으로 성능을 높이거나(번역 시간에 계산된 것들은 최적화될 수 있음) 인터페이스를 단순하게 하기 위함이다(일반적으로 짧다).

 

The State of Modern C++ Metaprogramming

Value Metaprogramming

C++11에서는 constexpr를 도입하여 컴파일 시간(meta-) 연산을 구성하는 것은 쉽게 해결할 수 있다. 하지만 프로그래밍 모델이 편리한 것은 아니다. 예를 들어, for-loop 구문을 사용할 수 없기 때문에 재귀적 함수 호출을 이용해야 한다. C++14에서는 constexpr 함수에서 루프를 사용할 수 있다. 예를 들어, C++14에서부터는 제곱근을 구하는 compile-time 함수를 아래와 같이 간단하게 작성할 수 있다.

template<typename T>
constexpr T sqrt(T x)
{
    if (x <= 1) {
        return x;
    }

    T lo = 0, hi = x;
    for (;;) {
        auto mid = (hi + lo) / 2, mid_squared = mid * mid;
        if (lo + 1 >= hi || mid_squared == x) {
            // mid must be square root
            return mid;
        }
        // continue with the higher/lower half-interval
        if (mid_squared < x) {
            lo = mid;
        }
        else {
            hi = mid;
        }
    }
}

위 알고리즘은 반복적으로 x의 제곱근을 가질 것으로 보는 구간을 반으로 나누어 탐색한다. 이 sqrt() 함수는 컴파일 과정 또는 실행 시간에 평가될 수 있다.

static_assert(sqrt(25) == 5, "");   // OK (evaluated at compile time)
static_assert(sqrt(40) == 6, "");   // OK (evaluated at compile time)
std::array<int, sqrt(40) + 1> arr;  // declares array of 7 elements (compile time)

long long l = 53478;
std::cout << sqrt(l) << std::endl;  // prints 231 (evaluated at run time)

이 함수는 실행 시간에서 가장 효율적으로 구현된 것은 아니지만, 컴파일 과정에 계산하는 것을 원하기 때문에 효율성보다는 이식성(portability)이 더 중요하다. 물론 이 예제에서 고급 템플릿 기법이 사용되지는 않았으며 일반적인 템플릿 인자 추론만 사용되었을 뿐이다. 코드 자체는 평범한 C++ 코드이다.

 

위와 같이 value metaprogramming(컴파일 과정에 값을 계산하는 프로그래밍)을 사용하면 상당히 유용할 때가 많다. 하지만 현대 C++(C++14, C++17)에서는 추가로 두 가지 종류의 메타프로그래밍을 더 시도해볼 수 있다.

  • Type Metaprogramming
  • Hybrid Metaprogramming

Type Metaprogramming

Type traits 템플릿은 기본적인 타입 연산 방식이다. 예를 들어, 타입을 입력받아 새로운 타입을 만들어내는 것이 있다. 여기서 재귀적인 템플릿 인스턴스화를 사용하면 상당히 복잡한 타입 연산까지 수행할 수 있다.

// primary template: in general we yield the given type
template<typename T>
struct RemoveAllExtentsT {
    using Type = T;
};

// partial specializations for array types (with and without bounds):
template<typename T, std::size_t SZ>
struct RemoveAllExtentsT<T[SZ]> {
    using Type = typename RemoveAllExtentsT<T>::Type;
};
template<typename T>
struct RemoveAllExtentsT<T[]> {
    using Type = typename RemoveAllExtentsT<T>::Type;
};

template<typename T>
using RemoveAllExtents = typename RemoveAllExtentsT<T>::Type;

위 코드에서 RemoveAllExtents는 type metafunction(예를 들어, 결과 타입을 생성하는 연산 장치)이며 타입에서 임의의 갯수의 top-level 'array layers'를 제거한다. 표준 라이브러리에서는 이에 해당하는 traits로 std::remove_all_extents를 제공한다. 이 템플릿은 다음과 같이 사용할 수 있다.

RemoveAllExtents<int[]>        // yield int
RemoveAllExtents<int[5][10]>   // yield int
RemoveAllExtents<int[][10]>    // yield int
RemoveAllExtents<int(*)[5]>    // yield int(*)[5]

이 메타함수는 자기 자신을 재귀적으로 호출하여 top-level 배열에 일치하는 부분 특수화를 사용해 자신의 태스크를 수행한다.

 

우리가 사용할 수 있는 것이 스칼라 값뿐이라면 값을 계산하는 것이 상당히 제한적이었을 것이다. 다행히 어떤 프로그래밍 언어라도 적어도 값에 대한 컨테이너를 생성하는 방식을 적어도 하나는 제공하고 이는 그 언어의 힘을 극적으로 강화한다 (대부분의 언어는 array/vector, hash tables 등을 제공한다). 타입 메타프로그래밍에서도 동일하다. "container of types"가 추가되면 이 기법의 적용은 엄청나게 늘어난다.

Hybrid Metaprogramming

Value metaprogramming과 type metaprogramming을 가지고 컴파일 시간에 값과 타입을 연산할 수 있다. 하지만 최종적으로는 실행 시간에 미치는 영향에 관심이 있다. 즉, 타입과 상수가 예상되는 실행 시간 코드에 이러한 메타프로그래밍을 사용하려는 것이다. 메타프로그래밍은 이보다 더 많은 일을 할 수 있는데, 컴파일 과정에 실행 시간에 영향을 미치는 코드 일부를 프로그래밍으로 조합할 수 있다. 이를 바로 hybrid metaprogramming이라고 한다.

 

이 원칙을 살펴보기 위해 두 std::array 값의 내적을 계산하는 예제를 살펴보자. std::array는 아래와 같이 선언된 고정된 길이를 갖는 컨테이너 템플릿이다.

namespace std {
    template<typename T, size_t N> struct array;
}

여기서 N은 배열 내 요소의 갯수이며 타입은 T이다. 같은 배열이 주어졌을 때 두 배열의 내적은 다음과 같이 계산할 수 있다.

template<typename T, std::size_t N>
auto dotProduct(std::array<T, N> const& x, std::array<T, N> const& y)
{
    T result{};
    for (std::size_t k = 0; k < N; ++k) {
        result += x[k] * y[k];
    }
    return result;
}

for 루프를 바로 컴파일하면 branching instructions이 생성되는데 그러면 일부 머신에서는 아래와 같이 일련의 계산을 수행하는 것보다 오버헤드가 크다.

result += x[0]*y[0];
result += x[1]*y[1];
result += x[2]*y[2];
...

다행히 컴파일러는 타겟 플랫폼에 따라서 가장 효율적인 루프 형태로 최적화한다. 그러나 자세히 살펴보기 위해서 먼저 다음과 같이 루프를 사용하지 않는 방식으로 dotProduct()를 다시 구현해보자.

template<typename T, std::size_t>
struct DotProductT {
    static inline T result(T* a, T* b) {
        return *a * *b + DotProductT<T, N-1>::result(a + 1, b + 1);
    }
};
// partial specialization as end criteria
template<typename T>
struct DotProductT<T, 0> {
    static inline T result(T*, T*) {
        return T{};
    }
};

template<typename T, std::size_t N>
auto dotProduct(std::array<T, N> const& x, std::array<T, N> const& y)
{
    return DotProductT<T, N>::result(x.begin(), y.begin());
}

위 구현은 내적 연산을 클래스 템플릿인 DotProductT에 위임한다. 그러면 재귀 템플릿 인스턴스화와 종료하기 위한 부분 특수화를 사용하여 내적을 계산한다. 이 과정이 효율적이려면 컴파일러가 정적 멤버 함수 result()를 호출할 때마다 인라인이 되어 있어야만 한다. 적절한 수준의 컴파일러를 사용한다면 충분히 인라인화될 수 있다 (위 코드에서 명시적으로 inline을 표시했다. 일부 컴파일러, 특히 clang에서는 이 키워드가 있으면 인라인화하라는 힌트를 조금 더 사용한다. 정확한 언어 정의에 따르면 이 함수들은 자신을 둘러싼 클래스 내부에 정의되어 있으므로 사실 암묵적으로 인라인된다).

 

이 코드에서 중점적으로 살펴봐야할 부분은 이 코드가 코드의 전체 구조를 결정하는 compile-time computation과 실행시간 효과(run-time effect)를 결정하는 run-time computation를 혼합한다는 것이다.

 

앞서 언급했듯이 container of types를 활용할 수 있게 되면서 타입 메타프로그래밍이 훨씬 더 향상되었다. Hybrid 메타프로그래밍에서 볼 수 있듯이 고정 길이의 배열 타입이 유용하게 사용된다. 하지만 hybrid 메타프로그래밍의 꽃은 튜플(tuple)이다. 튜플은 값의 시퀀스이며 각 값들은 선택되어진 타입을 갖는다. C++ 표준 라이브러리에서 이 기능을 std::tuple로 제공한다.

std::tuple<int, std::string, bool> tVal{42, "Answer", true};

위 코드는 int, std::string, bool 타입의 값 3개를 순서대로 모아놓은 변수 tVal을 정의한다. 다른 포스팅을 통해서 이를 어떻게 구현하는지 살펴볼 예정이다. 여기서 tVal은 다음의 단순한 struct 타입과 매우 유사하다.

struct MyTuple {
    int v1;
    std::string v2;
    bool v3;
};

 

배열 타입과 (단순한) 구조체 타입에 대해 유연한 대체인 std::array와 std::tuple이 있다면, 단순한 union 타입에 대한 대체로는 std::variant (C++17)이 있다. 이에 대한 내용도 별도의 포스팅을 통해서 살펴볼 예정이다.

 

std::tuple이나 std::variant는 struct 타입처럼 이종 타입이며, 이러한 타입을 사용하는 hybrid 메타프로그래밍을 heterogeneous(이종) 메타프로그래밍이라고 부른다.

Hybrid Metaprogramming for Unit Types

하이브리드 계산의 강력함을 보여줄 다른 예제로는 다양한 단위 타입(unit type) 값들의 결과를 계산할 수 있는 라이브러리가 있다. 값은 실행 시간에 계산되지만 결과 단위 타입은 컴파일 과정에 계산된다. 조금 더 구체적으로 살펴보자.

 

예를 들어, 시간의 주요 단위를 초(second)라고 한다면 밀리초(milisecond)는 1/1000로, 분(minute)은 60/1로 표시할 수 있다. 여기서 핵심은 각 값이 고유한 타입을 갖는 비율(ratio) 타입을 정의하는 것이다.

template<unsigned N, unsigned D = 1>
struct Ratio {
    static constexpr unsigned num = N; // numerator
    static constexpr unsigned den = D; // denominator
    using Type = Ratio<num, den>;
};

이제 두 단위를 더하는 것과 같은 compile-time 연산을 정의할 수 있다.

template<unsigned N, unsigned D = 1>
struct Ratio {
    static constexpr unsigned num = N; // numerator
    static constexpr unsigned den = D; // denominator
    using Type = Ratio<num, den>;
};

// implementation of adding two ratios
template<typename R1, typename R2>
struct RatioAddImpl
{
private:
    static constexpr unsigned den = R1::den * R2::den;
    static constexpr unsigned num = R1::num * R2::den + R2::num * R1::den;
public:
    typedef Ratio<num, den> Type;
};

// using declaration for convenient usage
template<typename R1, typename R2>
using RatioAdd = typename RatioAddImpl<R1, R2>::Type;

그러면 컴파일 과정에서 두 ratio의 합을 계산할 수 있다.

int main()
{
    using R1 = Ratio<1, 1000>;
    using R2 = Ratio<2, 3>;
    using RS = RatioAdd<R1, R2>;                    // RS has type Ratio<2003, 2000>
    std::cout << RS::num << "/" << RS::den << "\n"; // prints 2003/3000

    using RA = RatioAdd<Ratio<2,3>, Ratio<5,7>>;    // RA has type Ratio<29,21>
    std::cout << RA::num << "/" << RA::den << "\n"; // prints 29/21
}

 

임의의 값 타입과 Ratio<>의 인스턴스인 단위 타입으로 파라미터화된 duration에 대한 클래스 템플릿도 정의할 수 있다.

// duration type for values of type T with nuit type U
template<typename T, typename U = Ratio<1>>
class Duration
{
public:
    using ValueType = T;
    using UnitType = typename U::Type;
private:
    ValueType val;
public:
    constexpr Duration(ValueType v = 0) : val(v) {}
    constexpr ValueType value() const {
        return val;
    }
};

흥미롭게 살펴볼 부분은 두 Duration을 더하는 operator+의 정의이다.

// adding two durations where unit type might differ
template<typename T1, typename U1, typename T2, typename U2>
auto constexpr operator+(Duration<T1, U1> const& lhs, Duration<T2, U2> const& rhs)
{
    // resulting type is a unit with 1 a nominator and
    // the resulting denominator of adding both unit type fractions
    using VT = Ratio<1, RatioAdd<U1, U2>::den>;
    // resulting value is the sum of both values
    // converted to the resulting unit type
    auto val = lhs.value() * VT::den / U1::den * U1::num +
               rhs.value() * VT::den / U2::den * U2::num;
    return Duration<decltype(val), VT>(val);
}

+ 연산자 구현에서 다른 단위 타입인 U1과 U2를 인자로 받을 수 있다. 그리고 이 단위 타입들을 사용하여 결과로 얻는 duration이 가질 ratio(분자가 1인 ratio 값)에 해당하는 단위 타입을 계산한다. 그러고 나면 아래의 코드를 컴파일할 수 있다.

int main()
{
    int x = 42;
    int y = 77;
    auto a = Duration<int, Ratio<1, 1000>>(x);  // x miliseconds
    auto b = Duration<int, Ratio<2, 3>>(y);     // y 2/3 seconds
    auto c = a + b; // computes resulting unit type 1/3000 seconds
                    // and generates run-time code for c = a*3 + b*2000
    std::cout << a.value() << "*(0.001) seconds + " << b.value() << "*(2/3) seconds = " << c.value() << "*(1/3000) seconds" << std::endl;
}

뿐만 아니라 Duration에 대한 operator+는 constexpr이므로 값들이 컴파일 과정에 알려져 있다면 컴파일 과정에 값을 계산할 수도 있다.

 

C++의 표준 라이브러리 클래스 템플릿인 std::chrono에서 이 방식을 사용하되 약간 더 개선되었다 (오버플로우 처리 등).


The Dimensions of Reflective Metaprogramming

이제까지는 constexpr evaluation과 재귀적 템플릿 인스턴스화를 기반으로 하는 값 메타프로그래밍을 살펴봤다. 이는 모두 모던 C++에서 사용할 수 있으며, 계산을 위한 여러 가지 메소드를 활용한다. 값 메타프로그래밍에서 재귀적 템플릿 인스턴스화 측면에서도 동작할 수 있으며 C++11에서 constexpr function이 도입되기 이전에 사용하는 메커니즘이었다. 예를 들어, constexpr function이 도입되기 전에는 정수의 제곱근을 아래의 템플릿으로 구현했다.

// primary template to compute sqrt(N)
template<int N, int LO=1, int HI=N>
struct Sqrt {
    // compute the midpoint, rounded up
    static constexpr auto mid = (LO+HI+1)/2;
    // search a not too large value in a halved interval
    static constexpr auto value = (N < mid*mid) ? Sqrt<N, LO, mid-1>::value : Sqrt<N, mid, HI>::value;
};
// partial specialization for the case when LO equals HI
template<int N, int M>
struct Sqrt<N, M, M> {
    static constexpr auto value = M;
};

이 구현은 포스트 처음에 구현한 contexpr 함수 버전과 거의 동일한 알고리즘이며, 제곱근을 포함하고 있을 구간을 계속해서 반으로 나누어 가며 찾는다. 다만, 이번 구현에서는 메타함수에 대한 입력이 함수 인자가 아닌 템플릿 인자로 전달되며, 범위를 위한 경계값을 저장했던 지역 변수도 템플릿 인자로 바뀌었다. 이 구현이 constexpr 함수보다 덜 친근해보이지만 이 구현이 컴파일러 리소스를 소모하는 방법을 알아보기 위해서 이 코드를 조금 더 살펴보자.

 

어떤 경우에서든지 메타프로그래밍의 연산 엔진(computational engine)은 잠재적으로 다양한 옵션을 가질 수 있다. 여기서 옵션을 선택할 때 고려할 사항이 여러 가지 있다. C++에서 포괄적인 메타프로그래밍 솔루션이라면 다음의 3가지 중에 대한 선택이 필요하다.

  • Computation (계산)
  • Replection (반영)
  • Generation (생성)

Reflection은 프로그래밍적으로 프로그램의 기능을 조사할 수 있는 능력을 말하고, Generation은 프로그램을 위한 추가적인 코드를 생성하는 능력을 말한다.

계산에서는 재귀적 인스턴스와 constexpr evaluation이라는 두 가지 옵션이 있다. 반영에서는 type traits를 부분적인 솔루션으로 갖는다. 현재 특질은 템플릿 인스턴스화에 기반을 두고 있는데, 이러한 접근 방식은 컴파일러의 공간을 상당히 차지할 뿐만 아니라 컴파일이 끝날 때까지 그 공간을 계속 점유하기 때문에(이외의 방법으로 시도하면 컴파일 시간이 상당히 늘어난다) 그 대신 쓸 수 있는 방법은 계산 차원에서 constexpr evaluation 방식을 사용하는 것과 잘 어울리는 반영된 정보를 표현하는 새로운 표준 타입을 도입하는 것이다. C++ Template The Complete Guide의 챕터 17.9에서 이러한 옵션에 대해서 잘 언급하고 있다.

 

챕터 17.9에서는 이 외에도 강력한 코드 생성을 위한 새로운 접근 방식을 소개하고 있다. 유연하고 제너럴하며 프로그래머에게 친화적인 코드 생성 메커니즘을 현재 C++ 언어 기반 안에서 만든다는 것은 여전히 어려우며 다양한 곳에서 열심히 연구하고 있다. 하지만 템플릿을 인스턴스화한다는 것이 '코드 생성' 메커니즘이라는 것만은 항상 진실이다. 또한, 컴파일러는 해당 메커니즘을 코드 생성을 위한 수단으로 사용할 수 있도록 작은 함수에 대한 호출을 인라인으로 확장하는 데 충분히 신뢰할 수 있게 되었다.


The Cost of Recursive Instantiation

// primary template to compute sqrt(N)
template<int N, int LO=1, int HI=N>
struct Sqrt {
    // compute the midpoint, rounded up
    static constexpr auto mid = (LO+HI+1)/2;
    // search a not too large value in a halved interval
    static constexpr auto value = (N < mid*mid) ? Sqrt<N, LO, mid-1>::value : Sqrt<N, mid, HI>::value;
};
// partial specialization for the case when LO equals HI
template<int N, int M>
struct Sqrt<N, M, M> {
    static constexpr auto value = M;
};

위에서 구현한 Sqrt<> 템플릿을 분석해보자. Primary 템플릿은 템플릿 파라미터 N(제곱근을 계산하고자 하는 값)과 추가 파라미터 두 개를 받아서 일반적인 재귀 계산을 수행한다. 추가 파라미터는 결과 값이 가질 수 있는 최소, 최댓값을 나타낸다. 템플릿 인자가 하나 뿐이라면 최솟값은 1, 최댓값은 그 자신으로 설정한다.

그후 이진 탐색(binary search) 기법으로 템플릿 내에서 result가 LO와 HI 사이의 중간값을 기준으로 어디에 위치하는지 계산한다. 이는 ?: 연산자를 통해 구현한다. mid * mid가 N보다 크다면 LO와 mid 사이에서 계속 탐색하며, 같거나 작다면 mid와 HI에서 동일한 템플릿을 사용한다.

마지막으로 재귀 과정을 끝내기 위해서 LO와 HI가 같은 M이라는 값을 가질 때, 마지막 값 M을 반환한다.

 

템플릿 인스턴스화의 비용은 상당하다. 상대적으로 비용이 적게 드는 클래스 템플릿이라 하더라도 인스턴스를 생성할 때마다 1KB가 없는 공간을 차지하며 컴파일이 끝날 때까지 이 공간을 재활용할 수 없다. 아래의 간단한 코드를 통해서 자세히 살펴보자.

int main()
{
    std::cout << "Sqrt<16>::value = " << Sqrt<16>::value << std::endl;
    std::cout << "Sqrt<25>::value = " << Sqrt<25>::value << std::endl;
    std::cout << "Sqrt<42>::value = " << Sqrt<42>::value << std::endl;
    std::cout << "Sqrt<1>::value = " << Sqrt<1>::value << std::endl;
}

먼저 아래의 표현식은

Sqrt<16>::value

다음과 같이 확장된다.

Sqrt<16, 1, 16>::value

그리고 템플릿 내에서 메타프로그램은 이를 다음과 같이 계산한다.

mid = (1 + 16 + 1) / 2 = 9
value = (16 < 9 * 9) ? Sqrt<16, 1, 8>::value : Sqrt<16, 9, 16>::value
          = (16 < 81) ? Sqrt<16, 1, 8>::value : Sqrt<16, 9, 16>::value
          = Sqrt<16, 1, 8>::value

value는 아래와 같이 확장되는 Sqrt<16, 1, 8>::value로 계산된다.

mid = (1 + 8 + 1) / 2 = 5
value = (16 < 5 * 5) ? Sqrt<16, 1, 4>::value : Sqrt<16, 5, 8>::value
          = (16 < 25) ? Sqrt<16, 1, 4>::value : Sqrt<16, 5, 8>::value
          = Sqrt<16, 1, 4>::value

그리고 이와 유사하게 Sqrt<16, 1, 4>::value는 다음과 같이 분해된다.

mid = (1 + 4 + 1) / 2 = 3
value = (16 < 3 * 3) ? Sqrt<16, 1, 2>::value : Sqrt<16, 3, 4>::value
          = (16 < 9) ? Sqrt<16, 1, 2>::value : Sqrt<16, 3, 4>::value
          = Sqrt<16, 3, 4>::value

마지막으로 Sqrt<16, 3, 4>::value는 아래 결과를 도출해낸다.

mid = (3 + 4 + 1) / 2 = 4
value = (16 < 4 * 4) ? Sqrt<16, 3, 3>::value : Sqrt<16, 4, 4>::value
          = (16 < 4 * 4) ? Sqrt<16, 3, 3>::value : Sqrt<16, 4, 4>::value
          = Sqrt<16, 4, 4>::value

그리고 Sqrt<16, 4, 4>::value는 최소, 최댓값이 동일하므로 부분 특수화에 일치하여 재귀를 끝내게 되고, 마지막 값은 4가 된다.

 

여기서 컴파일러는 아래의 표현식을 계산할 때

(16 < 9 * 9) ? Sqrt<16, 1, 8>::value : Sqrt<16, 9, 16>::value

사실 사용되는 결과만 인스턴스화하는 것이 아니라 선택되지 않는 쪽(Sqrt<16, 9, 16>)도 인스턴스화한다. 뿐만 아니라 결과 클래스 타입의 멤버에 :: 연산자를 사용하여 접근하므로 클래스 타입 내 모든 멤버도 인스턴스화된다. 즉, Sqrt<16, 9, 16>의 전체 인스턴스화에 의해서 Sqrt<16, 9, 12>와 Sqrt<16, 13, 16>도 전체 인스턴스화된다. 전 과정을 상세하게 파고 들어가보면 엄청난 수의 인스턴스가 생성되었음을 알 수 있다. 총 생성되는 인스턴스의 수는 N의 두 배에 가깝다.

 

다행히 엄청나게 증가하는 인스턴스의 수를 줄이는 기법도 있다. 이 기법을 활용하여 Sqrt 메타프로그램을 아래와 같이 작성할 수 있다.

// primary template to compute sqrt(N)
template<int N, int LO=1, int HI=N>
struct Sqrt {
    // compute the midpoint, rounded up
    static constexpr auto mid = (LO+HI+1)/2;
    // search a not too large value in a halved interval
    using SubT = std::conditional_t<(N < mid * mid),
                                    Sqrt<N, LO, mid-1>,
                                    Sqrt<N, mid, HI>>;
    static constexpr auto value = SubT::value;
};
// partial specialization for the case when LO equals HI
template<int N, int M>
struct Sqrt<N, M, M> {
    static constexpr auto value = M;
};

여기서 핵심은 std::conditional_t 템플릿을 사용한다는 점이다. 상수 값이 참이라면 첫 번째 타입을 선택하고, 거짓이라면 두 번째 타입을 선택한다. 클래스 템플릿 인스턴스에 대해서 타입 alias를 정의하더라도 C++ 컴파일러는 해당 인스턴스의 내용을 인스턴스화하지 않는다는 점이 중요하다. 따라서, 두 선택지의 타입인 Sqrt<N, LO, mid-1>과 Sqrt<N, mid, HI> 모두가 완전히 인스턴스화되지 않는다.

 

둘 중 하나의 타입만 최종적으로 SubT가 될 것이고 SubT::value를 찾을 때 그 타입이 완전히 인스턴스화된다. 처음 방법과 달리 이러한 기법을 사용하면 인스턴스의 수가 log2(N)에 비례하게 되고 이전과 달리 메타프로그래밍의 비용을 상당히 줄일 수 있다.


 

Enumeration Values versus Static Constants

C++ 초기 클래스 선언에서 멤버로 이름을 붙일 수 있는 'true constants' (called constant expression)은 열거형 값뿐이었다. 열거형 값을 사용하여 3의 지수 승을 계산하는 Pow3 메타프로그램을 정의하면 다음과 같다.

// primary template to compute 3 to the Nth
template<int N>
struct Pow3 {
    enum { value = 3 * Pow3<N-1>::value };
};
// full specialization to end the recursion
template<>
struct Pow3<0> {
    enum { value = 1 };
};

C++98 표준에서는 클래스 내 정적 상수 초기화자(static constant initializer)라는 개념이 도입되어 Pow3 메타프로그래밍은 다음과 같이 바뀔 수 있다.

// primary template to compute 3 to the Nth
template<int N>
struct Pow3 {
    static int const value = 3 * Pow3<N-1>::value;
};
// full specialization to end the recursion
template<>
struct Pow3<0> {
    static int const value = 1;
};

다만, 이 버전에서 문제가 하나 있다. 정적 상수 멤버는 lvalue이다. 따라서, 아래와 같은 선언이 있을 때

void foo(int const&);

이 함수에 메타프로그램의 결과를 아래와 같이 전달하려면

foo(Pow3<7>::value);

컴파일러는 Pow3<7>::value의 주소를 전달해야만 하며, 컴파일러는 정적 멤버의 정의를 인스턴스화하고 할당해야만 한다. 결과적으로 계산은 순수하게 compile-time effect에 제한되지 않는다.

 

한편 열거형은 lvalue가 아니다. 따라서 이들은 주소를 갖지 않는다. 따라서 이들을 참조로 전달하더라도 정적 메모리가 사용되지 않는다. 따라서, 문자 그대로 계산한 값을 전달하는 것처럼 된다.

 

하지만 C++11에서는 constexpr 정적 데이터 멤버가 도입되었고, 이 멤버는 꼭 정수 타입이어야 할 필요도 없다. 이를 사용하면 앞서 언급한 주소 문제를 해결하지는 못하지만, 메타프로그램의 결과를 생성할 때 가장 흔하게 사용된다. 열거형이 아닌 올바른 타입을 가질 수 있고, auto로 선언되었다면 타입을 추론할 수도 있다. C++17에서는 inline 정적 데이터 멤버도 추가되어 앞서 언급한 주소 문제로 해결되고, constexpr과 함께 사용될 수도 있다.

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

[C++] 템플릿과 상속 (EBCO, CRTP)  (0) 2023.12.29
[C++] Typelists  (0) 2023.12.23
[C++] Type Erasure  (4) 2023.12.15
[C++] Overloading on Type Properties  (0) 2023.11.25
[C++] Traits(특질) 구현 (3)  (0) 2023.11.24

댓글