Post

[Effective C++] 7. 템플릿과 일반화 프로그래밍 [4/4]

이 글은 제 개인적인 공부를 위해 작성한 글입니다. 틀린 내용이 있을 수 있고, 피드백은 환영합니다.

[Effective C++] 7. 템플릿과 일반화 프로그래밍 [4/4]

항목 47 : 타입에 대한 정보가 필요하다면 특성정보 클래스를 사용하자


STL은 기본적으로 컨테이너 및 반복자, 알고리즘의 템플릿으로 구성되어 있지만, 이 외에 유틸리티라고 불리는 템플릿도 몇 개 들어 있다. 이들 중 하나가 advance라는 이름의 템플릿인데, 이 템플릿이 하는 일은 지정된 반복자를 지정된 거리만큼 이동시키는 것이다.

1
2
3
// iter를 d 단위만큼 전진시킨다. d < 0이면 iter를 후진시킨다.
template <typename IterT, typename DistT>
void advance(IterT& iter, DistT d);

간단히 개념만 놓고 볼 때 advance는 그냥 iter += d만 하면 될 것 같지만, 사실 이렇게 구현할 수는 없다. += 연산을 지원하는 반복자는 임의 접근 반복자밖에 없기 때문이다. 임의 접근 반복자보다 기능적으로 떨어지는 다른 반복자 타입의 경우에는 ++ 혹은 – 연산을 d번 적용하는 것으로 advance를 구현해야 한다.

STL 반복자에 여러 종류가 있다는 사실을 잊지말자. STL 반복자는 각 반복자가 지원하는 연산에 따라 다섯 개의 범주로 나뉜다.

  • 입력 반복자(input iterator)

입력 반복자는 전진만 가능하고, 한 번에 한 칸씩만 이동하며, 자신이 가리키는 위치에서 읽기만 가능한데다가, 그것도 읽을 수 있는 횟수가 한 번뿐이다. 이 입력 반복자는 입력 파일에 대한 읽기 전용 파일 포인터를 본떠서 만들었고, C++ 표준 라이브러리의 istream_iterator가 대표적인 입력 반복자이다.


  • 출력 반복자(output iterator)

출력 반복자는 입력 반복자와 비슷하지만 출력용인 점만 다르다. 즉, 오직 앞으로만 가고, 한 번에 한 칸씩만 이동하며, 자신이 가리키는 위치에서 쓰기만 가능한데다가 쓸 수 있는 횟수가 딱 한 번뿐이다. 이 출력 반복자는 출력 파일에 대한 쓰기 전용 파일 포인터를 본떠서 만들었고, ostream_iterator가 이 부류의 대표주자이다.

입력 반복자와 출력 반복자는 STL의 5대 반복자 범주 가운데 기능적으로 가장 처진다. 앞으로만 갈 수 있고 자신이 가리키는 위치에서 딱 한 번만 읽거나 쓸 수 있기 때문에, 단일 패스(one-pass) 알고리즘에만 제대로 쓸 수 있다.


  • 순방향 반복자(forward iterator)

이것들보다 기능이 조금 강력한 반복자 범주가 순방향 반복자이다. 순방향 반복자는 입력 반복자와 출력 반복자가 하는 일은 기본적으로 다 할 수 있고, 여기에 덧붙여 자신이 가리키고 있는 위치에서 읽기와 쓰기를 동시에 할 수 있으며, 그것도 여러 번이 가능하다. 따라서 이 반복자에 속하는 녀석들은 다중 패스(multi-pass) 알고리즘에 문제없이 쓸 수 있다. STL은 원칙적으로 단일 연결 리스트를 제공하지 않지만 몇몇 라이브러리를 보면 제공하는 것들이 있는데(대게 이름이 slist이다), 이 컨테이너에 쓰는 반복자가 바로 순방향 반복자이다. TR1의 해시 컨테이너(항목 54 참조)를 가리키는 반복자도 아마 순방향 반복자의 범주에 들어갈 것이다.


  • 양방향 반복자(bidirectional iterator)

양방향 반복자는 순방향 반복자에 뒤로 갈 수 있는 기능을 추가한 것이다. STL의 list에 쓰는 반복자가 바로 이 범주에 들어간다. set, multiset, map, multimap 등의 컨테이너에도 양방향 반복자를 쓴다.


  • 임의 접근 반복자(random access iterator)

