Post

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

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

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

개요


C++ 템플릿을 만들려고 헀던 원래 동기는 정말 단순했다. 사용자가 타입에 관계없는 컨테이너를 만들어 사용할 때 타입 안정성을 부여할 수 있도록 하는 것이다. 이를테면 vector, list, map 같은 것 말이다. 그런데 템플릿을 이런 데 저런 데 가지고 노는 사람들이 늘어나면서, 이것으로 할 수 있는 일들도 차츰 더 많이 발견되었다. 템플릿을 통해 원래의 목적을 달성한 컨테이너는 그 자체만으로도 훌륭했지만, 템플릿의 한 응용 분야로 파생된 일반화 프로그래밍(generic programming, 조작할 객체의 타입과 상관없이 코드를 작성하도록 하는 개념)은 그것 못지않게 더 기가 막혔다. for_each, find, merge 등의 STL 알고리즘이 바로 이 일반화 프로그래밍의 멋진 본보기이다. 급기야, C++ 템플릿 메커니즘은 그 자체가 튜링 완전(Turing-complete)성을 갖고 있다는 사실까지 밝혀진다. 다시 말해 C++ 템플릿을 사용하면 계산 가능한(computable) 어떤 값도 계산할 수 있다는 것이다. 이 사실이 퍼지면서 템플릿 메타프로그래밍이라는 새로운 영역이 탄생하게 된다. 짤막하게 말하자면 컴파일러 내부에서 실행되고 컴파일 과정이 끝날 때 실행을 멈추는 또 하나의 프로그램을 만드는 것이다. 실제로 요즘엔 컨테이너가 C++ 템플릿 분야에서 차지하는 부분은 정말 작다. 이렇듯 템플릿의 활용 영역은 다양해졌지만, 어떤 영역을 막론하고 템플릿 기반 프로그래밍이란 것의 밑바닥에는 몇 개 되지 않는 핵심 아이디어가 단단하게 자리하고 있다. 이들 아이디어가 무엇인지를 조명하는 것이 이 장의 목표이다.

이 장을 읽는다고 해서 별안간 초절정 템플릿 프로그래밍 내공이 생기는 것은 아니다. 템플릿 프로그래밍에 있어 탄탄한 기초를 기르는 데는 충분할 것이라고 확신한다. 또한 현재 우리가 자신 있게 구사할 수 있는 템플릿 프로그래밍 영역을 원하는 만큼 넓히는 데 필요한 지식도 얻을 수 있을 것이다. 시작하자.


항목 41 : 템플릿 프로그래밍의 천릿길도 암시적 인터페이스와 컴파일 타입 다형성부터


객체 지향 프로그래밍의 세계를 회전시키는 축은 명시적 인터페이스(explicit interface)런타임 다형성(runtime polymorphism)이다. 예를 들어 아래의 클래스가 주어졌고,

1
2
3
4
5
6
7
8
9
10
class Widget
{
public:
    Widget();
    virtual ~Widget();
    virtual std::size_t size() const;
    virtual void normalize();
    void swap(Widget& other);
    ...
};

다음의 함수가 있을 때,

1
2
3
4
5
6
7
8
9
void doProcessing(Widget& w)
{
    if (w.size() > 10 && w != someNastyWidget)
    {
        Widget temp(w);
        temp.normalize();
        temp.swap(w);
    }
}

doProcessing 함수 안에 있는 w에 대해 말할 수 있는 부분은 다음과 같다.

  • w는 Widget 타입으로 선언되었기 때문에, w는 Widget 인터페이스를 지원해야 한다. 이 인터페이스는 소스 코드에서 찾으면 이것이 어떤 형태인지를 확인할 수 있으므로 이런 인터페이스를 가리켜 명시적 인터페이스라고 한다. 다시 말해, 소스 코드에 명시적으로 드러나는 인터페이스를 일컫는다.

  • Widget의 멤버 함수 중 몇 개는 가상 함수이므로, 이 가상 함수에 대한 호출은 런타임 다형성에 의해 이루어진다. 다시 말해, 특정 함수에 대한 실제 호출은 w의 동적 타입을 기반으로 프로그램 시행 중, 즉 런타임에 결정된다.

