icon
11장 : 템플릿 프로그래밍

클래스 템플릿

독자 여러분, 지난 장에서 함수 템플릿을 통해 데이터 타입에 독립적인 함수를 정의하는 방법을 학습했습니다.

이제 템플릿의 또 다른 강력한 형태인 클래스 템플릿(Class Templates) 에 대해 알아보겠습니다.

클래스 템플릿은 함수 템플릿과 마찬가지로 타입 매개변수를 사용하여, 다양한 데이터 타입을 저장하고 처리할 수 있는 제네릭(Generic) 클래스를 만들 수 있게 해줍니다.

표준 C++ 라이브러리(STL)의 std::vector, std::list, std::map 등 대부분의 컨테이너들은 바로 이 클래스 템플릿으로 구현되어 있습니다.


왜 클래스 템플릿을 사용하는가?

함수 템플릿과 유사하게, 클래스 또한 특정 데이터 타입에 종속될 때 코드 중복 문제가 발생할 수 있습니다.

예를 들어 정수형 데이터를 저장하는 스택, 문자열 데이터를 저장하는 스택, 실수형 데이터를 저장하는 스택을 만들고 싶다면 어떻게 해야 할까요?

정수형 스택과 문자열 스택의 예시
// 정수형 스택
class IntStack {
private:
    int* arr;
    int top;
    int capacity;
public:
    // ... 생성자, push, pop 등 구현 ...
};

// 문자열 스택
class StringStack {
private:
    std::string* arr;
    int top;
    int capacity;
public:
    // ... 생성자, push, pop 등 구현 ...
};

// Double형 스택도 만들려면 또 복사/붙여넣기 해야 할까?

보시다시피, 스택의 push, pop 등의 로직은 어떤 데이터 타입을 저장하든 동일합니다.

하지만 저장하는 데이터 타입(int, std::string, double 등)이 달라지면 해당 클래스 전체를 복사하여 타입만 바꿔야 하는 비효율적인 상황이 발생합니다.

클래스 템플릿은 이러한 문제를 해결하기 위해 등장했습니다.

클래스 템플릿을 사용하면 저장할 데이터 타입을 T와 같은 타입 매개변수로 지정하여, 하나의 클래스 정의로 다양한 타입의 데이터를 다룰 수 있는 제네릭 클래스를 만들 수 있습니다.


클래스 템플릿의 정의와 사용

클래스 템플릿은 template 키워드와 함께 타입 매개변수를 사용하여 정의합니다.

클래스 이름 뒤에 <T>와 같이 타입 매개변수를 명시하며, 클래스 내부에서는 이 타입 매개변수를 마치 실제 타입처럼 사용합니다.

클래스 템플릿 정의 형식
template <typename T> // 또는 template <class T>
class 클래스이름 {
public:
    // T 타입을 사용하는 멤버 변수 및 멤버 함수
    T data;
    T getValue();
    void setValue(T value);
};

// 여러 개의 타입 매개변수 사용 가능
template <typename KeyType, typename ValueType>
class MyMap {
    // KeyType과 ValueType을 사용하는 멤버
};

두 개의 서로 다른 타입의 값을 저장하는 Pair 클래스를 만들어 봅시다.

Pair 클래스 템플릿 예시
#include <iostream>
#include <string>

// 클래스 템플릿 정의
template <typename T1, typename T2>
class Pair {
private:
    T1 first;
    T2 second;

public:
    // 생성자
    Pair(T1 f, T2 s) : first(f), second(s) {
        std::cout << "Pair 객체 생성.\n";
    }

    // Getter 함수 (const 멤버 함수)
    T1 getFirst() const { return first; }
    T2 getSecond() const { return second; }

    // Setter 함수
    void setFirst(T1 f) { first = f; }
    void setSecond(T2 s) { second = s; }

    void display() const {
        std::cout << "First: " << first << ", Second: " << second << std::endl;
    }
};