다섯 개 반복자 범주 중 가장 센 녀석은 임의 접근 반복자이다. 양방향 반복자에 “반복자 산술 연산(iterator arithmetic)” 수행 기능을 추가한 것이다. 쉽게 말해 주어진 반복자를 임의의 거리만큼 앞뒤로 이동시키는 일을 상수 시간 안에 할 수 있다는 것이다. 이러한 산술 연산은 포인터 산술 연산과 비슷하다. 사실 놀랄 만한 거리로 보기도 힘든 것이, 기본제공 포인터를 본떠서 임의 접근 반복자를 만들었기 때문이다. 기본제공 포인터는 임의 접근 반복자의 기능을 그대로 지니고 있다. C++ 표준 라이브러리의 vector, deque, string에 사용하는 반복자는 임의접근 반복자이다.


C++ 표준 라이브러리에는 지금까지 말한 다섯 개의 반복자 범주 각각을 식별하는 데 쓰이는 “태그(tag) 구조체”가 정의되어 있다.

1
2
3
4
5
struct input_iterator_tag {};
struct output_iterator_tag {};
struct forward_iterator_tag : public input_iterator_tag {};
struct bidirectional_iterator_tag : public forward_iterator_tag {};
struct random_access_iterator_tag : public bidirectional_iterator_tag {};

구조체들 사이의 상속 관계를 보면 is-a 관계인 것을 알 수 있는데, 딱 적합한 의미이다. 이를테면 모든 순방향 반복자는 입력 반복자도 될 수 있다. 이렇게 만들어진 public 상속을 어디에 쓰는지는 잠시후에 확인하자.

이제 advance로 돌아오자. 이렇듯 반복자들이 종류마다 가능한 것이 있고 불가능한 것이 있다는 점을 안 이상, 구현할 때 조금 신경을 써야 할 것 같다. 한 가지 방법이라면 소위 최소 공통 분모(lowest-common-denominator) 전략을 들 수 있다. 반복자를 주어진 횟수만큼 반복적으로 한 칸씩 증가시키거나 감소시키는 루프를 돌리는 것이다. 하지만 이 방법을 쓰면 선형 시간이 걸린다는 것은 쉽게 예측할 수 있다. 상수 시간의 반복자 산술 연산을 쓸 수 있는 임의 접근 반복자 입장에서는 분명 손해이다. 그래서 임의 접근 반복자가 주어졌을 때는 상수 시간 연산을 이용할 수 있는 방법이 있었으면 좋겠다는 생각이다.

그러니까, 우리가 진짜로 하고 싶은 일은 advance를 다음과 같이 구현하는 거란 말이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename IterT, typename DistT>
void advance(IterT& iter, DistT d)
{
    if (iter 임의 접근 반복자이다)
    {
        iter += d;
    }
    else
    {
        if (d >= 0) { while (d--) ++iter; }
        else { while (d++) --iter; }
    }
}

알겠지만, 위의 코드가 제대로 되려면 iter 부분이 임의 접근 반복자인지 판단할 수 있는 수단이 있어야 한다. 그러니까, 이 iter의 타입이 IterT가 임의 접근 반복자 타입인지를 알아야 한다는 거다. 즉, 어떤 타입에 대한 정보를 얻어낼 필요가 있다. 어떻게 해야 하냐면, 바로 특성정보(traits)이다. 특성정보란, 컴파일 도중에 어떤 주어진 타입의 정보를 얻을 수 있게 하는 객체를 지칭하는 개념이다.

특성정보는 C++에 미리 정의된 문법구조가 아니며, 키워드도 아니다. 그냥 C++ 프로그래머들이 따르는 구현 기법이며, 관례이다. 특성정보가 되려면 몇 가지 요구사항이 지켜져야 하는데, 특성 정보는 기본제공 타입과 사용자 정의 타입에서 모두 돌아가야 한다는 점이 그 중 하나이다. 이를테면 advance는 포인터(const char* 등) 및 int를 받아서 호출될 때도 제대로 동작할 수 있어야 한다. 하지만 이것의 정확한 의미는 ‘특성정보 기법을 포인터 등의 기본제공 타입에 적용할 수 있어야 한다’라는 것이다.

‘특성정보는 기본제공 타입에 대해서 쓸 수 있어야 한다’라는 사실을 뒤집어 생각하면, 어떤 타입 내에 중첩된 정보 등으로는 구현이 안 된다는 말로도 풀이할 수 있다. 포인터만 봐도, 포인터 안에 정보를 넣을 방법이 없지 않는가. 결국, 어떤 타입의 특성정보는 그 타입의 외부에 존재하는 것이어야 한다. 특성정보를 다루는 표준적인 방법은 해당 특성정보를 템플릿 및 그 템플릿의 1개 이상의 특수화 버전에 넣는 것이다. 반복자의 경우, 표준 라이브러리의 특성정보용 템플릿이 iterator_traits라는 이름으로 준비되어 있다.

1
2
3
// 반복자 타입에 대한 정보를 나타내는 템플릿
template<typename IterT>
struct iterator_traits;