템플릿과 일반화 프로그래밍의 세계에는 뿌리부터 뭔가 다른 부분이 있다. 명시적 인터페이스 및 런타임 다형성은 그대로 존재하긴 하지만 중요도는 사뭇 떨어진다. 이 바닥에서 활개를 치고 다니는 주인공은 암시적 인터페이스(implicit interface)컴파일 타임 다형성(compile-time polymorphism)이다. 어떻게 그렇게 되는지를 확인하는 방법은 그리 어렵지 않다. doProcessing 함수를 함수 템플릿으로 바꿀 때 무슨 일이 생기는지만 보면 된다.

1
2
3
4
5
6
7
8
9
10
template <typename T>
void doProcessing(T& w)
{
    if (w.size() > 10 && w != someNastyWidget)
    {
        T temp(w);
        temp.normalize();
        temp.swap(w);
    }
}

이번에는 doProcessing 템플릿 안의 w에 대해서 어떻게 말할 수 있을까?

  • w가 지원해야 하는 인터페이스는 이 템플릿 안에서 w에 대해 실행되는 연산이 결정한다. 지금의 경우를 보면, size/normalize/swap 멤버 함수를 지원해야 하는 쪽은 w의 타입, 즉 T이다. 그뿐 아니라 T는 복사 생성자도 지원해야 하고, 부등 비교를 위한 연산도 지원해야 한다. 뒷부분을 보면 알겠지만 이 이야기는 사실 완벽하게 정확한 것은 아니다. 하지만 지금은 이 정도면 충분하다고 생각한다. 진짜 중요한 점은, 이 템플릿이 제대로 컴파일되려면 몇 개의 표현식이 유효해야 하는데 이 표현식들은 바로 T가 지원해야 하는 암시적 인터페이스라는 점이다.

  • w가 수반되는 함수 호출이 일어날 때, 이를테면 operator> 및 operator!= 함수가 호출될 때는 해당 호출을 성공시키기 위해 템플릿의 인스턴스화가 일어난다. 이러한 인스턴스화가 일어나는 시점은 컴파일 도중이다. 인스턴스화를 진행하는 함수 템플릿에 어떤 템플릿 매개변수가 들어가느냐에 따라 호출되는 함수가 달라지기 때문에, 이것을 가리켜 컴파일 타임 다형성이라고 한다.

혹시 템플릿을 한 번도 사용해 본 적이 없더라도, 런타임 다형성과 컴파일 타임 다형성의 차이를 헷갈리는 사람은 없어야 한다. 오버로드된 함수 중 지금 호출할 것을 골라내는 과정(컴파일 중에 일어남)과 가상 함수 호출의 동적 바인딩(런타임 중에 일어남)의 차이점과도 흡사하니까이다. 반면, 명시적 인터페이스와 암시적 인터페이스의 차이는 템플릿에서 처음 접하는 부분일 것 같은데, 좀더 자세하게 알아보자.

명시적 인터페이스는 대개 함수 시그니처로 이루어진다. 알다시피 시그니처는 함수의 이름, 매개변수 타입, 반환 타입 등을 통틀어서 부르는 용어이다. 그러니까 앞에서 본 예제 클래스인 Widget의 public 인터페이스는,

생성자, 소멸자를 포함해서 size/normalize/swap 함수 그리고 이들의 매개변수 타입, 반환 타입 및 각 함수의 상수성 여부로 이루어져 있는 것이다. (게다가 컴파일러가 자동을 만들어 놓은 복사 생성자와 복사 대입 연산자도 포함된다). typedef 타입이 있을 경우에는 이것도 포함될 수 있다. 그리고 데이터 멤버의 경우에는 데이터 멤버를 private 멤버로 만들라는 항목 22의 이야기를 대담하게 어겨 주셨다고 해도 시그니처에 들어가지 않는다.

