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

[C++] 템플릿 인스턴스화(Instantiation)

by 별준 2023. 1. 11.

References

  • Ch14, C++ Templates The Complete Guide

Contents

  • On-Demand Instantiation
  • Lazy Instantiation
  • The C++ Instantiation Model
  • Implementation Schemes
  • Explicit Instantiation
  • Compile-Time if Statements

템플릿 인스턴스화(instantiation)는 제너릭 템플릿 정의로부터 타입, 함수, 변수를 생성하는 과정을 말합니다. C++ 템플릿의 인스턴스화 개념은 간단하지만 다소 얽혀져있습니다. 이는 템플릿에 의해 생성된 실체(entities)의 정의가 단 한 곳의 소스 코드로 한정되는 것이 아니기 때문입니다. 템플릿의 위치, 템플릿이 사용된 곳, 템플릿 인자가 정의된 위치 등등이 생성된 실체(entity)의 의미에 영향을 줍니다.

 

이번 포스팅에서는 템플릿을 적절하게 사용할 수 있도록 소스 코드를 구성하는 방식과 대부분의 C++ 컴파일러가 템플릿 인스턴스화를 처리하기 위해 사용하는 방법들에 대해 살펴보겠습니다.

 


On-Demand Instantiation

C++ 컴파일러가 템플릿 특수화(specialization)를 만나면 템플릿 파라미터에 제공된 인자들을 대체하여 특수화를 생성합니다 (특수화 specialization이라는 용어는 일반적으로 템플릿의 특정 인스턴스인 실체를 가리킬 때 사용됩니다). 이는 자동적으로 발생하기 때문에 클라이언트 코드 또는 템플릿 정의에서 따로 지시해야되는 것은 없습니다. 이러한 on-demand 인스턴스화 특성 때문에 C++ 템플릿은 다른 초기 컴파일 언어에서 제공하는 유사 기능과 다르다고 할 수 있으며, implicit(암묵적) 인스턴스화 또는 automatic(자동) 인스턴스화라고 부르기도 합니다.

 

on-demand 인스턴스화를 위해서는 템플릿을 사용하는 시점에서 컴파일러가 템플릿에 대한 전체 정의(선언만 있으면 안됨)와 그 멤버의 일부에 대해 액세스할 수 있어야 합니다. 아래의 소스 코드를 살펴보겠습니다.

template<typename T> class C;   // #1 declaration only
C<int>* p = 0;                  // #2 fine: definition of C<int> not needed

template<typename T>
class C {
public:
    void f();                   // #3 member declaration
};                              // #4 class template definition completed

void g(C<int>& c)               // #5 use class template declaration only
{
    c.f();                      // #6 use class template definition
}                               //    will need definition of C::f()
                                //    in this translation unit

template<typename T>
void C<T>::f()                  // required definition due to #6
{
}

소스코드의 #1 지점에서는 템플릿의 선언만 사용할 수 있으며, 이러한 선언을 전방 선언(forward declaration)이라고 합니다. 일반적인 클래스의 경우에서처럼 이 타입에 대한 포인터나 참조자를 선언할 때는 클래스 템플릿의 정의가 있어야만 하는 것 아니므로, #2에서와 같이 사용할 수 있습니다. 또한 함수 g의 파라미터 타입에 템플릿 C의 전체 정의가 필요하진 않습니다. 하지만 해당 컴포넌트가 템플릿 특수화의 정확한 크기를 알아야 하거나 그러한 특수화의 멤버에 접근해야 한다면 영역 내에 전체 템플릿 정의가 있어야 합니다. 따라서, #6 지점에서는 클래스 템플릿 정의를 알고 있어야 합니다(visible). 그렇지 않다면 멤버가 존재하는지, 접근은 가능한지를 컴파일러가 검증할 수 없습니다.

 

아래 코드에서는 C<void>의 크기가 필요하기 때문에 클래스 템플릿의 인스턴스화가 필요합니다.

C<void>* p = new C<void>;

위의 경우 컴파일러가 new 표현식에서 얼마만큼의 공간을 할당해야 하는지 알아야 하기 때문에 C<void>의 크기를 결정하려면 인스턴스화가 필요합니다. 사실 위 예제의 템플릿에서는 T가 어떤 타입이든 간에 C<X>는 빈 클래스이므로 템플릿의 크기가 달라지지는 않습니다. 하지만 컴파일러는 인스턴스화를 실행합니다. 또한 이 예제에서 C<void>가 접근 가능한 기본 생성자를 갖고 있는지 결정하고 C<void>가 new나 delete라는 연산자를 멤버 연산자로 선언하지는 않았는지 확인하기 위해 인스턴스화해야 합니다.

 

클래스 템플릿의 멤버에 대한 접근의 필요성이 항상 소스 코드에 명시적으로 보여지는 것은 아닙니다. 예를 들면, C++ overload resolution은 후보 함수의 파라미터에 대한 클래스 타입을 알고 있어야 합니다.

template<typename T>
class C {
public:
    C(int); // a constructor that can be called with a single parameter
            // may be used for implicit conversions
};

void candidate(C<double>);  // #1
void candidate(int);        // #2

int main()
{
    candidate(42);  // both previous function declarations can be called
}