보다시피, iterator_traits는 구조체(어차피 클래스) 템플릿이다. 예전부터 이어져 온 관례에 따라, 특성정보는 항상 구조체로 구현하는 것으로 굳어져 있다. 관례가 하나 더 있는데, 위처럼 특성정보를 구현하는 데 사용한 구조체를 가리켜 ‘특성정보 클래스‘라고 부르낟.

iterator_traits 클래스가 동작하는 방법은 이렇다. iterator_traits 안에는 IterT 타입 각각에 대해 iterator_category라는 이름의 typedef 타입이 선언되어 있다. 이렇게 선언된 typedef 타입이 바로 IterT의 반복자 범주를 가리키는 것이다.

iterator_traits 클래스는 이 반복자 범주를 두 부분으로 나누어 구현한다. 첫 번째 부분은 사용자 정의 반복자 타입에 대한 구현인데, 사용자 정의 반복자 타입으로 하여금 iterator_category라는 이름의 typedef 타입을 내부에 가질 것을 요구사항으로 둔다. 이때 이 typedef 타입은 해당 태그 구조체에 대응되어야 한다. 예를 들어, deque의 반복자는 임의 접근 반복자이므로, deque 클래스(템플릿)에 쓸 수 있는 반복자는 다음과 같은 형태일 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
template < ... >
class deque
{
public:
    class iterator
    {
    public:
        typedef random_access_iterator_tag iterator_category;
        ...
    };
    ...
};

다른 예로, list의 반복자는 양방향 반복자이기 때문에 다음과 같이 되어 있다.

1
2
3
4
5
6
7
8
9
10
11
12
template < ... >
class list
{
public:
    class iterator
    {
    public:
        typedef bidirectional_iterator_tag iterator_category;
        ...
    };
    ...
};

이 iterator 클래스가 내부에 지닌 중첩 typedef 타입을 앵무새처럼 똑같이 재생한 것이 iterator_traits이ㅏㄷ.

1
2
3
4
5
6
template <typename IterT>
struct iterator_traits
{
    typedef typename IterT::iterator_category iterator_category;
    ...
};

위의 코드는 확실히 사용자 정의 타입에 대해서는 잘 돌아가지만, 반복자의 실제 타입이 포인터인 경우에는 전혀 안 돌아간다. 포인터 안에 typedef 타입이 중첩된다는 것부터가 도무지 말이 안 되기 때문이다. iterator_traits 구현의 두 번재 부분은 바로 반복자가 포인터인 경우의 처리이다.

포인터 타입의 반복자를 지원하기 위해, iterator_traits는 포인터 타입에 대한 부분 템플릿 특수화(partial template specialization) 버전을 제공하고 있다. 사실 포인터의 동작 원리가 임의 접근 반복자와 똑같으므로, iterator_traits가 이런 식으로 지원하는 반복자 범주가 바로 임의 접근 반복자이다.

1
2
3
4
5
6
7
// 기본제공 포인터 타입에 대한 부분 템플릿 특수화
template<typename IterT>
struct iterator_traits<IterT*>
{
    typedef random_access_iterator_tag iterator_category;
    ...
};

이쯤 됐으니, 특성정보 클래스의 설계 및 구현 방법에 대해 감을 잡았을 것이다.

  • 다른 사람이 사용하도록 열어 주고 싶은 타입 관련 정보를 확인한다. (예를 들어, 반복자라면 반복자 범주 등이 여기에 속한다).
  • 그 정보를 식별하기 위한 이름을 선택한다(예: iterator_category).
  • 지원하고자 하는 타입 관련 정보를 담은 템플릿 및 그 템플릿의 특수화 버전(예: iterator_traits)을 제공한다.

자, 이렇게 iterator_traits가 주어졌으므로(실제로는 std::iterator_traits) advance의 의사 코드를 다음과 같이 다듬을 수 있다.

1
2
3
4
5
6
7
template<typename IterT, typename DistT>
void advance(IterT& iter, DistT d)
{
    if (typeid(typename std::iterator_traits<IterT>::iterator_category)
        == typeid(std::random_access_iterator_tag))
    ...
}

잘 될 것 같다는 기대감이 들지만, 아쉽게도 우리가 원하는 형태가 아니다. 첫 번째 걸림돌이 컴파일 문제인데, 이 부분은 항목 48에서 자세히 살펴보자. 일단 지금은 그보다 더 근본적인 문제를 해결해 보자. IterT의 타입은 알다시피 컴파일 도중에 파악되기 때문에, iterator_traits::iterator_category를 파악할 수 있는 때도 역시 컴파일 도중이다. 하지만 if 문은 프로그램 실행 도중에 평가된단 말이다. 아니 컴파일 도중에 할 수 있는 것을 굳이 실행 도중에 해야 할 이유가 없다. 시간 낭비와 더불어 실행 코드의 크기도 비대해진다.