반면, 암시적 인터페이스는 사뭇 다르다. 함수 시그니처에 기반하고 있지 않다는 것이 가장 큰 차이점이다. 암시적 인터페이스를 이루는 요소는 유효 표현식(expression)이다. doProcessing 템플릿의 시작부분에 있는 조건문을 다시 한 번 보자.

1
if (w.size() > 10 && w != someNastyWidget)

이 T에서 제공될 암시적 인터페이스에는 다음과 같은 제약이 걸릴 것이다.

  • 정수 계열의 값을 반환하고 이름이 size인 함수를 지원해야 한다.
  • T 타입의 객체 둘을 비교하는 operator!= 함수를 지원해야 한다.(여기서 someNastyWidget이 T 타입의 객체라고 가정한다).

실제로는 연산자 오버로딩의 가능성이 있기 때문에 T는 위의 두 가지 제약 중 어떤 것도 만족시킬 필요가 없다. 우선 첫 번째 제약부터 보자. T가 size 멤버 함수를 지원해야 하는 것은 맞다. 기본 클래스로부터 물려받을 수도 있다는 점은 간과할 순 없더라도 말이다. 그렇다고 해도 이 멤버 함수는 수치 타입을 반환할 필요까지는 없다. 심지어, operator>의 정의에 필요한 타입도 반환할 필요가 없다. size 멤버 함수가 해야 하는 일은 별거 없다. 그저 어떤 X 타입의 객체와 int가 함께 호출될 수 있는 operator>가 성립될 수 있도록, X 타입의 객체만 반환하면 임무 종료인 것이다. 한편, operator> 함수는 반드시 X 타입의 매개변수를 받아들일 이유가 없다. 그 이유는, 이 함수가 Y 타입의 매개변수를 받도록 정의되어 있고 X 타입에서 Y 타입으로 암시적인 변환이 가능하다면 만사 오케이이기 때문이다.

첫 번째 제약의 경우와 비슷한 이치로, T가 operator!= 함수를 지원해야 한다는 두 번째 제약도 필수 요구사항이 되지 않는다. operator!= 함수가 X 타입의 객체 하나와 Y 타입의 객체 하나를 받아들인다고 하면 이 부분은 별 걸림돌 없이 넘어갈 수 있기 때문이다. T가 X로 변환될 수 있으며 someNastyWidget이 Y로 변환되는 것이 가능하기만 하면 operator!= 함수의 호출은 유효 호출로 간주될 것이다.

여담이지만, 위의 이야기는 operator&& 함수가 오버로드될 수 있다는 가능성을 염두에 두지 않고 한 것이기 때문에, 위의 표현식에 나온 &&가 논리곱 연산의 의미가 아니라면 이야기가 완전히 달라질 것이다.

사람들은 암시적 인터페이스를 이런 방법으로 생각하자마자 머리에 쥐가 나는 경우가 대부분이지만, 사실 엄청 머리 아픈 것은 아니다. 암시적 인터페이스는 그저 유효 표현식의 집합으로 구성되어 있을 뿐이다. 표현식 자체만 뚫어지게 보면 좀 복잡해 보일 수도 있지만, 표현식에 걸리는 제약은 일반적으로 지극히 평이하다. 예를 들어, 다음 조건식에서

1
if (w.size() > 10 && w != someNastyWidget) ...

size, operator>, operator&&, operator!= 함수에 대한 제약을 일일이 집어내 보라고 하면 열 분 중 아홉 분은 난감해 하겠지만, 이 표현식에 대한 제약은 되레 집어내기가 쉽다. if 문의 조건문 부분은 불(boolean) 표현식이어야 하기 때문에, 표현식에서 쓰이는 것들이 정확히 어떤 타입인지 상관없이, 그리고 “w.size () > && w != someNastyWidget”이 정확히 어떤 값을 내놓든 간에, 이 조건식 부분의 결과 값은 bool과 호환되어야 한다. 이 제약이 바로 doProcessing 템플릿이 타입 매개변수인 T에 대해 요구하는 암시적 인터페이스의 일부이다. 그럼, 그 나머지도 궁금할텐데 마찬가지이다. 복사 생성자, normalize 그리고 swap 함수에 대한 호출이 T 타입의 객체에 대해 ‘유효’해야 한다는 것이다.