int main() {
    // 1. int와 double 타입을 사용하는 Pair 객체 생성
    // 컴파일러가 Pair<int, double> 클래스를 인스턴스화
    Pair<int, double> p1(10, 20.5);
    p1.display(); // 출력: First: 10, Second: 20.5

    // 2. std::string과 char 타입을 사용하는 Pair 객체 생성
    Pair<std::string, char> p2("Hello", 'W');
    p2.display(); // 출력: First: Hello, Second: W

    // 3. Pair 템플릿을 중첩하여 사용
    Pair<int, Pair<std::string, double>> p3(1, Pair<std::string, double>("Nested", 3.14));
    p3.display(); // 출력: First: 1, Second: First: Nested, Second: 3.14

    std::cout << "p3.getFirst(): " << p3.getFirst() << std::endl;
    std::cout << "p3.getSecond().getFirst(): " << p3.getSecond().getFirst() << std::endl;

    return 0;
}

Pair 클래스 템플릿은 T1T2라는 두 개의 타입 매개변수를 가집니다.

main 함수에서 Pair<int, double> p1과 같이 클래스 이름을 사용할 때 <int, double>과 같이 실제 타입을 명시해야 합니다.

컴파일러는 이 정보를 바탕으로 Pair 클래스의 특정 버전(예: Pair<int, double>)을 생성(인스턴스화)합니다.


클래스 템플릿의 선언과 정의 분리

함수 템플릿과 마찬가지로 클래스 템플릿도 일반적으로 선언과 정의를 분리하여 헤더 파일(.h)과 소스 파일(.cpp)에 작성할 수 있습니다.

하지만 클래스 템플릿은 함수 템플릿보다 조금 더 복잡한 규칙을 가집니다.

일반적인 권장 사항

  • 클래스 템플릿의 선언과 정의를 모두 헤더 파일에 포함하는 것이 가장 일반적이고 권장되는 방법입니다.
  • .cpp 파일에 정의를 분리할 경우, 컴파일러가 템플릿을 인스턴스화할 때 필요한 모든 정보를 .cpp 파일에서 찾을 수 없어 링커 오류가 발생할 수 있습니다. 이를 해결하려면 .cpp 파일에서 사용할 모든 가능한 템플릿 인스턴스를 명시적으로 인스턴스화하거나, 템플릿 정의를 헤더 파일에 두어야 합니다.

예시 (모범 사례): 모든 정의를 헤더 파일에 포함

MyContainer.h
#pragma once
#include <iostream>
#include <vector> // std::vector 사용 예시

// 클래스 템플릿 선언 및 정의
template <typename T>
class MyContainer {
private:
    std::vector<T> elements; // T 타입의 요소를 저장하는 vector
    int capacity;

public:
    MyContainer(int cap) : capacity(cap) {
        elements.reserve(capacity); // 미리 메모리 할당
        std::cout << "MyContainer<" << typeid(T).name() << "> 생성, 용량: " << capacity << std::endl;
    }

    void add(const T& item) {
        if (elements.size() < capacity) {
            elements.push_back(item);
            std::cout << "항목 추가: " << item << std::endl;
        } else {
            std::cerr << "컨테이너가 가득 찼습니다!\n";
        }
    }

    T get(int index) const {
        if (index >= 0 && index < elements.size()) {
            return elements[index];
        } else {
            std::cerr << "오류: 인덱스 범위를 벗어났습니다.\n";
            // 실제 코드에서는 예외 처리 또는 다른 오류 반환 방식 사용
            return T(); // T의 기본 생성자로 생성된 값 반환
        }
    }

    void displayAll() const {
        std::cout << "컨테이너 내용: [";
        for (size_t i = 0; i < elements.size(); ++i) {
            std::cout << elements[i] << (i == elements.size() - 1 ? "" : ", ");
        }
        std::cout << "]\n";
    }

    // 소멸자도 템플릿 클래스 내에 정의
    ~MyContainer() {
        std::cout << "MyContainer<" << typeid(T).name() << "> 소멸.\n";
    }
};
main.cpp
#include "MyContainer.h" // 모든 클래스 템플릿 정의가 포함됨