candidate(42) 호출은 #2에서 오버로딩된 선언을 호출하는 것으로 해석됩니다. 하지만 #1 지점에서의 선언도 candidate(42)라는 호출에 대한 후보인지 알아보기 위해 인스턴스화됩니다. 여기서 42는 암묵적으로 C<double> 타입의 rvalue로 변환될 수 있습니다. 하지만 이번 예제처럼 둘 다 정확히 일치하지만 암묵적 변환이 필요한 쪽을 선택하지는 않습니다. 따라서, 인스턴스화 없이 호출을 처리할 수 있으면 그렇게 처리한다는 점에 주의해야 합니다.

 


Lazy Instantiation

지금까지 살펴본 예제들의 요구 사항은 템플릿이 아닌 클래스를 사용할 때의 요구 사항과 크게 다르지 않습니다. 대부분 클래스 타입이 완전(complete)해야 합니다. 템플릿의 경우 컴파일러가 클래스 템플릿 정의에서 이러한 완전한 정의를 생성합니다.

 

그렇다면 얼마나 많은 템플릿이 인스턴스화될까요?

막연하게 이야기하자면 실제로 필요한 만큼이라고 말할 수 있습니다. 다시 말해 컴파일러는 템플릿을 인스턴스화할 때 "lazy"해야 한다는 것을 뜻합니다. 그렇다면 레이지 인스턴스화에 대해서 살펴보도록 하겠습니다.

 

Partial and Full Instantiation

앞서 살펴본 것처럼 컴파일러는 클래스나 함수 템플릿의 정의 전체를 치환할 필요는 없습니다. 아래 예를 살펴봅시다.

template<typename T> T f(T p) { return 2*p; }
decltype(f(2)) x = 2;

위 예제에서 decltype(f(2))로 나타난 타입 때문에 함수 템플릿 f()를 완전히 인스턴스화할 필요는 없습니다. 따라서, 컴파일러는 f()의 'body'가 아닌 선언만 치환해도 됩니다. 이런 것을 부분 인스턴스화(partial instantiation)이라고 합니다.

 

비슷하게, 클래스 템플릿의 인스턴스가 완전한(complete) 타입일 필요가 없는 인스턴스를 참조한다면, 컴파일러는 클래스 템플릿 인스턴스에 대한 완전한 인스턴스화를 수행할 필요가 없습니다. 아래 예제 코드를 살펴보겠습니다.

template<typename T> class Q {
    using Type = typename T::Type;
};
Q<int>* p = 0; // OK: the body of Q<int> is not substituted

T가 int라면, T::Type은 말이되지 않으므로 Q<int> 전체를 인스턴스화하면 에러가 발생합니다. 하지만, 위 코드에서 Q<int>는 완전할 필요가 없기 때문에 전체 인스턴스화가 발생하지 않고 에러도 뱉지 않습니다. 하지만 에러가 발생할 여지는 여전히 남아있습니다.

 

변수 템플릿 역시 'full'과 'partial' 인스턴스화로 구분될 수 있습니다.

template<typename T> T v = T::default_value();
decltype(v<int>) s; // OK: initializer of v<int> not instantiated

v<int> 전체를 인스턴스화하면 에러가 발생하지만 변수 템플릿 인스턴스의 타입만 있으면 되기 때문에 전체 인스턴스화는 필요하지 않습니다.

 

별칭 템플릿(alias templates)에는 이를 구분하지 않으며, 치환하는 방법은 오직 하나 입니다.

 

C++에서 전체 또는 부분 인스턴스화를 명시하지 않는다면 기본적으로 전체 인스턴스화를 의미합니다.

 

Instantiated Components

클래스 템플릿이 암묵적으로 (full) 인스턴스화될 때, 멤버의 각 선언 또한 인스턴스화됩니다. 하지만 멤버의 각 정의는 인스턴스화되지 않습니다. 즉, 멤버는 부분적으로 인스턴스화됩니다. 물론 몇 가지 예외는 있습니다. 먼저, 클래스 템플릿에 이름이 없는 공용체(union)이 있다면, 그 공용체에 속하는 멤버들도 인스턴스화됩니다. 또한 가상 멤버 함수에도 예외가 있습니다. 이들의 정의는 클래스 템플릿 인스턴스화의 결과에 따라 인스턴스화될 수도 있고 되지 않을 수도 있습니다. 많은 컴파일러들의 구현에서는 사실 가상 함수 정의를 인스턴스화하는데, 가상 호출 메커니즘을 위한 내부 구조에는 링크될 수 있는 실체로 존재하는 가상 함수가 필요하기 때문입니다.

 

기본 함수 호출 인자는 템플릿을 인스턴스화할 때 따로 고려됩니다. 특히 기본 인자는 함수에서 실제로 사용되지 않는다면 인스턴스화되지 않습니다. 즉, 함수가 호출될 때마다 항상 기본 인자 대신 명시적인 다른 인자가 제공된다면 기본 인자는 인스턴스화되지 않습니다.

 

유사하게, 예외 명세와 기본 멤버 초기화자(initializer) 또한 필요하지 않다면 인스턴스화되지 않습니다.

 

아래 코드는 위에서 설명한 일부 규칙들에 대한 예제 코드입니다.

template<typename T>
class Safe {};

template<int N>
class Danger {
    int arr[N]; // OK here, although would fail for N <= 0
};