템플릿 매개변수에 요구되는 암시적 인터페이스는 클래스의 객체에 요구되는 명시적 인터페이스만큼이나 우리 피부에 가깝게 닿아 있다. 게다가 컴파일 도중에 점검된다는 점도 둘이 똑같다. 클래스에서 제공하는 명시적 인터페이스와 호환되지 않는 방법으로 그 클래스의 객체를 쓸 수 없듯이, 어떤 템플릿 안에서 어떤 객체를 쓰려고 할 때 그 템플릿에서 요구하는 암시적 인터페이스를 그 객체가 지원하지 않으면 사용이 불가능하다. (컴파일이 불가능하다)

클래스 및 템플릿은 모두 인터페이스와 다형성을 지원한다.

클래스의 경우, 인터페이스는 명시적이며 함수의 시그니처를 중심으로 구성되어 있다. 다형성은 프로그램 실행 중에 가상 함수를 통해 나타난다.

템플릿 매개변수의 경우, 인터페이스는 암시적이며 유효 표현식에 기반을 두어 구성된다. 다형성은 컴파일 중에 템플릿 인스턴스화와 함수 오버로딩 모호성 해결을 통해 나타난다.


항목 42 : typename의 두 가지 의미를 제대로 파악하자


질문 : 아래 두 템플릿 선언문에 쓰인 class와 typename의 차이점이 무엇일까?

1
2
template <class T> class Widget; // "class"를 사용
template <typename T> class Widget; // "typename"을 사용

답변 : 차이가 없다. 템플릿의 타입 매개변수를 선언할 때는 class와 typename의 뜻이 완전히 똑같다. 어떤 프로그래머 중에선 class만 고집하는 사람도 있다. 단지 타이핑하기가 편하다는 이유로 좋아한다. 그와 반대로 typename을 좋아한느 사람도 있다. 매개변수가 클래스 타입일 필요가 없다고 외치는 것 같은 강단 있는 모습이 좋아서라고 한다. 많지는 않지만, 어떤 타입도 허용되는 부분에는 typename을 쓰고 사용자 정의 타입이 쓰이는 부분에 class를 쓰는 개발자도 종종 있다. 하지만 어쨌든 C++의 관점에서 보면, 템플릿 매개변수를 선언하는 경우의 class 및 typename은 다시 말하지만 완전히 같은 의미를 지닌다.

그렇다고 언제까지나 class와 typename이 C++ 앞에서 동등한 것만은 아니다. typename을 쓰지 않으면 안 되는 때가 분명히 있다. 이때가 언제인지를 제대로 알아보려면, 일단 템플릿 안에서 우리가 참조할 수 있는 이름의 종류가 두 가지라는 것부터 이야기를 해야 할 것 같다.

함수 템플릿이 하나 있다고 가정하자. 이 템플릿은 STL과 호환되는 컨테이너를 받아들이도록 만들어졌고, 이 컨테이너는 담기는 객체는 int에 대입할 수 있다. 이 템플릿이 하는 일은 컨테이너에 담긴 원소들 중 두 번째 것의 값을 출력하는 것뿐이다. 함수 자체도 참 한심하고 만드는 방법도 존재감 부족이 이만저만 아닐 것이다. 곧 아래에서 보겠지만, 심지어 컴파일도 안 될 것이다. 하지만 이런저런 부분은 일단 전부 넘어가자. 제정신이 아닌 것은 맞지만 다 이유가 있는 거다.

