C++ 템플릿 기초 이해하기

소스 코드 컴파일 환경: Windows 10 x86
디스어셈블리 도구: IDA Pro

템플릿을 처음 접한 것은 C#의 제네릭 프로그래밍을 통해서였습니다. 표면적으로는 특정 제약 조건 내에서 다양한 인자 타입에 대해 메서드를 재사용할 수 있게 해주어 코드 중복을 줄여준다는 이해를 했습니다. 이후 C++과 어셈블리를 접하면서 템플릿의 원리에 대한 의문이 생겼습니다. 왜 템플릿의 선언과 구현을 분리하면 정상적으로 링크가 되지 않는 문제가 발생하는지, 템플릿의 매칭 메커니즘은 무엇인지 궁금해졌습니다. 학습과 연구 과정에서 테스트 코드를 디스어셈블리하여 그 원리를 살펴보고, 이 내용을 정리합니다. 템플릿 컴파일 원리는 아직 완전히 이해하지 못했으므로, 추후 컴파일러 이론을 공부한 후 관련 내용을 보충할 예정입니다.

템플릿은 함수 템플릿과 클래스 템플릿으로 나뉩니다. 함수나 메서드 내부의 인자 타입이나 데이터 타입을 템플릿 매개변수로 추출한 후, 컴파일러가 구체적인 타입에 대해 해당 타입에 특화된 메서드 본문이나 함수를 생성합니다.

템플릿은 동일한 파일에 작성하는 것이 권장되며, 일반적으로 hpp 파일에 배치합니다.

템플릿 선언과 구현 분리

명시적 템플릿 인스턴스화

// mb.h
template<typename T>
T Add(T v1, T v2);
// mb.cpp
#include "mb.h"
template<typename T>
T Add(T v1, T v2)
{
    return v1 + v2;
}
template int Add(int v1, int v2); // 명시적 템플릿 인스턴스화
// 테스트 코드
int result = Add<int>(1, 2);
std::cout << result << std::endl;

명시적 템플릿 인스턴스화를 추가하지 않으면 코드가 컴파일되지 않습니다.

외부 템플릿 (extern template)

// TODO: 제 로컬 환경에서는 컴파일이 되지 않아 추후 보완 예정

템플릿 인자 자동 추론

템플릿은 자동 추론 기능을 제공합니다. 템플릿이 인자 타입을 자동으로 추론할 수 있으면, 템플릿 인자 목록에 타입을 명시할 필요가 없어져 일반 함수나 메서드처럼 사용할 수 있습니다.

템플릿 인자 추론 조건

  1. 컴파일러는 호출 시 제공된 실제 인자 목록만을 기반으로 추론합니다.
  2. 함수의 반환 타입과는 무관합니다.
  3. 추론 가능한 매개변수는 매개변수 목록의 끝에 위치해야 합니다.
template<typename T0, typename T1, typename T2, typename T3>
T2 Test(T1 v1, T3 v3) {
    T0 v0;
    T2 v2 = T2(0);
    return v2 - v1 - v3;
}

int main() {
    // <double>: T2는 double, <int>: T0는 int, <double>: T1은 double, T3는 자동 추론
    // 결과: temp = -3.0
    double temp = Test<double, int, double>(0.1f, 2.0f);
}

결론: 반환 타입 T2는 반드시 명시해야 합니다. T0는 함수 본문에서 사용되지 않지만 템플릿의 첫 번째 매개변수이므로 명시해야 합니다. T1은 추론되지 않는 이유가 명확하지 않습니다.

T0를 반드시 명시해야 함을 증명

반환 타입을 지정하고 메서드 인자 목록이 자동 추론되는 경우에도 T0 타입을 생략할 수 없습니다.

template<typename T0, typename T1, typename T2 = double, typename T3>
T2 Test(T1 v1, T3 v3) {
    // T0는 int, T1은 float, T2는 double, T3는 float
}
int main() {
    double temp = Test<int>(0.1f, 2.0f);
}

디스어셈블리 분석

template<typename T0, typename T1, typename T2 = double, typename T3>
T2 Test(T1 v1, T3 v3) {
    T0 v0;
    T2 v2 = T2(0);
    return v2 - v1 - v3;
}

int main() {
    double t1 = Test<int>(0.1f, 2.0f);
    double t2 = Test<int>(1, 2);
}

이것은 컴파일된 소스 코드입니다. 두 개의 float 인자와 두 개의 int 인자로 템플릿을 호출하여 비교합니다.

다음은 디스어셈블리 결과입니다. 함수 이름은 j_??$Test@HMNM@@YANMM@Zj_??$Test@HHNH@@YANHH@Z와 같이 나타납니다. 템플릿 함수의 이름 생성 방식은 함수 오버로딩과 유사하게 네임 맹글링(name mangling)을 사용합니다.

템플릿과 정적 변수

일반 함수 내의 정적 변수는 함수별로 독립적이며, 서로 다른 함수 간에 동일한 이름의 정적 변수가 공유되지 않습니다. 템플릿의 정적 변수는 어떻게 처리될까요?

template<typename T0, typename T1, typename T2 = double, typename T3>
T2 Test(T1 v1, T3 v3) {
    static T0 v0 = T0(0);
    std::cout << "v0 초기값: " << v0 << std::endl;
    v0 += v1;
    std::cout << "v0 결과값: " << v0 << std::endl;
    T2 v2 = T2(0);
    return v2 - v1 - v3;
}

int main() {
    double t1 = Test<int>(2.0f, 2.0f);
    double t2 = Test<int>(3.0f, 4.0f);
    double t3 = Test<int>(1, 2);
    double t4 = Test<int>(3, 4);
}

위 메서드를 수정하여 메서드 내에 정적 변수를 선언하고, 이 변수를 첫 번째 인자와 더한 값을 저장하도록 했습니다.

v0 초기값: 0
v0 결과값: 2
v0 초기값: 2
v0 결과값: 5
v0 초기값: 0
v0 결과값: 1
v0 초기값: 1
v0 결과값: 4

이를 통해 처음 두 번의 호출은 동일한 정적 변수를 공유하고, 나중 두 번의 호출은 다른 정적 변수를 공유함을 알 수 있습니다.

디스어셈블리 분석 결과, float 템플릿 호출은 동일한 메서드를 호출하고 (int도 마찬가지), 이는 일반 메서드의 처리 방식과 동일합니다.

따라서 우리가 작성한 템플릿 메서드는 컴파일러가 일반 메서드를 생성해 주는 것에 지나지 않습니다.

프렌드 템플릿 (Friend Template) (2022/12/2 추가)

프렌드는 프렌드로 선언된 클래스가 자신의 멤버에 자유롭게 접근할 수 있도록 허용합니다. 템플릿 프렌드와 일반 프렌드는 선언에 약간의 차이가 있습니다. 먼저 템플릿을 선언한 후 일반적인 템플릿 선언을 추가하면 됩니다.

class B;   // B 클래스는 이후에 선언되므로 전방 선언 필요

template<typename T>
class A
{
public:
    friend class C;   // 일반 프렌드 클래스

private:
    static int ha;
    static void A1(T t) {
        B::B1((int)t);
    }
};

class B
{
public:
    template<typename T> friend class A;  // 템플릿 프렌드 클래스

private:
    static int hb;
    static void B1(int t) {
    }
};

class C
{
public:
    void Test() {
        A<int>::A1(1);
    }
};

int main() {
    C c;
    c.Test();
}

태그: C++ 템플릿 함수 템플릿 클래스 템플릿 템플릿 인스턴스화

7월 1일 05:42에 게시됨