template<typename T, int N>
class Tricky {
public:
    void noBodyHere(Safe<T> = 3);   // OK until usage of default value results in an error
    void inclass() {
        Danger<N> noBoomYet;        // OK until inclass() is used with N <= 0
    }
    struct Nested {
        Danger<N> pfew;             // OK until Nested is used with N <= 0
    };
    union {                         // due anonymous union:
        Danger<N> anonymous;        // OK until Tricky is instantiated with N <= 0
        int align;
    };
    void unsafe(T (*p)[N]);         // OK until Tricky is instantiated with N <= 0
    void error() {
        Danger<-1> boom;            // always ERROR (which not all compilers detect)
    }
};

마지막 error() 멤버 함수는 제 PC에서 에러를 발생시키지 않았습니다.

 

표준 C++ 컴파일러는 문법과 일반적인 의미상의 제약 조건을 체크하기 위해 템플릿의 정의를 평가합니다. 이 과정에서 컴파일러는 템플릿 파라미터를 포함하는 제약사항을 체크할 때 '최상의 조건'을 가정합니다. 예를 들어, 위 코드에서 멤버인 Danger::arr에서 파라미터 N은 0이나 음수가 될 수도 있습니다. 하지만 컴파일러는 유효한 파라미터가 들어올 것이라고 가정합니다. 따라서, inclass(), struct Nested와 익명의 공용체 정의도 문제가 되지 않습니다.

 

동일한 이유로 멤버 unsafe(T (*p)[N])의 선언도 문제가 되지 않습니다 (N이 아직 치환되지 않은 상태에서).

 

멤버 함수인 noBodayHere()에 대한 기본 인자 특수화 선언(=3)은 Safe<>라는 템플릿이 정수로 초기화되지 않기 때문에 이상할 수 있지만, 기본 인자가 필요없거나 Safe<T>가 정수 값으로 초기화될 수 있도록 특수화될 것이라고 가정합니다.

하지만 error() 멤버 함수의 정의는 템플릿이 인스턴스화되지 않더라도 에러인데, 여기서 Danger<-1>을 사용했기 때문입니다. Danger<-1>에 대한 완전한 정의가 필요한데, 그러면 크기가 음수인 배열을 정의하려고 하기 때문입니다. 하지만, 표준에서 이런 코드가 유효하지 않다고 명확히 선언하고 있지만, 동시에 템플릿 인스턴스화가 실제로 사용되지 않는 한 컴파일러가 에러에 대한 진단 메세지를 내보내지 않는 것도 허용합니다. 예를 들어, GCC와 Visual C++은 이 에러를 발생시키지 않습니다.

 

이번에는 아래의 정의를 추가해봅시다.

Tricky<int, -1> inst;

컴파일러는 위의 정의를 보고 T를 int로 치환하고 N은 -1로 치환하여 템플릿 Tricky<>를 (full) 인스턴스화합니다. 모든 멤버의 정의가 필요한 것은 아니지만 기본 생성자와 소멸자는 분명히 호출됩니다. 따라서 이 두 함수의 정의는 어떤 방식으로든 사용할 수 있어야 하는데, 여기서는 암묵적으로 생성되므로 사용할 수 있습니다. 앞서 언급했듯이 Tricky<int, -1>의 멤버는 부분 인스턴스화됩니다. 따라서, 이 과정 중에 에러가 발생할 수 있습니다.

예를 들어, unsafe(T(*p)[N])이라는 선언은 요소의 수가 음수인 배열을 생성하므로 당연히 에러입니다. 이와 비슷하게 멤버 anonymous에서는 Danger<-1> 타입이 완전할 수 없으므로 에러가 발생합니다. 이와 반대로 inclass() 멤버 함수와 struct Nested의 정의는 인스턴스화되지 않으며, 여기서 필요한 Danger<-1> 타입의 완전한 정의가 필요없기 때문에 에러가 발생하지 않습니다.

 

 

템플릿을 인스턴스화할 때는 가상 멤버의 정의도 제공해야만 하는데, 그렇지 않으면 링커 에러가 발생합니다.

template<typename T>
class VirtualClass {
public:
    virtual ~VirtualClass() {}
    virtual T vmem();   // Likely ERROR if instantiated without definition
};

int main()
{
    VirtualClass<int> inst;
}

 

마지막으로 operator->에 대해 살펴봅시다.

template<typename T>
class C {
public:
    T operator->();
};

보통 operator->는 operator->가 적용된 포인터 타입이나 다른 클래스 타입을 반환해야만 합니다. 이 규칙에 따르면 C<int>를 인스턴스화할 때 operator->의 반환형으로 int를 선언하기 때문에 에러가 발생할 수 있습니다. 그러나 일부 자연스러운 클래스 템플릿 정의는 이러한 종류의 정의를 사용하므로 C++ 언어 규칙은 조금 더 유연합니다. 결론적으로 위 예제에서 템플릿 선언으로 인해 operator-> 연산자의 반환형이 int라 하더라도 에러가 발생하지 않습니다.

 


The C++ Instantiation Model

템플릿 인스턴스화는 대응하는 템플릿 실체(entity)로부터 템플릿 파라미터를 적절히 치환하여 일반 타입, 함수, 또는 변수를 얻는 과정입니다. 표현은 굉장히 직관적이지만 실제 구현에서는 많은 세부 사항들이 공식적으로 구성되어 있어야 합니다.

 

Two-Phase Lookup