int main() {
    MyContainer<int> intContainer(5); // int 타입 컨테이너
    intContainer.add(10);
    intContainer.add(20);
    intContainer.add(30);
    intContainer.displayAll(); // 출력: 컨테이너 내용: [10, 20, 30]
    std::cout << "인덱스 1의 값: " << intContainer.get(1) << std::endl; // 출력: 인덱스 1의 값: 20

    std::cout << "--------------------------------\n";

    MyContainer<std::string> stringContainer(3); // std::string 타입 컨테이너
    stringContainer.add("Apple");
    stringContainer.add("Banana");
    stringContainer.add("Cherry");
    stringContainer.add("Durian"); // 컨테이너가 가득 찼습니다!
    stringContainer.displayAll(); // 출력: 컨테이너 내용: [Apple, Banana, Cherry]

    std::cout << "--------------------------------\n";

    // 동적 할당
    MyContainer<double>* doubleContainer = new MyContainer<double>(2);
    doubleContainer->add(1.1);
    doubleContainer->add(2.2);
    doubleContainer->displayAll();
    delete doubleContainer; // 소멸자 호출

    return 0;
}

비타입 템플릿 매개변수

템플릿 매개변수는 타입(typename T) 외에 정수, 열거형, 포인터, 참조 등의 비타입 값도 가질 수 있습니다.

이는 주로 배열의 크기나 다른 상수 값을 컴파일 시점에 지정할 때 유용합니다.

비타입 템플릿 매개변수 형식
template <typename T, int N> // N은 정수형 비타입 매개변수
class MyStaticArray {
private:
    T arr[N]; // 크기가 N인 T 타입 배열
public:
    // ...
};
고정 크기 스택 클래스 템플릿
#include <iostream>
#include <string>

template <typename T, int MAX_SIZE> // T: 타입, MAX_SIZE: 정수형 비타입 매개변수
class FixedSizeStack {
private:
    T elements[MAX_SIZE]; // 컴파일 시점에 크기가 결정되는 배열
    int top;

public:
    FixedSizeStack() : top(-1) {
        std::cout << "FixedSizeStack<" << typeid(T).name() << ", " << MAX_SIZE << "> 생성.\n";
    }

    void push(const T& item) {
        if (top < MAX_SIZE - 1) {
            elements[++top] = item;
            std::cout << "Push: " << item << std::endl;
        } else {
            std::cerr << "스택이 가득 찼습니다!\n";
        }
    }

    T pop() {
        if (top >= 0) {
            T item = elements[top--];
            std::cout << "Pop: " << item << std::endl;
            return item;
        } else {
            std::cerr << "스택이 비어 있습니다!\n";
            return T(); // T의 기본 생성자로 생성된 값 반환
        }
    }

    bool isEmpty() const { return top == -1; }
    bool isFull() const { return top == MAX_SIZE - 1; }
};

int main() {
    FixedSizeStack<int, 5> intStack; // int형, 최대 5개 저장 가능한 스택
    intStack.push(10);
    intStack.push(20);
    intStack.pop(); // 출력: Pop: 20
    intStack.push(30);
    intStack.push(40);
    intStack.push(50);
    intStack.push(60); // 출력: 스택이 가득 찼습니다!

    std::cout << "--------------------------------\n";

    FixedSizeStack<std::string, 3> stringStack; // string형, 최대 3개 저장 가능한 스택
    stringStack.push("A");
    stringStack.push("B");
    stringStack.push("C");
    stringStack.pop(); // 출력: Pop: C
    stringStack.pop(); // 출력: Pop: B
    stringStack.pop(); // 출력: Pop: A
    stringStack.pop(); // 출력: 스택이 비어 있습니다!

    return 0;
}

클래스 템플릿의 특수화 (심화)

때로는 특정 타입에 대해 템플릿 클래스가 다른 동작을 하도록 만들고 싶을 때가 있습니다.

이를 템플릿 특수화(Template Specialization) 라고 합니다.

클래스 템플릿 특수화 예시
// 일반 템플릿 클래스
template <typename T>
class MyClass { /* ... */ };

// 특정 타입 (예: int)에 대한 부분 특수화 또는 완전 특수화
template <> // 완전 특수화
class MyClass<int> { /* int 타입에 대한 특별한 구현 */ };

템플릿 특수화는 복잡하고 고급 개념이므로, 현재 장에서는 개념만 소개하고 자세한 내용은 이후 고급 템플릿에서 다루겠습니다.