지금 우리에게 정말 있었으면 하는 것은 주어진 타입에 대한 평가를 컴파일 도중에 수행하는 조건처리 구문요소(if…else 문 같은)이다. 마치 기다리고나 있었다는 듯이 C++에는 이런 효과를 얻을 수 있는 방법이 준비되어 있다. 바로 오버로딩이다.

어떤 함수 f를 오버로딩한다는 것은 매개변수 리스트가 다르지만 f라는 같은 이름은 같은 오버로드 버전을 여러 개 만든다는 것이다. 이 상태에서 f를 호출하면, 컴파일러는 우리가 넘긴 인자를 보고 호출 시의 전후관계에 가장 잘 맞는 오버로드 버전을 골라낸다. 사실 컴파일러의 의도는 이렇다.

“당신이 넘긴 인자 타입에 가장 잘 맞는 오버로드 버전이 이거면, 이 f를 부를 거야. 이게 아니라 두 번째로 고른 이 오버로드 버전이 가장 잘 맞는 것이면, 이것을 호출할 것이고 말이지. 세 번째로 고른 게 딱 맞으면 그것을 호출하겠어.”

뭐, 이런 식이다. 뭔가가 눈에 보이는가? 컴파일 타임에 타입에 따라 선택되는 조건처리 구문 요소가 바로 이것이다. 이걸 써서 advance가 우리가 원하는 대로 동작하게 만들면 되는 거다. 방법도 아주 간단하다. advance의 “동작 원리 알맹이”는 똑같게 하고, 받아들이는 iterator_category 객체의 타입을 다르게 해서 오버로드 함수를 만든다. 이 오버로드 함수의 이름은 doAdvance로 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 임의 접근
template<typename IterT, typename DistT>
void doAdvance(IterT& iter, DistT d, std::random_access_iterator_tag)
{
    iter += d;
}

// 양방향 반복자
template<typename IterT, typename DistT>
void doAdvance(IterT& iter, DistT d, std::bidirectional_iterator_tag)
{
    if (d >= 0) {
        while (d--) ++iter;
    } else {
        while (d++) --iter;
    }
}

// 입력 반복자
template<typename IterT, typename DistT>
void doAdvance(IterT& iter, DistT d, std::input_iterator_tag)
{
    if (d < 0) 
    {
        throw std::out_of_range("Negative distance");
    }
    while (d--) ++iter;
}

forward_iterator_tag는 input_iterator_tag로부터 상속을 받은 것이므로, input_iterator_tag를 매개변수로 받는 doAdvance는 순방향 반복자도 받을 수 있다. 이제는 앞에서 본 iterator_tag 구조체들 사이의 상속 관계가 어째서 그렇게 된 것인지 슬슬 이해될 것이다. 솔직히, 딱 이것만 생각하고 public 상속 관계를 만든 것은 아니다. 일반적인 측면에서 파생 클래스 타입에도 작동하는 기본 클래스 타입용 코드를 작성할 수 있도록 한 것이다.

보면 advance는 임의 접근 반복자 및 양방향 반복자에 대해 양수 및 음수 거리를 받을 수 있게 되어 있지만, 순방향 반복자나 입력 반복자를 음수 거리만큼 이동하려고 하면 미정의 동작이 발생한다. 필자는 단순히 d가 음수가 아닐 것이라고 가정하고 점검 코드를 구현했기 때문에, 함수에 음수 거리가 인자로 넘어올 경우에는 아주 긴 루프가 돌면서 d가 0에 가까워지는 심각한 상황에 빠질 수 있다. 위의 코드에서는 이런 상황을 막기 위해 예외를 던지는 루틴을 넣었다. 두 부분 모두 유효한 구현이다. 미정의 동작에서는 어떤 일이 일어날지 상상도 못할 것이다.

doAdvance 함수의 오버로딩도 마쳤겠다, 이제 advance가 해 줄 일은 오버로딩된 doAdvance를 호출하는 것뿐이다. 이때 컴파일러가 오버로딩 모호성 해결을 통해 적합한 버전을 호출할 수 있도록 반복자 범주 타입 객체를 맞추어 전달해야 된다.

1
2
3
4
5
6
template<typename IterT, typename Dist>
void advance(IterT& iter, DistT d)
{
    // iter의 반복자 범주에 적합한 doAdvance의 오버로드 버전을 호출한다.
    doAdvance(iter, d, typename std::iterator_traits<IterT>::iterator_category());
}