템플릿을 파싱할 때는 종속된 이름을 해석할 수 없습니다. 대신 인스턴스화 시점에서 다시 룩업됩니다. 다만, 처음 템플릿의 선언/정의를 봤을 때 최대한 많은 에러를 걸러내기 위해 먼저 종속되지 않은 이름들을 룩업합니다. 이와 같은 이유 때문에 2단계 룩업(two-phase lookup)이라는 개념이 나왔습니다. 첫 번째 단계는 템플릿을 파싱하는 것이고, 두 번째 단계는 인스턴스화입니다.

 

  1. 첫 번째 단계에서 템플릿을 파싱할 때, ordinary lookup rules와 만약 적용 가능하다면 argument-dependent lookup(ADL)을 사용하여 종속되지 않은 이름을 룩업합니다. 한정되지 않은(unqualified) 종속된 이름 역시 동일한 방식으로 룩업되지만 이 룩업의 결과는 인스턴스화에서의 룩업이 수행될 때까지 끝나지 않습니다.
  2. 두 번째 단계, 즉, 인스턴스화 지점(POI; point of instantiation)이라고 부르는 지점에서 템플릿을 인스턴스화할 때, 종속 및 한정된 이름을 룩업하며, ADL을 수행하여 한정되지 않은 종속된 이름에 대해 추가적으로 룩업합니다.

한정되지 않은 종속된 이름을 위해 처음 ordinary lookup을 사용하여 템플릿의 이름인지 결정하는데, 아래의 예시를 살펴보겠습니다.

namespace N {
    template<typename> void g() {}
    enum E{ e };
}

template<typename> void f() {}

template<typename T> void h(T p) {
    f<int>(p);  // #1
    g<int>(p);  // #2 ERROR
}

int main()
{
    h(N::e);    // calls template h with T = N::E
}

#1에서 f 다음에 '<'가 오는 것을 보면 컴파일러는 '<'가 템플릿을 위한 꺽쇠의 시작인지 '보다 작다'를 의미하는 기호인지 결정해야 합니다. f가 템플릿의 이름으로 알려져 있는지 아닌지에 따라 결정되는데, 위 예제에서 일반적인 방식(ordinary lookup)으로 f의 선언을 찾아보면 템플릿이기 때문에 '<'를 템플릿의 시작을 의미하는 꺽쇠로 생각하고 파싱해 나갑니다.

 

하지만 #2에서는 ordinary lookup으로 g라는 템플릿을 찾을 수 없기 때문에 에러가 발생합니다. 이번에는 '<'를 '보다 작다' 기호로 취급하는데, 따라서 문법 에러가 발생하게 됩니다. 이 부분을 에러없이 넘어갔다면 T = N::E로 h를 인스턴스화하는 동안 ADL을 사용하여 N::g라는 템플릿을 찾을 수 있었겠지만(N은 E와 관련된 네임스페이스이기 때문) h의 제너릭 정의를 성공적으로 파싱하기 전까지는 이를 알 수 없습니다.

 

Points of Instantiation

위의 내용으로부터 C++ 컴파일러가 템플릿 실체의 선언이나 정의에 접근해야 하는 지점에 대해 살펴봤습니다. POI(point of instantiation)은 코드 구성이 템플릿 특수화를 참조할 때 대응하는 템플릿의 정의가 이 특수화를 생성하기 위해 인스턴스화되는 방식으로 생성됩니다. POI는 치환된 템플릿이 삽입될 소스 코드의 위치를 말합니다. 아래 예제 코드를 살펴보겠습니다.

class MyInt {
public:
    MyInt(int i);
};

MyInt operator-(MyInt const&);

bool operator>(MyInt const&, MyInt const&);

using Int = MyInt;

template<typename T>
void f(T i)
{
    if (i > 0) {
        g(-i);
    }
}
// #1
void g(Int)
{
    // #2
    f<Int>(42); // point of call
    // #3
}
// #4

C++ 컴파일러가 f<Int>(42)라는 호출을 본다면 컴파일러는 템플릿 f에서 T를 MyInt로 치환하여 인스턴스해야 한다는 것을 알게 됩니다. 따라서, POI가 생성됩니다. #2와 #3은 호출 지점과 매우 가깝지만 C++에서 ::f<Int>(Int)의 정의를 이 위치에 삽입할 수 없기 때문에 POI가 될 수 없습니다. #4 지점은 #1과 달리 함수 g(Int)가 보일 수 있기 때문에 템플릿 종속 호출인 g(-i)를 해석할 수 있습니다. 하지만 #1 지점이 POI였다면 g(Int)가 아직 보이지 않기 때문에 해당 호출은 해석될 수 없습니다. 다행히 C++은 함수 템플릿 특수화를 참조하기 위해 POI를 정의하는데, 이는 해당 참조를 포함하는 가장 가까운 네임스페이스 영역 선언이나 정의의 뒤 입니다. 따라서, 위 예제 코드에서 POI는 바로 #4가 됩니다.

 

여기서 왜 굳이 int 타입이 아닌 MyInt를 사용했을까요?

그 이유는 POI에서 수행되는 두 번째 룩업은 오직 ADL 뿐이기 때문입니다. int는 연관된 네임스페이스를 가지지 않기 때문에 POI 룩업이 일어나지 않으며, 결국 함수 g를 찾지 못합니다. 따라서, Int에 대한 alias를 아래와 같이 변경하면 컴파일되지 않습니다.

using Int = int;

 

다른 예제로 다시 한 번 살펴보겠습니다.

