클래스 템플릿
타입과 비타입 매개변수로 재사용 가능한 제네릭 클래스를 정의하고 구현 분리와 인스턴스화 규칙을 이해합니다.
앞 절에서는 함수 템플릿으로 데이터 타입에 독립적인 함수를 정의하는 방법을 다뤘습니다. 클래스 템플릿에서는 클래스의 상태와 멤버 함수 전체가 어떤 인수 조합에 속하는지 확인합니다. 스택 예제를 읽기 전에 배열의 인덱스, push·pop, 객체마다 별도 멤버 상태를 갖는다는 점을 떠올리세요.
이번 절에서는 타입 단위 일반화를 담당하는 클래스 템플릿(Class Templates)을 다룹니다.
클래스 템플릿은 함수 템플릿과 마찬가지로 타입 매개변수를 사용하여, 다양한 데이터 타입을 저장하고 처리할 수 있는 제네릭(Generic) 클래스를 만들 수 있게 해줍니다.
표준 C++ 라이브러리(STL)의 std::vector, std::list, std::map 등 대부분의 컨테이너들은 바로 이 클래스 템플릿으로 구현되어 있습니다.
C++ · CLASS TEMPLATE
타입 T와 크기 N의 조합마다 별도 스택 타입
FixedSizeStack<T, N>은 청사진이다. 타입 인수와 비타입 인수가 확정되고 필요한 연산이 유효할 때 서로 다른 구체 타입과 객체가 만들어진다.
- 기본 템플릿
타입 T와 정적 크기 N을 매개변수로 선언합니다.
- 인수 확정
FixedSizeStack<int, 5>처럼 타입·값 인수 조합을 고릅니다. - 요구 연산 검사
실제로 사용한 멤버가 T에 요구하는 연산을 검사합니다.
- 독립 타입 생성
예:
FixedSizeStack<int, 5>,FixedSizeStack<std::string, 3>,FixedSizeStack<double, 8>은 서로 다른 타입입니다.
C++17부터 클래스 템플릿 인수 추론을 사용할 수 있지만, deduction guide와 호출 문맥을 함께 검토한다.
왜 클래스 템플릿을 사용하는가?
함수 템플릿과 유사하게, 클래스 또한 특정 데이터 타입에 종속될 때 코드 중복 문제가 발생할 수 있습니다.
예를 들어 정수형 데이터를 저장하는 스택, 문자열 데이터를 저장하는 스택, 실수형 데이터를 저장하는 스택을 만들고 싶다면 어떻게 해야 할까요?
#include <string>
// 정수형 스택
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 키워드와 함께 타입 매개변수를 사용하여 정의합니다.
기본 템플릿 선언 앞에 타입 매개변수를 지정하며, 클래스 내부에서는 이 타입 매개변수를 마치 실제 타입처럼 사용합니다.
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 클래스를 만들어 봅시다.
#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));
// Pair 자체에는 스트림 삽입 연산자가 없으므로 중첩 값은 직접 나누어 출력합니다.
std::cout << "First: " << p3.getFirst() << ", Second: ";
p3.getSecond().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 클래스 템플릿은 T1과 T2라는 두 개의 타입 매개변수를 가집니다.
main 함수에서 Pair<int, double> p1과 같이 클래스 이름을 사용할 때 <int, double>과 같이 실제 타입을 명시할 수 있습니다. C++17부터는 적합한 생성자나 추론 가이드가 있을 때 클래스 템플릿 인수 추론도 가능합니다.
컴파일러는 이 정보를 바탕으로 Pair 클래스의 특정 버전(예: Pair<int, double>)을 생성(인스턴스화)합니다. 같은 타입의 객체 두 개도 각각 first·second를 보관합니다. 타입의 인스턴스화와 실행 중 객체의 생성은 같은 사건이 아닙니다.
중첩 예제에서는 p1·p2 생성, 안쪽 Pair 생성, 바깥 p3 생성에서 직접 정의한 생성자 안내가 합계 네 번 나옵니다. Getter의 반환 타입 T2는 참조가 아니라 값이므로 p3.getSecond()는 안쪽 Pair의 복사 값을 돌려줍니다. 이 복사에는 위에서 직접 정의한 두 인자 생성자가 다시 호출되는 것이 아닙니다.
스스로 확인하기
원래 Pair 정의는 그대로 두고, 새 main에서 Pair<int, double> a(10, 20.5), b(10, 20.5);를 만든 뒤 a의 first만 99로 바꾸세요. 두 객체의 display 결과를 예측하세요. 이어 원 main의 p3에 대해 p3.getSecond().setSecond(9.9);를 호출한 다음 다시 p3.getSecond().display();를 하면 내부 저장값도 9.9가 될까요?
타입과 객체, 복사 반환의 풀이
a는 First: 99, Second: 20.5이고 b는 First: 10, Second: 20.5입니다. 같은 Pair 타입을 사용해도 저장 상태는 독립입니다. p3의 Getter는 복사 값을 반환하므로 임시 복사에만 9.9를 썼고 p3 안쪽은 Nested·3.14 그대로입니다. 내부 값을 실제로 바꾸려면 안쪽 Pair를 복사 변수로 받은 뒤 그 변수에 setSecond를 하고, 바깥 p3의 setSecond에 변경한 Pair를 전달할 수 있습니다.
클래스 템플릿의 선언과 정의 분리
함수 템플릿과 마찬가지로 클래스 템플릿도 일반적으로 선언과 정의를 분리하여 헤더 파일(.h)과 소스 파일(.cpp)에 작성할 수 있습니다.
하지만 클래스 템플릿은 함수 템플릿보다 조금 더 복잡한 규칙을 가집니다. 선언은 멤버 이름과 타입을 알려 주고, 정의는 실제 본문을 제공합니다. 아래 예제에서는 MyContainer.h와 main.cpp를 같은 폴더에 각각 저장합니다. 헤더를 별도 실행 프로그램처럼 빌드하는 것이 아니라 main.cpp가 헤더를 포함합니다.
일반적인 권장 사항- 클래스 템플릿의 선언과 정의를 모두 헤더 파일에 포함하는 것이 가장 일반적이고 권장되는 방법입니다.
.cpp파일에 정의를 분리할 경우, 컴파일러가 템플릿을 인스턴스화할 때 필요한 모든 정보를.cpp파일에서 찾을 수 없어 링커 오류가 발생할 수 있습니다. 이를 해결하려면.cpp파일에서 사용할 모든 가능한 템플릿 인스턴스를 명시적으로 인스턴스화하거나, 템플릿 정의를 헤더 파일에 두어야 합니다.
#pragma once
#include <iostream>
#include <string>
#include <typeinfo>
#include <stdexcept>
#include <vector> // std::vector 사용 예시
// 클래스 템플릿 선언 및 정의
template <typename T>
class MyContainer {
private:
std::vector<T> elements; // T 타입의 요소를 저장하는 vector
int capacity;
public:
MyContainer(int cap) : capacity(cap) {
if (cap < 0) throw std::invalid_argument("capacity must be nonnegative");
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 (std::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";
}
};#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;
}따라 해보기 위 두 파일을 저장한 폴더에서 C++17로 빌드하고 실행합니다. 다음은 g++ 명령의 Linux 예입니다. Windows에서는 생성한 실행 파일의 경로 표기를 환경에 맞게 바꿉니다.
g++ -std=c++17 main.cpp -o container_demo
./container_demoint 컨테이너는 10·20·30을 저장하고 get(1)은 20을 돌려줍니다. 문자열 컨테이너에는 Apple·Banana·Cherry가 남고, 초과한 Durian은 저장되지 않습니다. typeid가 출력하는 타입 이름은 구현에 따라 달라지므로 특정 철자를 정답으로 외우지 않습니다. 표준 출력과 표준 오류를 섞어 캡처할 때 두 스트림의 표시 순서도 결과 값과 구분하세요.
reserve(capacity)는 재할당을 줄일 저장 공간을 준비하며 원소 수를 capacity개로 늘리지 않습니다. 첫 add 전 size는 0이고, 코드의 capacity 멤버는 이 예제에서 허용한 최대 원소 수입니다. vector의 물리적인 capacity가 반드시 이 수와 같다는 주장도 아닙니다.
스스로 확인하기
MyContainer.h는 그대로 두고 새 main에서 MyContainer<int> c(2);에 0·7·9를 차례로 add하세요. displayAll의 원소와 get(0), get(2)의 반환값은 무엇일까요? 두 get의 반환값만 비교해 정상과 오류를 알아낼 수 있을까요?
논리 용량과 오류 반환의 풀이
원소는 [0, 7]이고 9의 add는 오류 메시지만 내며 상태를 늘리지 않습니다. get(0)은 실제 저장된 0, get(2)는 범위 오류 안내 뒤 T()에 해당하는 int 값 0을 반환합니다. 같은 반환값 0으로 둘을 구분할 수 없으므로, 이 단순 API의 호출 전에 유효 인덱스를 확인하거나 오류와 값을 구분하는 API로 설계를 바꿔야 합니다. T()를 사용하는 멤버에는 해당 타입의 값 초기화도 가능해야 합니다. 클래스의 인수만 정했다고 모든 멤버의 본문이 즉시 유효해지는 것은 아닙니다.
.cpp로 멤버 정의만 옮긴 후, 소비하는 main에서는 선언만 보고 정의가 있는 .cpp에서도 필요한 타입을 생성하지 않았다면 링크할 멤버 정의가 없을 수 있습니다. 명시적 인스턴스화 방식에서는 제공할 조합을 정해 정의가 있는 번역 단위에서 생성하고 그 목적 파일도 링크합니다. “어떤 타입이든 지원”하는 헤더 방식과 지원 조합을 정한 별도 생성 방식을 섞지 않는 것이 중요합니다.
비타입 템플릿 매개변수
템플릿 매개변수는 타입(typename T) 외에 정수, 열거형, 포인터, 참조 등의 비타입 값도 가질 수 있습니다.
이는 주로 배열의 크기나 다른 상수 값을 컴파일 시점에 지정할 때 유용합니다.
template <typename T, int N> // N은 정수형 비타입 매개변수
class MyStaticArray {
private:
T arr[N]; // 크기가 N인 T 타입 배열
public:
// ...
};#include <typeinfo>
#include <iostream>
#include <string>
template <typename T, int MAX_SIZE> // T: 타입, MAX_SIZE: 정수형 비타입 매개변수
class FixedSizeStack {
static_assert(MAX_SIZE > 0, "MAX_SIZE must be positive");
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); // 출력: Push: 60 (현재 5개로 가득 참)
intStack.push(70); // 출력: 스택이 가득 찼습니다!
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;
}위 int 스택의 상태를 top과 활성 원소로 따라가면 다음과 같습니다. 실패한 push는 top을 바꾸지 않습니다.
| 동작 직후 | top | 활성 원소 |
|---|---|---|
| 생성 | −1 | 없음 |
| push(10), push(20) | 1 | 10, 20 |
| pop() | 0 | 10 |
| push(30), push(40), push(50), push(60) | 4 | 10, 30, 40, 50, 60 |
| push(70) 실패 | 4 | 10, 30, 40, 50, 60 |
이 코드의 논리 원소는 배열의 0..top 위치에 있습니다. 배열 슬롯은 클래스 안에 계속 존재하며 pop이 슬롯 자체의 수명을 즉시 끝내는 구현은 아닙니다. 특히 문자열 배열은 논리적으로 비어 있는 슬롯에도 문자열 객체를 갖습니다.
스스로 확인하기
FixedSizeStack 정의를 유지한 별도 main에서 FixedSizeStack<int, 2> s;를 만들고 5·0·9를 push한 뒤, pop을 세 번 호출해 각 반환값을 별도 변수에 저장하세요. 성공한 두 pop의 값과 실패한 pop의 값을 구분하고, 마지막 isEmpty 결과도 예측하세요. 이어 크기 2를 0으로 바꾼 프로그램은 실행까지 갈 수 있을까요?
경계와 반환값 풀이
5·0을 저장해 가득 찬 뒤 9는 거절됩니다. 반환값은 0·5·0 순서이고, 마지막 0은 빈 스택에서 보낸 오류 반환입니다. 첫 0은 정상 값이므로 반환값만으로 성공 여부를 알 수 없습니다. 마지막 isEmpty는 true입니다. 크기 0은 static_assert(MAX_SIZE > 0, ...)에 의해 컴파일 단계에서 실패합니다. 크기 2와 3은 같은 int 원소를 쓰더라도 다른 클래스 타입입니다. 그림의 double·크기 8은 타입 조합을 설명하는 추가 예이며 원 main에서 생성한 객체는 int·크기 5와 string·크기 3입니다.
클래스 템플릿의 특수화 (심화)
때로는 특정 타입에 대해 템플릿 클래스가 다른 동작을 하도록 만들고 싶을 때가 있습니다.
이를 템플릿 특수화(Template Specialization)라고 합니다.
// 일반 템플릿 클래스
template <typename T>
class MyClass { /* ... */ };
// 특정 타입 (예: int)에 대한 완전 특수화
template <> // 완전 특수화
class MyClass<int> { /* int 타입에 대한 특별한 구현 */ };템플릿 특수화는 복잡하고 고급 개념이므로, 현재 장에서는 개념만 소개하고 자세한 내용은 이후 고급 템플릿에서 다루겠습니다.
클래스 템플릿을 설계할 때는 타입 매개변수, 비타입 매개변수, 정의 위치, 인스턴스화 시점이 서로 연결되어 있습니다.
아래 흐름에서 사용 지점과 정의의 가시성을 확인해 봅시다.
C++ · TEMPLATE DEFINITION VISIBILITY
템플릿 정의를 보이게 하거나, 특정 조합을 따로 생성한다
아래 두 패널은 서로 다른 구현 배치 방식입니다. 템플릿 정의를 단순히 .cpp에 옮기는 것만으로는 필요한 타입의 코드를 확보할 수 없습니다.
일반 경로 · 정의를 헤더에 둔다
- 사용 지점에서 헤더 포함
main.cpp가 MyContainer의 선언과 필요한 멤버 정의를 볼 수 있습니다. - 필요한 조합을 암시적으로 생성
MyContainer<int>처럼 실제로 사용하는 타입·멤버의 코드를 인스턴스화합니다.
선택한 조합 · 명시적 인스턴스화
- 정의가 있는 번역 단위에서 생성
template class MyContainer<int>;처럼 지원할 조합을 명시적으로 생성합니다. - 소비 측은 생성된 정의에 연결
소비 측의
extern template class MyContainer<int>;는 암시적 생성을 억제합니다. 생성된 코드를 포함한 목적 파일을 링크해야 합니다.
본문은 일반 경로: MyContainer.h에 모든 정의를 두고 main.cpp가 포함합니다. 명시적 생성 방식은 지원할 타입 조합과 멤버 정의의 가시성을 함께 관리해야 합니다.
클래스 템플릿은 T와 비타입 매개변수, 선언·정의 배치가 함께 맞아야 인스턴스화됩니다.
정의가 보이는 위치와 타입이 요구하는 연산을 함께 확인하면 템플릿 오류를 훨씬 빨리 좁힐 수 있습니다. 클래스와 멤버의 암시적 인스턴스화에서는 클래스 사용에 필요한 선언과 실제로 요구되는 멤버 정의를 구분하며, 명시적 인스턴스화는 특정 조합을 제공하는 조건을 설명합니다.