특성정보 클래스를 어떻게 사용하는지, 마지막으로 깔끔히 정리해 보자.

  • 작업자(worker) 역할을 맡을 함수 혹은 함수 템플릿(예: doAdvance)을 특성정보 매개변수를 다르게 하여 오버로딩한다. 그리고 전달되는 해당 특성정보에 맞추어 각 오버로드 버전을 구현한다.
  • 작업자를 호출하는 “주작업자(master)” 역할을 맡을 함수 혹은 함수 템플릿(예: advance)을 만든다. 이때 특성정보 클래스에서 제공되는 정보를 넘겨서 작업자를 호출하도록 구현한다.

특성정보는 C++ 표준 라이브러리에서 발에 치일 정도로 흔히 쓰인다. iterator_traits는 여기서 소개한 iterator_category 말고도 제공하는 반복자 관련 정보가 네 가지나 된다. (이들 중 가장 유용하게 쓰이는 것이 value_type인데, 항목 42에서 사용 예를 찾을 수 있다). 또한 문자 타입에 대한 정보를 담고 있는 char_traits도 있고, 숫자 타입에 대한 정보(표현 가능한 최소값 및 최대값 등)를 품고 있는 numeric_limits도 있다 (관계를 따르자면 traits로 끝나야 할 것 같은데, 예전부터 이렇게 불러 왔기 때문에 이 이름이 굳어졌다).

TR1(항목 54 참조)이 도입되면서 타입 관련 정보를 제공하는 특성정보 클래스가 상당수 추가되었다. 몇 개를 소개하자면 is_fundamental(T가 기본제공 타입인지 알려 줌), is_array(T가 배열 타입인지 알려 줌), is_base_of<T1, T2>(T1이 T2와 같거나 T2의 기본 클래스인지 알려 줌) 등이 있다. 모두 합치면 추가된 개수가 무려 50개나 된다.

특성정보 클래스는 컴파일 도중에 사용할 수 있는 타입 관려 정보를 만들어낸다. 또한 특성정보 클래스는 템플릿 및 템플릿 특수 버전을 사용하여 구현한다.

함수 오버로딩 기법과 결합하여 특성정보 클래스를 사용하면, 컴파일 타임에 결정되는 타입별 if…else 점검문을 구사할 수 있다.


항목 48 : 템플릿 메타프로그래밍, 하지 않겠는가?


템플릿 메타프로그래밍(template metaprogramming: TMP)은 컴파일 도중에 실행되는 템플릿 기반의 프로그램을 작성하는 일을 말한다. 대체 이게 뭘까? 1분만 곰곰이 생각해 보자. 템플릿 메타프로그램은 C++ 컴파일러가 실행시키는, C++로 만들어진 프로그램이다. TMP 프로그램이 실행을 마친 후엔 그 결과로 나온 출력물(템플릿으로부터 인스턴스화된 C++ 소스 코드)이 다시 보통의 컴파일 과정을 거치는 것이다.

사실 C++는 템플릿 메타프로그래밍을 염두에 두고 설계되지는 않았다. 그런데 1990년대 초에 TMP 개념이 발굴된 이후 TMP의 뛰어난 유용성이 하나 둘 드러나면서, 마침내는 C++ 언어 및 표준 라이브러리에 TMP를 용이하게 만드는 확장요소가 추가될 여지까지 생긴 것이다. 맞다. TMP는 발굴된 것이지 처음에 누가 작정하고 만든 것이 아니다. TMP의 축을 이루는 갖가지 특징들은 템플릿이란 것이 C++에 추가되면서 저절로 따라 들어와 있었다. 그러니까, 영특하고도 기발한 방법으로 템플릿을 사용하는 방법이 있다는 사실을 누군가가 알아채 주기만 하면 되는 것이었다.

TMP에는 엄청난 강점이 두 개나 있다.

  1. TMP를 쓰면 다른 방법으로는 까다롭거나 불가능한 일을 굉장히 쉽게 할 수 있다.
  2. TMP는 C++ 컴파일이 진행되는 동안에 실행되기 때문에, 기존 작업을 런타임 영역에서 컴파일 타임으로 전환할 수 있다.

이 때문에 두 가지 짭짤한 재미를 맛볼 수 있다. 하나는 일반적으로 프로그램 실행 도중에 잡혀 왔던 몇몇 에러들을 컴파일 도중에 찾을 수 있다는 점이다. 또 하나는 TMP를 써서 만든 C++ 프로그램이 확실히 모든 면에서 효율적일 여지가 많다는 것이다. 컴파일 타임에 동작을 다 해 가지고 오기 때문에 실행 코드도 작아지고, 실행 시간도 짧아지며, 메모리도 적게 잡아먹는 것이다. 하지만 기존 작업을 런타임에서 컴파일 타임으로 전환하면서 컴파일 타임이 길어지는 결과도 나타난다. TMP를 사용한 프로그램은 TMP를 쓰지 않고 그와 똑같이 동작하는 프로그램과 비교해서 컴파일 시간이 꽤 길다.