template<typename T>
void f1(T x)
{
    g1(x);  // #1
}

void g1(int)
{}

int main()
{
    f1(7);  // ERROR: g1 not found!  
}
// #2 POI for f1<int>(int)

위 코드에서 f1(7)에 대한 호출로 인해 f1<int>(int)에 대한 POI가 main 함수 바로 뒤 #2 지점에 만들어지게 됩니다. 이 인스턴스화에서 함수 g1()에 대한 룩업이 중요한데, 템플릿 f1의 정의를 처음 만났을 때, 한정되지 않은 이름인 g1은 함수 호출에서 종속된 인자(x의 타입은 템플릿 파라미터 T에 종속됨)를 갖는 함수의 이름이므로 g1() 역시 종속됩니다. 따라서 g1은 #1 지점에서 ordinary lookup rules에 따라 룩업됩니다. 하지만 이때 볼 수 있는 g1()은 이 지점에서 없습니다. POI인 #2 지점에서 관련된 네임스페이스와 클래스 내에서 다시 룩업하는데 유일한 인자 타입이 int이고 int와 관련된 네임스페이스나 클래스는 없습니다. 그래서 POI에서 ordinary lookup 방식으로는 g1()을 찾을 수 있더라도 g1은 절대 찾지 못하게 됩니다.

 

변수 템플릿의 POI는 함수 템플릿과 비슷한 방식으로 처리됩니다.

클래스 템플릿 특수화에서는 상황이 약간 다른데, 예제를 통해 살펴보겠습니다.

template<typename T>
class S {
public:
    T m;
};
// #1
unsigned long h()
{
    // #2
    return (unsigned long)sizeof(S<int>);
    // #3
}
// #4

함수 내 영역인 #2와 #3에서는 네임스페이스 영역의 S<int>의 정의가 나타날 수 없기 때문에 POI가 될 수 없습니다. 만약 함수 템플릿 인스턴스에 대한 규칙을 따른다면, POI는 #4 지점이 됩니다. 하지만 sizeof(S<int>) 표현식에서 S<int>의 크기가 #4 지점에 도달할 때까지 결정되지 않으므로 해당 표현식은 유효하지 않습니다. 따라서, 해당 인스턴스에 대한 참조를 포함하는 가장 가까운 네임스페이스 영역 또는 선언 바로 전 지점에서 생성된 클래스 인스턴스의 참조를 위한 POI가 생성됩니다. 위의 경우, #1 지점이 POI가 됩니다.

 

템플릿이 실제로 인스턴스화될 때는 추가적인 인스턴스화가 필요합니다. 아래의 예제 코드를 살펴보겠습니다.

template<typename T>
class S {
public:
    using I = int;
};

// #1
template<typename T>
void f()
{
    S<char>::I var1 = 41;
    typename S<T>::I var2 = 42;
}

int main()
{
    f<double>();  
}
// #2 : #2a, #2b

위에서 봤듯이 f<double>를 위한 POI는 #2 지점에서 생성됩니다. 또한, 함수 템플릿 f()는 #1 지점에서 생성된 클래스 특수화 S<char>를 참조합니다. S<T> 또한 참조하지만, 여기서는 종속되어 있기 때문에 실제로 인스턴스화는 할 수 없습니다. 하지만 f<double>을 인스턴스화했다면 S<double>의 정의도 인스턴스화할 필요가 있습니다.

이러한 secondary 또는 transitive POI는 조금 다른 방식으로 정의됩니다. 함수 템플릿의 경우, secondary POI는 정확히 primary POI와 동일합니다. 클래스인 경우, secondary POI는 primary POI의 직전에 나타납니다. 위 예제에서는 f<double>의 POI가 #2b 지점에 있을 수 있고, 직전 위치인 #2a 지점에 S<double>을 위한 secondary POI가 있을 수 있습니다.

 

그렇다면 S<double>과 S<char>의 차이는 무엇일까요?

일반적으로 한 번역 단위 내에서 같은 인스턴스에 대한 여러 POI를 가질 수 있습니다. 클래스 템플릿 인스턴스의 경우, 각 번역 단위에서 첫 번째 POI만 유지되고, 나머지는 무시됩니다(POI로 취급하지 않음). 함수나 변수 템플릿의 경우, 모든 POI들을 유지합니다. 어떤 경우에서도 ODR(one definition rule)에 따라 유지하고 있는 POI들은 모두 같은 인스턴스가 만들어져야 합니다. 그러나 C++ 컴파일러가 ODR을 위반하는지를 검증하거나 진단할 필요는 없습니다. 그렇기 때문에 C++ 컴파일러는 nonclass POI를 하나 선택하여 실제 인스턴스화를 수행하며, 다른 POI로 인해 다른 인스턴스가 발생할 걱정은 없습니다.

 

실제로 많은 컴파일러들은 번역 단위의 끝에 이르기 전까지는 인라인이 아닌 함수 템플릿에 대해 실제 인스턴스화를 수행하지 않습니다. 몇몇 인스턴스화는 지연될 수 없는데, 반환 타입을 추론하는데 필요한 인스턴스화나 함수가 conxtexpr이어서 상수 결과를 평가해야 하는 경우에 이에 해당합니다.

 

The Inclusion Model