1
2
3
4
5
6
7
8
9
10
template <typename C>
void print2nd(const C& container)
{
    if (container.size() >= 2)
    {
        C::const_iterator iter(container.begin());
        ++iter;
        int value = *iter;
        std::cout << value;
}

이 함수 안에서 쓰이고 있는 지역 변수 두 개를 자세히 보아야 하는데, 하나는 iter, 또 하나는 value이다. iter의 타입은 보다시피 C::const_iterator인데, 템플릿 매개변수인 C에 따라서 달라지는 타입이다. 템플릿 내의 이름 중에 이렇게 템플릿 매개변수에 종속된 것을 가리켜 의존 이름(dependent name)이라고 한다. 의존 이름이 어떤 클래스 안에 중첩되어 있는 경우가 있는데, 필자는 이 경우의 이름을 중첩 의존 이름(nested dependent name)이라고 부른다. 위의 코드에서 C::const_iterator는 중첩 의존 이름이다. 사실, 정확히 하자면 중첩 의존 타입 이름(nested dependent type name)이라고 말해야 맞다. 타입을 참조하는 중첩 의존 이름이란 뜻이다.

print2nd 함수에서 쓰이는 또 하나의 지역 변수, value는 int 타입이다. int는 템플릿 매개변수가 어떻든 상관없는 타입 이름이다. 이러한 이름은 비의존 이름(non-dependent name)이라고 한다.

코드 안에 중첩 의존 이름이 있으면 골치 아픈 일이 생길 수 있다. 바로 컴파일러가 구문분석을 할 대 애로사항이 생긴다는 것이다. 예를 들어, print2nd 함수를 이런 식으로 시작해서 바보스러움이 더 묻어나게 만들었다고 가정하자.

1
2
3
4
5
6
template<typename C>
void print2nd(const C& container)
{
    C::const_iterator * x;
    ...
}

언뜻 보면, C::const_iterator에 대한 포인터인 지역 변수로서 x를 선언하고 있는 것 같다. 하지만 주의할 게 있다! 우리 눈에 그렇게 보이는 것은 C::const_iterator가 타입이라는 사실을 “인간인 우리가 알고 있을 때만” 그렇다는 말이다. 그런데 C::const_iterator가 타입이 아니라면 어떻게 할까? 우연히 const_iterator라는 이름을 가진 정적 데이터 멤버가 C에 들어 있다고도 볼 수 있다. 그리고 x가 다른 전역 변수의 이름이라면 어떤 사태가 발생하는지 아는가? 이런 경우라면 위의 코드는 지역 변수를 선언한 것이 아니다. 그냥 C::const_iterator와 x를 피연산자로 한 곱셈 연산이란 말이다. 맛이 간 것처럼 보이겠지만, 얼마든지 가능한 일이다. C++ 구문분석기를 작성하는 개발자는 가능한 모든 입력에 대해 온 촉각을 곤두세워야 한다. 이런 돼먹지 않은 입력도 포함해서 말이다.

C의 정체가 무엇인지 다른 곳에서 알려 주지 않으면, C::const_iterator가 진짜 타입인지 아닌지를 알아낼 방법은 천채가 만든 컴파일러라고 해도 없다. print2nd 함수 템플릿이 구문분석기에 의해 처리되는 순간에도 C의 정체는 저절로 밝혀지지 않는다. 이때 C++는 모호성을 해결하기 위해 어떤 규칙을 하나 사용한다. 이 규칙에 의하면, 구문 분석기는 템플릿 안에 중첩 의존 이름을 만나면 우리가 타입이라고 알려 주지 않는 한 그 이름이 타입이 아니라고 가정하게 되어 있다. 다시 말해, 중첩 의존 이름은 기본적으로 타입이 아닌 것으로 해석된다. 예외가 하나 있긴 한데, 조금 있다가 알아보자.

이야기는 이 정도면 된 것 같다. 이제는 print2nd 템플릿의 시작 부분이 다르게 보일 것이다.

1
2
3
4
5
6
7
8
9
template<typename C>
void print2nd(const C& container)
{
    if (container.size() >= 2)
    {
        C::const_iterator iter(container.begin()); // 이 이름은 타입이 아닌 것으로 가정한다.
        ...
    }
}

이것이 제 정신으로 작성한 C++ 코드가 아닐 수 밖에 없는 이유가 이제 이해가 되는 기분이다. iter의 선언이 선언으로서 의미가 있으려면 C::const_iterator가 반드시 타입이어야 하는데, 우리는 C++ 컴파일러에게 타입이라고 알려주지 않았으니, C++는 제멋대로 타입이 아닌 것으로 가정해 버린 것이다. 이 난국을 바로 잡으려면 한 가지 방법밖엔 없다. C+에게 C::const_iterator가 타입이라고 말해 주는 것이다. 바로 이 경우에 C::const_iterator 앞에다가 typename이라는 키워드를 붙여 놓는다.

1
2
3
4
5
6
7
8
9
template<typename C>
void print2nd(const C& container)
{
    if (container.size() >= 2)
    {
        typename C::const_iterator iter(container.begin());
        ...
    }
}

이 규칙은 우리가 알고 있으면 평생 도움이 될 테니 잘 알아 두길 바란다. 어느 때이든지 템플릿 안에서 중첩 의존 이름을 참조할 경우에는, 이제 그 이름 앞에 typename 키워드를 붙여 주는 것을 잊지 말자. 예외가 있지만 조금 있다가 보자.

typename 키워드는 중첩 의존 이름만 식별하는 데 써야 한다. 그 외의 이름은 typename을 가져선 안 된다는 이야기이다. 예를 들면, 어떤 컨테이너와 그 컨테이너 내의 반복자를 한꺼번에 받아들이는 함수 템플릿을 다음과 같이 만들었을 때 그러지 말라는 것이다

1
2
3
template<typename C>                        // typename을 쓸 수 있음(class와 같은 의미)
void f(const C& container,                  // typename을 쓰면 안 됨
        typename C::const_iterator iter);   // typename을 써야 함

이 예제에서 C는 중첩 의존 타입이 아니기(즉, 템플릿 매개변수에 의존적인 어떤 것도 C를 품고 있지 않는다) 떄문에, 컨테이너를 선언할 때는 typename을 이 앞에 붙이면 안 된다. 반면에 C::const_iterator는 분명히 중첩 의존 이름이기 때문에, 이 앞에는 typename이 꼭 붙어야 한다.

“typename은 중첩 의존 타입 이름 앞에 붙여 주어야 한다”는 규칙에 예외가 하나 있다고 앞에서 말했었다. 이 예외란, 중첩 의존 타입 이름이 기본 클래스의 리스트에 있거나 멤버 초기화 리스트 내의 기본 클래스 식별자로서 있을 경우에는 typename을 붙여 주면 안 된다는 것이다. 다음의 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T>
class Derived : public Base<T>::Nested // 상속되는 기본 클래스 리스트 : typename을 쓰면 안 된다.
{
public:
    explicit Derived(int x)
    : Base<T>::Nested(x) //  멤버 초기화 리스트에 있는 기본 클래스 식별자 : typename을 쓰면 안 된다.
    {
        typename Base<T>::Nested temp; 
        // 중첩 의존 타입 이름이며, 기본 클래스 리스트에도 없고, 멤버 초기화 리스트의 기본 클래스 식별자도 아님. typename 필요
        ...
    }
    ...
};

이런 식으로 이랬다저랬다 하는 게 싫어질 것도 같지만, 일단 몇 번만 경험해 보면 크게 거슬리는 일은 없을 것이다.

마지막으로 typename에 관한 예제를 하나만 더 보도록 하자. 이 예제는 현업 코드에서 우리가 보게 될 대표적인 사례일 것이다. 우리가 반복자를 매개변수로 받는 어떤 함수 템플릿을 만들고 있는데, 매개변수로 넘어온 반복자가 가리키는 객체의 사본을 temp라는 이름의 지역 변수로 만들어 놓고 싶다고 가정하자. 그러면 다음과 비슷한 코드가 나올 것이다.

1
2
3
4
5
6
template<typename IterT>
void workWithIterator(IterT iter)
{
    typename std::iterator_traits<IterT>::value_type temp(*iter);
    ...
}

std::iterator_traits::value_type을 보고 당황하는 사람이 없었으면 좋겠다. 그저 C++ 표준의 특성정보(trait) 클래스를 사용한 것뿐이다. 우리말로 풀면 "IterT 타입의 객체로 가리키는 대상의 타입"이란 뜻이다. 이 문장은 IterT 객체가 가리키는 것과 똑같은 타입의 지역 변수(temp)를 선언한 후, iter가 가리키는 객체로 그 temp를 초기화하는 문장이다. 만일 IterT가 vector::iterator라면 temp의 타입은 int일 것이다. IterT가 list::iterator라면 temp의 타입은 string이 될 것이다. 어쨌든 여기서 std::iterator_traits::value_type은 중첩 의존 타입 이름이므로 (value_type이 iterator_traits 안에 중첩되어 있고, IterT는 템플릿 매개변수이다), 이 이름 앞에는 typename을 써 주어야 한다.

혹시 우리 중에 std::iterator_traits::value_type이 왠지 읽기가 거북하다고 느끼는 사람이 있다면 이걸 키보드로 치면 또 어떤 느낌일까라고 상상해보자. 저 이름을 두 번 이상 친다고 생각만 해도 숨이 막힐지도 모른다. 그래서 typedef 이름을 만들고 싶을 것이다. 참, 특성정보 클래스에 속한 value_type 등의 멤버 이름에 대해 typedef 이름을 만들 때는 그 멤버 이름과 똑같이 짓는 것이 관례로 되어 있다. 따라서 이런 경우에는 typedef로 정의하는 지역 이름을 대개 다음과 같이 짓는다.

1
2
3
4
5
6
7
template<typename IterT>
void workWithIterator(IterT iter)
{
    typedef typename std::iterator_traits<IterT>::value_type value_type;
    value_type temp(*iter);
    ...
}

하나씩 따로 있어야 할 것들이 “typedef typename” 형태로 나란히 있는 게 뭔가 잘못 된 것 같다고 생각할 수 있지만, 이것은 그저 중첩 의존 타입 이름을 참조하는 데 지켜야 할 규칙 때문에 생긴 부산물이며 논리적으로도 하자가 없다. 모르긴 해도 적응하는 데 그리 오래 걸리지 않을 것이다. 또 사실 이렇게 할 수밖에 없는 이유도 있다. 제정신이라면 대체 typename std::iterator_traits::value_type을 몇 번까지 또박또박 칠 수 있을까?

이야기를 끝내기 전에 한 가지만 더 알아가자. 사실, 이번 항목에 나온 typename에 관한 규칙을 얼마나 강조하느냐는 컴파일러마다 조금씩 차이가 있다. 어떤 컴파일러는 typename을 꼭 써야 하는데 빼먹은 경우를 그대로 받아들이고, 또 어떤 컴파일러는 typename이 쓰였지만 원래는 허용되지 않는 경우를 내버려 둔다. 이 외에 typename이 쓰였고 문맥상 꼭 써야 하는 부분인데도 typename을 거부하는 컴파일러도 몇 개 있다(대개 구닥다리 컴파일러다). 무슨 뜻인지 알 것이다. typename과 중첩 의존 타입 이름 사이에는 아직도 이런 미묘한 관계가 있기 때문에 프로그램을 이식할 때 다소 골치가 아플 수 있다는 것이다.

템플릿 매개변수를 선언할 때, class 및 typename은 서로 바꾸어 써도 무방하다.

중첩 의존 타입 이름을 식별하는 용도에는 반드시 typename을 사용하자. 단, 중첩 의존 이름이 기본 클래스 리스트에 있거나 멤버 초기화 리스트 내의 기본 클래스 식별자로 있는 경우에는 예외이다.


참고

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