기억날지는 모르겠지만, 바로 앞 항목에 나온 바 있는 STL의 advance에 대한 유사 코드를 다시 가져와 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename IterT, typename DistT>
void advance(IterT& iter, DistT d)
{
    // 임의 접근 반복자에 대해서는 반복자 산술 연산을 쓴다.
    if (iter 임의 접근 반복자이다)
    {
        iter += d;
    }
    else
    {
        if (d >= 0) { while (d--) ++iter; }
        else { while (d++) --iter; }
    }
}

이 유사코드를 진짜 코드로 만들려면 typeid를 쓸 수 있다. 어떻게 보면 “지극히 밋밋한” C++적인 접근 방법이다. 타입 정보를 꺼내는 작업을 런타임에 하겠다는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename IterT, typename DistT>
void advance(IterT& iter, DistT d)
{
    if (typeid(typename std::iterator_traits<IterT>::iterator_category) ==
        typeid(std::random_access_iterator_tag))
    {
        iter += d;
    }
    else
    {
        if (d >= 0) { while (d--) ++iter; }
        else { while (d++) --iter; }
    }
}

항목 47에서 지적했듯이, 이렇게 typeid 연산자를 쓰는 방법은 특성정보를 쓰는 방법보다 효율이 떨어진다. 왜냐하면

  1. 타입 점검 동작이 컴파일 도중이 아니라 런타임에 일어난다.
  2. 런타임 타입 점검을 수행하는 코드는 어쩔 수 없이 실행 파일에 들어가야 한다.

사실 위의 코드는 어째서 TMP가 “보통” C++ 프로그램보다 효율이 나은가를 보여주는 단적인 예라고 할 수 있다. 특성정보 방법이 바로 TMP이기 때문이다. 기억나는가? 특성정보를 썼기 때문에 타입에 따른 if … else 처리를 컴파일 타임에 할 수 있었던 것이다.

TMP를 쓰면 몇 가지 작업을 “보통” C++를 썼을 때보다 더 쉽게 할 수 있다고 말했었는데, 위의 advance에서 그 예도 확인할 수 있다. typeid 방법은 성능 외에도 컴파일 문제를 일으킬 수 있다는 말을 항목 47에서 말했었다. 이제 대체 어디서 컴파일 문제가 생기는지 확인해 보자.

1
2
3
4
std::list<int>::iterator iter;
...
advance(iter, 10);
// iter를 10개 원소만큼 앞으로 옮길까 했는데, 위처럼 구현된 advance로는 컴파일이 안 된다.

위의 코드를 컴파일러가 돌린다고 가정했을 때 어떤 advance가 만들어질지 생각해 보자. 템플릿 매개변수인 IterT 및 DistT에 대해 iter의 타입과 10의 타입을 넣고 나면, 다음과 같은 advance가 생길 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
void advance(std::list<int>::iterator& iter, int d)
{
    if (typeid(typename std::iterator_traits<std::list<int>::iterator>::iterator_category) ==
        typeid(std::random_access_iterator_tag))
    {
        iter += d; // 에러!
    }
    else
    {
        if (d >= 0) { while (d--) ++iter; }
        else { while (d++) --iter; }
    }
}

바로 +=를 쓴 줄에서 문제가 생긴다. 지금의 경우는 list::iterator에 +=를 쓰려고 한 것인데, list::iterator는 양방향 반복자이기 때문에 += 연산을 지원하지 못한다. += 연산의 지원은 임의 접근 반복자에서만 가능하기 때문이다. 하지만 지금은 += 줄까지도 실행될 수가 없다. 앞에 있는 if문에서 list::iterator에 대해 typeid 점검이 실패해 버리기 때문이다. 하지만, 그렇다 해도 모든 소스 코드가 제대로 되어 있는지 확인하는 일은 컴파일러의 책무이고, "iter += d" 부분은 iter가 임의 접근 반복자가 아닌 한 컴파일되지 않을 것은 자명하다. 만약 특성정보 기반의 TMP를 썼다면 이 상황이 어떻게 변할지 한 번 비교해 봐라. 주어진 타입에 대한 코드가 별도의 함수로 분리될 것이고, 각각의 함수는 자신이 맡은 타입에 대한 연산만 수행할 것이다.

TMP는 그 자체가 튜링 완전성을 갖고 있는 것으로 알려져 왔다. 범용 프로그래밍 언어처럼 어떤 것이든 계산할 수 있는 능력을 갖고 있다는 뜻이다. 변수 선언도 되고 루프도 실행시킬 수 있으며, 함수를 작성하고 호출하는 것까지도 된다. 단, 이런 것들에 필요한 구문 요소가 “보통”의 C++에서 쓰이는 구문요소들과 꽤나 다른 모습을 갖고 있다는 것이 특이하다.