POI를 마주할 때마다 대응되는 템플릿의 정의는 반드시 어떤 방식으로든 접근 가능해야 합니다. 클래스 특수화에서 이는 클래스 템플릿 정의가 반드시 번역 단위 내 앞부분에 나타나야 한다는 것을 의미합니다. 함수와 변수 템플릿(그리고 클래스 템플릿의 멤버 함수 및 정적 데이터 멤버)의 POI에 대해서도 필요합니다. 그리고 일반적으로 템플릿 정의는 #include를 통해 헤더 파일을 추가하는 방식으로 번역 단위에 포함됩니다.

 

inclusion model을 사용하면 프로그래머는 모든 템플릿 정의를 헤더 파일에 두어 발생하는 모든 POI에서 사용할 수 있도록 해야하지만, explicit instantiation declaration(명시적 인스턴스화 선언)과 explicit instantiation definitions(명시적 인스턴스화 정의)를 사용하여 명시적으로 인스턴스화를 관리할 수도 있습니다. 논리적으로 자명한 것은 아니고 대부분의 프로그래머는 automatic instantiation 메커니즘을 더 선호합니다. 다만, 자동 메커니즘으로 구현할 때 발생하는 문제 중 하나는 함수나 변수 템플릿(또는 클래스 템플릿 인스턴스의 멤버 함수나 정적 데이터 멤버)에 대한 특수화가 여러 번역 단위에 걸쳐 POI를 여러 개 만들 수 있다는 것입니다. 이에 대한 해결 방법은 자세히 따로 자세히 다루지는 않겠습니다.

 

 


Implementation Schemes

지금부터는 inclusion model을 지원하는 C++ 구현 방식과 종류에 대해 간단히 살펴보겠습니다. 모든 구현은 두 가지 클래식 컴포넌트에 의존하는데, 바로 컴파일러와 링커입니다. 컴파일러는 소스 코드를 오브젝트 파일(object files)로 번역하며, 오브젝트 파일에는 symbolic annotations의 기계 코드(machine code)를 포함하고 있습니다. 링커는 오브젝트 파일들을 결합하고 이들이 포함하고 있는 심볼들의 상호 참조를 해석하여 실행 파일이나 라이브러리를 생성합니다. C++을 다른 방식으로도 구현할 수 있지만 여기서는 inclusion model을 사용한다고 가정하겠습니다 (예를 들어, C++ 인터프리터가 있음).

 

여러 번역 단위에서 클래스 템플릿 특수화가 사용된다면 컴파일러는 모든 번역 단위에서 인스턴스화를 반복할 것 입니다. 클래스 정의는 low-level 코드를 직접 생성하지 않기 때문에 몇 가지 문제가 발생할 수 있습니다. 클래스 정의는 오직 C++ 구현 내부에서 다양한 표현식과 선언을 검증하고 해석할 때만 사용됩니다. 따라서, 클래스 정의에 대한 다중 인스턴스화는 다양한 번역 단위에서 헤더 파일 inclusion을 통한 클래스 정의의 multiple inclusion과 다를 바가 없습니다.

 

그러나 (noninline) 함수 템플릿을 인스턴스화하려면 상황은 다릅니다. 만약 일반적인 noninline 함수의 다중 정의를 제공한다면, ODR을 위반하게 됩니다. 예를 들어, 아래와 같은 두 개의 파일을 컴파일하고 링크한다고 가정해봅시다.

// ==== a.cpp:
int main()
{}

// ==== b.cpp:
int main()
{}

C++ 컴파일러는 어떠한 문제도 없이 두 모듈을 분리하여 컴파일하며, 이들은 실제로 유효한 C++ 번역 단위입니다. 하지만 이 둘을 링크하려고 하면 에러가 발생되는데, 중복 정의는 허용되지 않기 때문입니다.

 

이와 반대로, 템플릿의 경우는 조금 다릅니다.

// ==== t.hpp:
// common header (inclusion model)
template<typename T>
class S {
public:
    void f();
};

template<typename T>
void S<T>::f()    // member definition
{}

void helper(S<int>*);

// ==== a.cpp:
#include "t.hpp"
void helper(S<int>* s)
{
    s->f(); // #1 first point of instantiation of S::f
}

// ==== b.cpp:
#include "t.hpp"
int main()
{
    S<int> s;
    helper(&s);
    s.f();  // #2 second point of instantiation of S::f
}

