[Effective C++]typename의 두 가지 의미를 제대로 파악하자

템플릿과 일반화 프로그래밍, 두 번째 이야기

Posted by SungBeom on December 30, 2019 · 12 mins read

class vs typename

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

1
2
template<class T> class Widget;     // "class"를 사용합니다.
template<typename T> class Widget;  // "typename"을 사용합니다.
cs

결론은 차이가 없습니다. 템플릿의 타입 매개변수를 선언할 때는 class와 typename의 뜻이 완전히 똑같습니다. 그렇다고 언제까지나 class와 typename이 C++ 앞에서 동등한 것만은 아닙니다. typename을 쓰지 않으면 안 되는 때가 분명히 있습니다. 이때가 언제인지를 제대로 알아보려면, 일단 템플릿 안에서 여러분이 참조할 수 있는 이름의 종류가 두 가지라는 것부터 이야기를 해야 합니다.

typename을 사용해야 하는 경우

함수 템플릿이 하나 있다고 가정합시다. 이 템플릿은 STL과 호환되는 컨테이너를 받아들이도록 만들어졌고, 이 컨테이너에 담기는 객체는 int에 대입할 수 있습니다. 이 템플릿이 하는 일은 컨테이너에 담긴 원소들 중 두 번째 것의 값을 출력하는 것뿐입니다. 아래 코드는 컴파일이 되지 않는 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
template<typename C>                                // 컨테이너에 들어 있는
void print2nd(const C& container)                   // 두 번째 원소를 출력합니다.
{
    if (container.size() >= 2) {
        C::const_iterator iter(container.begin());  // 첫째 원소에 대한 반복자를 얻습니다.
                                                    // iter를 두 번째 원소로 옮깁니다.
        ++iter;
        int value = *iter;                          // 이 원소를 다른 int로 복사합니다.
        std::cout << value;                         // 이 int를 출력합니다.
    }
}
cs

iter의 타입은 보시다시피 C::const_iterator인데, 템플릿 매개변수인 C에 따라 달라지는 타입입니다. 템플릿 내의 이름 중에 이렇게 템플릿 매개변수에 종속된 것을 가리켜 의존 이름(dependent name)이라고 합니다. 의존 이름이 어떤 클래스 안에 중첩되어 있는 경우가 있는데, 이 경우의 이름을 중첩 의존 이름(nested dependent name)이라 부르겠습니다. 위의 코드에서 C::const_iterator는 중첩 의존 이름입니다. 사실, 정확히 하자면 중첩 의존 타입 이름(nested dependent type name)이라고 말해야 맞습니다. print2nd 함수에서 쓰이는 또 하나의 지역 변수, value는 템플릿 매개변수가 어떻든 상관없는 int 타입입니다. 이러한 이름은 비의존 이름(non-dependent name)이라고 합니다.

코드 안에 중첩 의존 이름이 있으면 컴파일러가 구문분석을 할 때 골치 아픈 일이 생길 수 있습니다.

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

언뜻 보면, C::const_iterator에 대한 포인터인 지역 변수로서 x를 선언하고 있는 것 같습니다. 그런데 C::const_iterator가 타입이 아니라면, 위의 코드는 지역 변수를 선언한 것이 아니라 그냥 C::const_iterator와 x를 피연산자로 한 곱셈 연산이 되어버립니다. 따라서 C++ 구문분석기를 작성하는 개발자는 가능한 모든 입력에 대해 온 촉각을 곤두세워야 합니다.

C의 정체가 무엇인지 다른 곳에서 알려 주지 않으면, C::const_iterator가 진짜 타입인지 아닌지를 알아낼 방법은 없습니다. 이때 C++은 모호성을 해결하기 위해 어떤 규칙을 하나 사용합니다. 이 규칙에 의하면, 구문분석기는 템플릿 안에서 중첩 의존 이름을 만나면 프로그래머가 타입이라고 알려 주지 않은 한 그 이름이 타입이 아니라고 가정하게 되어 있습니다. 다시 말해, 중첩 의존 이름은 기본적으로 타입이 아닌 것으로 해석됩니다.

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());  // 이 이름은 타입이 아닌
        ...                                         // 것으로 가정합니다.
    }
}
 
cs

iter의 선언이 선언으로서 의미가 있으려면 C::const_iterator가 반드시 타입이어야 하는데, 우리는 C++ 컴파일러에게 타입이라고 알려 주지 않았으니, C++은 제멋대로 타입이 아닌 것으로 가정해 버린 거죠. 이 상황을 바로 잡으려면 C++에게 C::const_iterator가 타입이라고 말해 주면 됩니다. 바로 이 경우에 C::const_iterator 앞에다가 typename이라는 키워드를 붙여 놓습니다. 어느 때이든지 템플릿 안에서 중첩 의존 이름을 참조할 경우에는, 이제 그 이름 앞에 typename 키워드를 붙여 주는 것을 잊지 마세요(예외가 있긴 합니다).

1
2
3
4
5
6
7
8
template<typename C>  // 이 코드는 제대로 된 C++ 코드입니다.
void print2nd(cosnt C& container)
{
    if (container.size() >= 2) {
        typename C::const_iterator iter(container.begin());
        ...
    }
}
cs

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

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

이 예제에서 C는 중첩 의존 타입 이름이 아니기 때문에, 컨테이너를 선언할 때는 typename을 이 앞에 붙이면 안 됩니다. 반면에 C::iterator는 분명히 중첩 의존 이름이기 때문에, 이 앞에는 typename이 반드시 붙어야만 합니다.

typename을 사용하지 않는 예외

중첩 이름 앞에 typename을 붙여 주어야 하는 규칙에 예외가 하나 있는데, 중첩 의존 타입 이름이 기본 클래스의 리스트에 있거나 멤버 초기화 리스트 내의 기본 클래스 식별자로 있을 경우에는 typename을 붙여 주면 안 된다는 것입니다.

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

마지막으로 typename에 관한 예제를 하나만 더 보도록 합시다. 여러분이 반복자를 매개변수로 받는 어떤 함수 템플릿을 만들고 있는데, 매개변수로 넘어온 반복자가 가리키는 객체의 사본을 temp라는 이름의 지역 변수로 만들어 놓고 싶다고 가정합시다.

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

std::iterator_traits<IterT>::value_type 문장은 IterT 객체가 가리키는 것과 똑같은 타입의 지역 변수(temp)를 선언한 후, iter가 가리키는 객체로 그 temp를 초기화하는 문장입니다. 만일 IterT가 vector<int>::iterator라면 temp의 타입은 int이고, IterT가 list<string>::iterator라면 temp의 타입은 string이겠지요. 어쨌든 여기서 std::iterator_traits<IterT>::value_type은 중첩 의존 타입 이름이므로(value_type이 iterator_traits<IterT> 안에 중첩되어 있고, IterT는 템플릿 매개변수이니까요), 이 이름 앞에는 typename을 써 주어야 합니다.

위 코드에서 std::iterator_traits<IterT>:;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);
    ...
}
cs

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


정리

템플릿 매개변수를 선언할 때, class 및 typename은 서로 바꾸어 써도 무방합니다.
중첩 의존 타입 이름을 식별하는 용도에는 반드시 typename을 사용합니다. 단, 중첩 의존 이름이 기본 클래스 리스트에 있거나 멤버 초기화 리스트 내의 기본 클래스 식별자로 있는 경우에는 예외입니다.