역시 항목 47에 나온 것을 가지고 예를 들 수 있는데, TMP에서 if … else 조건문을 나타내는 데는 통상의 if 문이 아닌 템플릿 및 템플릿 특수화 버전을 사용한다. 하지만 프ꃠ그래밍 언어 수준으로 보면 이런 방법은 TMP의 어셈블리라고 할 수 있다. “보통”의 C++로 착각할 정도의 것은 아니지만, 차원 높은 수준의 문법으로 TMP를 구사할 수 있도록 이미 만들어 두신 TMP용 라이브러리도 꽤 있다(부스트의 MPL이 대표적이다. 항목 55 참조).

TMP의 동작 원리를 엿볼 수 있는 부분을 하나 더 말하자면 루프를 빼놓을 수 없다. 사실 TMP에는 반복(iteration) 의미의 진정한 루프는 없기 때문제, 재귀를 사용해서 루프의 효과를 낸다. 그런데 이 재귀조차도 우리가 알고 있는 종류가 아니다. 왜냐하면 TMP의 루프는 재귀 함수 호출을 만들지 않고 재귀식 템플릿 인스턴스(recursive template instantiation)를 하기 때문이다.

TMP에서 처음 접하는 프로그램은 컴파일을 통해 팩토리얼을 계산하는 템플릿이다. TMP 팩토리얼 계산에서는 앞에서 말한 재귀식 템플릿 인스턴스화를 통한 루프 효과도 확인할 수 있다. 게다가 TMP에서 변수를 만들어서 사용하는 방법도 엿볼 수 있다.

1
2
3
4
5
6
7
8
9
10
11
template<unsigned n>
struct Factorial
{
    enum { value = n * Factorial<n-1>::value };
};

template<>
struct Factorial<0>
{
    enum { value = 1 };
};

이렇게 만들어진 템플릿 메타프로그램이 있으면, Factorial::value를 참조함으로써 n의 계승을 바로 얻을 수 있다.

이 코드에서 루프를 도는 위치는 템플릿 인스턴스인 Factorial의 내부에서 또 다른 템플릿 인스턴스인 Factorial을 참조하는 곳이다. 제대로 만들어진 재귀 코드가 그러하듯, 여기에도 재귀를 끝내는 특수 조건이 붙어 있다. 바로 Factorial<0>이다.

우리도 보았겠지만 Factorial 템플릿은 구조체 타입이 인스턴스화되도록 만들어져 있다. 그리고 이렇게 만들어진 구조체 안에는 value라는 이름의 TMP 변수가 선언되어 있는데, 항목 2에서 말한 나열자 둔갑술(enum hack)이 쓰인 것이다. 이 value 변수는 현재 계산된 팩토리얼 값을 담는 역할을 맡는다. 만약 진짜 루프가 있었다면 이 갑은 루프가 한 번 돌때마다 갱신될 것이다. TMP는 루프 대신에 재귀식 템플릿 인스턴스화를 사용하기 때문에, 꼬리에 꼬리를 물고 만들어지는 템플릿 인스턴스화 버전마다 자체적으로 value 사본을 갖게 되고, 각각의 value에는 “루프”를 한 번 돌 때 만들어지는 그 값이 담기게 되는 거다.

Factorial 템플릿은 다음과 같이 쓰면 된다.

1
2
3
4
5
int main()
{
    std::cout << Factorial<5>::value; // 런타임 계산 없이 출력
    std::cout << Factorial<10>::value;
}