링커가 클래스 템플릿의 인스턴스화된 멤버 함수를 일반 함수나 일반 멤버 함수처럼 취급한다면, 컴파일러는 두 POI(#1과 #2)들 중 단 하나에서만 코드를 생성하도록 주의를 기울여야 합니다. 그러기 위해서 컴파일러는 하나의 번역 단위에서 다른 번역 단위로 정보를 옮겨야 하고, 이는 템플릿이 도입되기 전에는 C++ 컴파일러가 해본 적이 없는 동작입니다. 여기서는 C++ 컴파일러 구현상 잘 알려진 3가지 해결 방법이 있습니다. 그 내용에 대해서는 여기서 따로 다루지는 않겠습니다 (굳이 자세히 알고 있지 않아도 될 것 같아서 패스합니닷..!).

  • Greedy Instantiation
  • Queried Instantiation
  • Iterated Instantiation

 

템플릿 인스턴스화를 통해 생성된 모든 링크 가능한 실체에서 동일한 문제가 발생한다는 점에 주목해야 합니다. 인스턴스화된 함수 템플릿과 멤버 함수 템플릿뿐만 아니라 인스턴스화된 정적 데이터 멤버와 변수 템플릿에서도 동일합니다.

 


Explicit Instantiation

템플릿 특수화를 위한 명시적인 POI를 생성할 수 있습니다. 이러한 생성 방식을 explicit instantiation directive(명시적 인스턴스화 지시자)라고 부릅니다. 이 방식은 template 키워드 뒤에 인스턴스화되어야 할 특수화의 선언을 넣는 방식입니다. 아래 예를 살펴보겠습니다.

template<typename T>
void f(T)
{}

// four valid explicit instantiation
template void f<int>(int);
template void f<>(float);
template void f(long);
template void f(char);

여기서 템플릿 인자는 추론될 수 있습니다.

 

클래스 템플릿의 멤버 또한 아래와 같은 방식으로 명시적으로 인스턴스화될 수 있습니다.

temlate<typename T>
class S {
public:
    void f() {
    }
};

template void S<int>::f();
template class S<void>;

 

Manual Instantiation

 

자동 템플릿 인스턴스화는 빌드 시간에 부정적인 영향을 미칩니다. 빌드 시간을 개선하기 위해 프로그램에 필요한 템플릿 특수화를 한곳에 모아 수동으로 인스턴스화시키고, 다른 번역 단위에서는 관련 인스턴스화를 억제하는 기법도 있습니다. 이러한 억제가 이식 가능하려면 명시적 인스턴스화된 번역 단위를 제외한 나머지 번역 단위에서는 아예 템플릿 정의를 제공하지 않아야 합니다. 아래 예제 코드를 살펴보겠습니다.

// ==== translation unit 1:
template<typename T> void f(); // no definition: prevents instantiation
                               // in this translation unit
void g()
{
    f<int>();
}

// ==== translation unit 2:
template<typename T> void f()
{
    // implemenation
}

template void f<int>(); // manual instantiation

void g();

int main()
{
    g();
}

첫 번째 번역 단위에서 컴파일러는 함수 템플릿 f의 정의를 볼 수 없기 때문에 f<int>의 인스턴스화를 생성하지 못합니다. 두 번째 번역 단위에서는 명시적 인스턴스화 정의를 통해 f<int>의 정의를 제공합니다. 만약 이게 없다면 링크할 때 에러가 발생합니다.

 

수동 인스턴스화에는 한계가 분명합니다. 어떤 실체가 인스턴스화될 지 주의깊게 추적해야만 합니다. 규모가 큰 프로젝트라면 이는 큰 부담이 됩니다. 따라서 추천하는 방법은 아닙니다.

 

하지만 몇 가지 장점도 있는데, 프로그램의 요구 사항에 맞춰 인스턴스화를 할 수 있습니다. 특히 헤더의 크기가 지나치게 커지지 않게 됩니다.

 

템플릿 정의를 제3의 소스 파일(일반적으로 .tpp를 사용)에 둔다면 수동 인스턴스화 부담을 조금 덜 수 있습니다. 예를 들어, 위의 예제 코드의 함수 f를 아래와 같이 나눌 수 있습니다.

// ==== f.hpp:
template<typename T> void f(); // no definition: prevents instantiation

// ==== t.hpp:
#include "f.hpp"
template<typename T> void f() // definition
{
    // implementation
}

// ==== f.cpp:
#include "f.hpp"
template void f<int>(); // manual instantiation

 

Explicit Instantiation Declarations

중복되는 automatic instantiation을 제거하는데 특화된 방식에는 explicit instantiation declaration(명시적 인스턴스화 선언)이 있습니다. 명시적 인스턴스화 선언은 extern 키워드가 앞에 붙은 explicit instantiation directive(명시적 인스턴스화 지시자)를 가리키는 것 입니다. 명시적 인스턴스화 선언이 있다면 프로그램의 어딘가에 같은 이름의 템플릿 특수화가 정의되어 있다는 것을 선언하는 것이므로, 일반적으로 그 이름의 템플릿 특수화에 대한 자동 인스턴스화가 억제됩니다. 일반적이라고 이야기 했는데, 여기에는 예외가 많기 때문입니다.

  • 인라인 함수는 인라인으로 확장시키기 위해 여전히 인스턴스화될 수 있습니다. 하지만 목적 코드가 따로 생성되지 않습니다.
  • 추론된 auto나 decltype(auto) 타입을 갖는 변수나 추론된 리턴 타입을 갖는 함수는 자신의 타입을 결정하기 위해 인스턴스화됩니다.
  • 변수의 값이 상수 표현식으로 사용될 수 있는 변수는 그 값을 계산하기 위해 인스턴스화될 수 있습니다.
  • 참조자 타입의 변수는 이들이 참조하는 실체를 해석하기 위해 인스턴스화될 수 있습니다.
  • 클래스 템플릿과 별칭 템플릿은 결과 타입을 검사하기 위해 인스턴스화될 수 있습니다.

명시적 인스턴스화 선언을 사용하면 f의 선언을 t.hpp 헤더 내에 제공할 수 있고, 일반적으로 사용되는 특수화에 대한 자동 인스턴스화를 아래와 같이 억제할 수 있습니다.

// ==== t.hpp:
template<typename T> void f()
{}

extern template void f<int>();    // declared but not defined
extern template void f<float>();  // declared but not defined

// ==== t.cpp:
template void f<int>();   // definition
template void f<float>(); // definition

각 명시적 인스턴스화 선언은 반드시 대응하는 명시적 인스턴스화 정의와 쌍을 이루어야 합니다. 그리고 반드시 정의는 선언 뒤에 따라와야 합니다. 만약 정의를 생략하면 링커 에러가 발생합니다.

 

명시적 인스턴스화 선언을 사용하면 특정 특수화가 많은 번역 단위에서 컴파일이나 링크 시간을 줄일 수 있습니다. 새로운 특수화가 필요할 때마다 명시적 인스턴스화 정의 리스트를 수동으로 갱신해야 하는 수동 인스턴스화와 달리 명시적 인스턴스화 선언은 언제든지 최적화 기법으로 사용할 수는 있습니다. 하지만 컴파일 과정에서 얻는 이득은 그다지 극적이지는 않습니다.

 


Compile-Time if Statements

템플릿을 작성할 때 매우 유용한 새로운 명령문인 compile-time if가 C++17에서 추가되었습니다. 또한, 이 때문에 인스턴스화 과정에 새로운 단계가 추가되었습니다.

 

아래 예제 코드를 통해 기본적인 동작 방식을 살펴보겠습니다.

template<typename T>
bool f(T p)
{
    if constexpr (sizeof(T) <= sizeof(long long)) {
        return p > 0;
    }
    else {
        return p.compare(0) > 0;
    }
}

bool g(int n)
{
    return f(n); // OK
}

컴파일 타임 if는 if 키워드 바로 뒤에 constexpr 키워드가 뒤따라오는 if문 입니다. 그 뒤에 나오는 괄호에 있는 조건은 반드시 상수 bool 값이어야 합니다. 그러면 컴파일러는 어떤 branch를 선택해야 하는지 알 수 있습니다. 선택되지 않은 branch를 discarded branch라고 부릅니다. 특별히 흥미로운 부분은 템플릿을 인스턴스화하는 동안 발생한다는 것입니다. 버려진 branch는 인스턴스화되지 않습니다. 위 예제에서 f(T)가 T=int로 인스턴스화되었으며, 따라서 else branch가 버려지게 됩니다. 만약 branch가 버려지지 않았다면 인스턴스화되었을 것이고 p.compare(0)라는 표현식으로 인해 에러가 발생했을 것 입니다.

 

C++17 이전에서는 이러한 에러가 발생하지 않으려면 아래와 같이 명시적으로 템플릿을 특수화하거나 오버로딩해야 했습니다.

template<bool b>
struct Dispatch {                // only to be instantiated with b is false
    static bool f(T p) {         // (due to next specialization for true)
        return p.compare(0) > 0;
    }
};

template<>
struct Dispatch<true> {
    static bool f(T p) {
        return p > 0;
    }
};

template<typename T>
bool f(T p)
{
    return Dispatch<sizeof(T) <= sizeof(long long)>::f(p);
}

bool g(int n)
{
    return f(n); // OK
}

명확하게 if constexpr 구문이 훨씬 더 명확하고 간결하게 원래 의도를 나타낼 수 있다는 것을 볼 수 있습니다. 하지만, 이전 함수 정의에서는 항상 전체 구현이 인스턴스화되지만, 이제는 일부분은 인스턴스화되지 않도록 인스턴스화 단위를 구체화하는 구현이 필요합니다.

 

또한, 함수 파라미터 팩을 처리하는 재귀를 표현할 때 if constexpr을 사용하면 매우 편리합니다.

template<typename Head, typename... Remainder>
void f(Head&& h, Remainder&&... r) {
    doSomething(std::forward<Head>(h));
    if constexpr (sizeof...(r) != 0) {
        // handle the remainder recursively (perfectly forwarding the arguments)
        f(std::forward<Remainder>(r)...);
    }
}

위 코드에서 if constexpr을 사용하지 않는다면 재귀 끝내기 위해 f() 템플릿에 대한 또 다른 오버로딩이 필요합니다.

 

템플릿이 아닐 때에도 if constexpr을 사용하면 독특한 효과를 얻을 수 있습니다.

void h();
void g() {
    if constexpr (sizeof(int) == 1) {
        h();
    }
}

대부분의 플랫폼에서 g() 안에서의 조건문은 false 이므로 h()에 대한 호출은 버려집니다. 이에 따라 h()는 정의될 필요가 없습니다. 만약 constexpr이 없다면 링크 시 에러가 발생합니다.

 


In the Standard Library

C++ 표준 라이브러리에는 몇 개의 기본 타입에서만 일반적으로 사용되는 템플릿이 많습니다. 예를 들어, std::basic_string 클래스 템플릿은 거의 대부분 char 또는 wchar_t에 대해 사용됩니다. 따라서 표준 라이브러리 구현에서는 흔히 사용되는 경우를 위해 명시적 인스턴스화 선언을 사용하는 것이 일반적입니다.

namespace std {
    template<typename charT, typename traits = char_traits<charT>,
        typename Allocator = allocator<charT>>
    class basic_string {
        ...
    };
    
    extern template class basic_string<char>;
    extern template class basic_string<wchar_t>;
}

표준 라이브러리를 구현하느 소스 파일에는 위와 같은 명시적 인스턴스화 정의가 있으며, 이런 일반적인 구현은 표준 라이브러리를 사용하는 모든 사용자가 공유할 수 있습니다. 유사하게 basic_iostream과 basic_istream 등과 다양한 스트림 클래스에서 자주 사용됩니다.

댓글