Factorial 템플릿은 “hello world” 예제가 프로그래밍 언어의 쓰임새를 보여주는 것과 마찬가지로 본연의 임무인 TMP의 쓰임새를 보여주는 데 충실한 예제일 뿐이다. 어째서 TMP가 공부해 둘 만한 걸물인지를 파악하려면 이 TMP로 할 수 있는 것들을 확실히 이해해야 하는 것이 마땅한 마음가짐이다. C++ 프로그래밍에서 TMP가 실력 발휘하는 예를 들어 보면 세 군데이다.

  • 치수 단위(dimensional unit)의 정확성 확인

    과학 기술 분야의 응용 프로그램을 만들 때는 무엇보다도 치수 단위(질량, 거리, 시간)가 똑바로 조합되어야 하는 것이 최우선이다. 예를 들면, 속도를 나타내는 변수에 질량을 나타내는 변수를 대입하면 에러다. 하지만 거리 변수를 시간 변수로 나누고 그 결과를 속도 변수에 대입하는 것은 올바르다. TMP를 사용하면 프로그램 안에서 쓰이는 모든 치수 단위의 조합이 제대로 됐는지를 맞춰 컴파일 동안에 볼 수 있다. 그것도 계산 시간에 상관 없이 말이다. 선행 에러 탐지에 TMP를 써먹을 수 있는 사례가 이것이다.

    여기서 한 가지 재밌는 점은 바로 분수식 지수 표현이 지원된다는 것이다. 이런 표현이 가능하려면 컴파일러가 확인할 수 있도록 컴파일 도중에 분수의 약분이 되어야 한다. 이를테면 time1/2는 time4/8과 똑같이 받아들여져야 한다는 말이다.

  • 행렬 연산의 최적화

    operator* 등의 어떤 연산자 함수는 연산 결과를 새로운 객체에 담아 반환해야 한다고 항목 21에서 이야기한 것 같고, 또 항목 44를 본 사람은 SquareMatrix 클래스를 기억할 것 같다. 다음의 코드를 봐 보자.

    1
    2
    3
    4
    
    typedef SquareMatrix<double, 10000> BigMatrix;
    BigMatrix m1, m2, m3, m4, m5;
    ...
    BigMatrix = m1 * m2 * m3 * m4 * m5;
    

    곱셈 결과를 보통 방법으로 계산하면 네 개의 임시 행렬이 생겨야 한다. operator*를 한 번씩 호출할 때마다 반환되는 결과로 생기는 것이다. 그뿐 아니라, 행렬 원소들 사이에 곱셈을 해야 하므로 네 개의 루프가 순차적으로 만들어질 수밖에 없다. 바로 이런 비싼 연산에 TMP를 사용할 수 있다.

    TMP를 응용한 고급 프로그래밍 기술인 표현식 템플릿(expression template)을 사용하면 덩치 큰 임시 객체를 없애는 것은 물론이고 루프까지 합쳐 버릴 수 있다. 게다가 위에 써 놓은 사용자 코드에서 문법 하나 바꾸지 않고도 이 놀라운 기술을 적용할 수 있다. 이로써 우리는 메모리도 적게 먹으면서 속도는 빛나게 빠른 소프트웨어를 만들 수 있는 것이다.

  • 맞춤식 디자인 패턴 구현의 생성

    전략 패턴, 옵저버 패턴, 방문자 패턴 등의 디자인 패턴은 그 구현 방법이 여러 가지일 수 있다. TMP를 사용한 프로그래밍 기술인 정책 기반 설계(policy-based design)라는 것을 사용하면, 따로따로 마련된 설계상의 선택(이것을 정책이라 한다)을 나타내는 템플릿을 만들어날 수 있게 된다. 이렇게 만들어진 정책 템플릿은 서로 임의대로 조합되어 사용자의 취향에 맞는 동작을 갖는 패턴으로 구현되는 데 쓰인다.

    이 기술이 쓰이는 예를 하나 말하자면, 몇 개의 스마트 포인터 동작 정책을 하나씩 구현한 각각의 템플릿을 만들어 놓고, 이들을 사용자 마음대로 조합하여 수백 가지의 스마트 포인터 타입을 컴파일 도중에 생성할 수 있게 하는 것이다. 사실 이 기술은 디자인 패턴 혹은 스마트 포인터 등으로 대표되는 프로그래밍 구조물의 영역을 뛰어넘어 일반화되어 있다. 이른바 생성식 프로그래밍(generative programming)의 기초가 바로 이 기술이다.

TMP는 누구나 쉽게 재미있게 할 수 있는 씩씩한 우리들의 친구는 아니다. 문법은 비직관적이고, 개발 도구의 지원도 아주 미약하다. 그저 비교적 최근에 발견됐을 뿐인 어쩌다 마주친 언어이다 보니 TMP의 프로그래밍 관례들도 여전히 실험 수준의 덜 성숙한 면모를 보이고 있다. 이 모든 난관은 험난하지만 ‘기존 작업을 런타임에서 컴파일 타임으로 전환’함으로써 손에 쥘 수 있는 놀라운 효율 향상은 경험해 본다면 쉽게 잊을 수 없다. 게다가 런타임에 했다면 까다롭거나 불가능했을 동작을 쉽게 표현할 수 있는 능력도 상당히 매력적이다.

템플릿 메타프로그래밍은 기존 작업을 런타임에서 컴파일 타임으로 전환하는 효과를 낸다. 따라서 TMP를 쓰면 선행 에러 탐지와 높은 런타임 효율을 손에 거머쥘 수 있다.

TMP는 정책 선택의 조합에 기반하여 사용자 정의 코드를 생성하는 데 쓸 수 있으며, 또한 특정 타입에 대해 부적절한 코드가 만들어지는 것을 막는 데도 쓸 수 있다.


참고

This post is licensed under CC BY 4.0 by the author.