C++ 클래스 템플릿 | 제네릭 컨테이너와 부분 특수화
이 글의 핵심
C++ 클래스 템플릿에 대한 실전 가이드입니다. 제네릭 컨테이너와 부분 특수화 등을 예제와 함께 상세히 설명합니다.
들어가며: int 스택, double 스택… 계속 만들어야 하나?
“타입마다 Stack 클래스를 복사하고 있어요”
간단한 스택 자료구조를 만들었습니다. 하지만 타입마다 클래스를 복사해야 했습니다.
문제의 코드:
class IntStack {
std::vector<int> data;
public:
void push(int value) { data.push_back(value); }
int pop() {
int value = data.back();
data.pop_back();
return value;
}
bool empty() const { return data.empty(); }
};
class DoubleStack {
std::vector<double> data;
public:
void push(double value) { data.push_back(value); }
double pop() {
double value = data.back();
data.pop_back();
return value;
}
bool empty() const { return data.empty(); }
};
// 타입마다 클래스 추가...
위 코드 설명: IntStack과 DoubleStack은 push/pop/empty 로직이 같고 저장하는 요소 타입만 다릅니다. 새 타입마다 클래스를 복사하면 유지보수 비용이 늘고, 한쪽만 수정하면 동작이 어긋날 수 있어, “요소 타입만 다른” 구조는 클래스 템플릿 하나로 통합하는 편이 좋습니다.
추가 문제 시나리오
시나리오 1: 설정값 저장소
설정 시스템에서 int, double, std::string, bool 등 여러 타입의 값을 저장해야 합니다. 타입마다 IntConfig, StringConfig를 만들면 코드 중복이 폭발합니다.
시나리오 2: 네트워크 버퍼 풀
패킷 버퍼를 uint8_t[1024], uint8_t[4096] 등 크기별로 관리할 때, 크기마다 클래스를 복사하면 템플릿 비타입 인자(size_t N)로 한 번에 해결할 수 있습니다.
시나리오 3: 직렬화 래퍼
JSON, Protobuf 등 직렬화 대상 타입이 User, Order, Product 등 수십 개일 때, 각 타입마다 UserSerializer, OrderSerializer를 만들면 템플릿 하나로 통합할 수 있습니다.
시나리오 4: 캐시 컨테이너
키-값 캐시에서 키 타입(std::string, int64_t)과 값 타입(User, std::vector<Item>) 조합이 많을 때, template <typename K, typename V> class Cache로 제네릭하게 처리할 수 있습니다.
클래스 템플릿(타입을 인자로 받아 여러 타입에 대해 같은 구조의 클래스를 생성하는 틀)을 쓰면 Stack<int>, Stack<double>처럼 타입만 바꿔서 같은 로직을 재사용할 수 있고, 컴파일러가 타입별로 코드를 생성합니다. 함수 템플릿과 마찬가지로 “동작은 같은데 요소 타입만 다를 때” 클래스 템플릿으로 통합하면 유지보수가 쉬워집니다.
클래스 템플릿으로 해결 (아래는 복사해 붙여넣은 뒤 g++ -std=c++17 -o stack_tpl stack_tpl.cpp && ./stack_tpl 로 실행 가능):
// 복사해 붙여넣은 뒤: g++ -std=c++17 -o stack_tpl stack_tpl.cpp && ./stack_tpl
#include <vector>
#include <iostream>
#include <string>
#include <stdexcept>
template <typename T>
class Stack {
std::vector<T> data;
public:
void push(const T& value) { data.push_back(value); }
T pop() {
if (empty()) throw std::logic_error("Stack is empty");
T value = data.back();
data.pop_back();
return value;
}
bool empty() const { return data.empty(); }
size_t size() const { return data.size(); }
};
int main() {
Stack<int> intStack;
intStack.push(10);
intStack.push(20);
std::cout << intStack.pop() << "\n"; // 20
Stack<std::string> strStack;
strStack.push("hello");
strStack.push("world");
std::cout << strStack.pop() << "\n"; // world
return 0;
}
위 코드 설명: template <typename T> class Stack으로 한 번 정의하면 Stack<int>, Stack<std::string> 등 타입만 바꿔 사용할 수 있습니다. data는 std::vector<T>로 요소 타입에 맞게 저장되고, pop 시 빈 스택이면 logic_error를 던지도록 했습니다. 클래스 템플릿은 사용할 때 타입을 반드시 지정해야 합니다(Stack<int> 등).
실행 결과: 아래 명령으로 실행하면 20 과 world 가 각각 한 줄씩 출력됩니다.
g++ -std=c++17 -o stack_tpl stack_tpl.cpp && ./stack_tpl
클래스 템플릿 인스턴스화 흐름
컴파일러는 Stack<int>, Stack<std::string>처럼 사용할 때마다 해당 타입에 맞는 클래스를 생성합니다. 아래 다이어그램은 템플릿 정의에서 구체적인 타입이 생성되는 과정을 보여 줍니다.
flowchart TB
subgraph template["템플릿 정의"]
T[template <typename T>\nclass Stack]
end
subgraph instances["인스턴스화된 타입"]
I1[Stack<int>]
I2[Stack<double>]
I3["Stack<std string>"]
end
T -->|T=int| I1
T -->|T=double| I2
T -->|T=std::string| I3
함수 템플릿과 달리, 클래스 템플릿은 사용할 때 타입을 반드시 명시해야 합니다. Stack<int>, Stack<std::string>처럼요. C++17부터는 생성자 인자로부터 타입을 추론하는 CTAD가 도입되어 Stack s(1);처럼 쓸 수 있는 경우도 있지만, 클래스 이름만으로는 “어떤 타입의 Stack인지” 컴파일러가 알 수 없기 때문에 대부분의 코드에서는 여전히 Stack<int>처럼 적어 줍니다.
이 글을 읽으면:
- 클래스 템플릿의 기본 문법을 이해할 수 있습니다.
- 멤버 함수를 클래스 외부에 정의하는 방법을 알 수 있습니다.
- 부분 특수화를 사용할 수 있습니다.
- Stack·Array·타입 특성 등 완전한 예제를 구현할 수 있습니다.
- 흔한 에러와 프로덕션 패턴을 파악할 수 있습니다.
목차
- 클래스 템플릿 기본 문법
- 멤버 함수 외부 정의
- 부분 특수화
- 템플릿 별칭
- 실전 예제: 제네릭 컨테이너
- 완전한 예제: Stack·Array·타입 특성
- 자주 발생하는 에러와 해결법
- 모범 사례
- 프로덕션 패턴
1. 클래스 템플릿 기본 문법
클래스 템플릿은 타입을 매개변수로 받는 클래스입니다. std::vector<int>, std::map<std::string, int>가 모두 클래스 템플릿의 인스턴스입니다. 한 번 정의해 두면 정수 벡터, 문자열 벡터, 사용자 정의 타입 벡터를 같은 코드로 다룰 수 있어서, STL처럼 재사용 가능한 자료 구조를 만들 때 필수입니다.
기본 선언과 사용
template <typename T>
class Box {
T value;
public:
Box(const T& v) : value(v) {}
T get() const { return value; }
void set(const T& v) { value = v; }
};
int main() {
Box<int> intBox(42);
std::cout << intBox.get() << "\n"; // 42
Box<std::string> strBox("hello");
std::cout << strBox.get() << "\n"; // hello
// ❌ 에러: 클래스 템플릿은 타입 추론 안 됨 (C++17 전)
// Box box(42);
// ✅ C++17: CTAD (Class Template Argument Deduction)
Box box(42); // Box<int>로 추론
}
위 코드 설명: Box<T>는 타입 T 하나를 매개변수로 받는 클래스 템플릿입니다. Box<int>, Box<std::string>처럼 사용할 때 타입을 명시해야 하고, C++17에서는 생성자 인자로부터 타입을 추론하는 CTAD로 Box box(42)처럼 쓸 수 있습니다.
함수 템플릿은 func(42)처럼 인자만으로 T를 추론할 수 있지만, 클래스는 생성자 호출 전에 “이 객체의 타입”이 정해져야 합니다. 그래서 C++17 이전에는 항상 Box<int>, Box<std::string>처럼 타입을 써 줘야 했습니다.
여러 타입 매개변수
template <typename K, typename V>
class KeyValue {
K key;
V value;
public:
KeyValue(const K& k, const V& v) : key(k), value(v) {}
K getKey() const { return key; }
V getValue() const { return value; }
};
int main() {
KeyValue<std::string, int> kv("age", 25);
std::cout << kv.getKey() << ": " << kv.getValue() << "\n";
// age: 25
}
위 코드 설명: KeyValue<K, V>는 키 타입 K와 값 타입 V 두 개의 타입 매개변수를 가집니다. KeyValue<std::string, int>처럼 사용하면 “age”: 25 같은 키-값 쌍을 타입 안전하게 저장할 수 있고, map이나 pair처럼 제네릭한 자료 구조를 만들 때 쓰는 패턴입니다.
기본 템플릿 인자
template <typename T, typename Container = std::vector<T>>
class Stack {
Container data;
public:
void push(const T& value) {
data.push_back(value);
}
T pop() {
T value = data.back();
data.pop_back();
return value;
}
};
int main() {
Stack<int> stack1; // std::vector<int> 사용
Stack<int, std::deque<int>> stack2; // std::deque<int> 사용
}
위 코드 설명: 두 번째 템플릿 인자 Container에 기본값 std::vector<T>를 주면 Stack<int>만 써도 내부적으로 std::vector<int>를 사용합니다. Stack<int, std::deque<int>>처럼 두 번째 인자를 넘기면 deque 기반 스택으로 바꿀 수 있어, STL의 기본 인자 패턴과 같습니다.
기본 템플릿 인자를 두면 사용하는 쪽에서 생략할 수 있어서, Stack<int>만 써도 내부적으로 std::vector<int>를 쓰도록 할 수 있습니다. std::vector도 실제로는 할당자(allocator) 같은 두 번째 인자가 있지만 기본값이 있어서 보통은 vector<int>만 씁니다.
2. 멤버 함수 외부 정의
클래스 내부 정의 (인라인)
template <typename T>
class Container {
T value;
public:
void set(const T& v) {
value = v; // 클래스 내부 정의
}
T get() const {
return value;
}
};
위 코드 설명: 멤버 함수를 클래스 안에 그대로 두면 자동으로 인라인으로 취급됩니다. 템플릿 클래스도 본문이 짧으면 이렇게 내부 정의로 두는 경우가 많고, 컴파일러가 인스턴스화할 때 정의를 볼 수 있어야 하므로 보통 같은 헤더에 둡니다.
클래스 외부 정의
template <typename T>
class Container {
T value;
public:
void set(const T& v);
T get() const;
};
// 외부 정의 시 template 키워드 필요
template <typename T>
void Container<T>::set(const T& v) {
value = v;
}
template <typename T>
T Container<T>::get() const {
return value;
}
위 코드 설명: 멤버를 클래스 밖에 정의할 때는 template <typename T>를 다시 쓰고, 함수 이름을 Container<T>::set처럼 “클래스명<T>::멤버명”으로 써야 합니다. 이 정의도 사용하는 쪽에서 인스턴스화할 수 있도록 헤더에 두지 않으면 링크 에러가 납니다.
주의: 외부 정의도 헤더 파일에 작성해야 함 (링크 에러 방지)
함수 템플릿과 마찬가지로, 클래스 템플릿의 멤버 함수도 “어떤 타입으로 인스턴스화되는지”가 사용하는 쪽(.cpp)에서 결정됩니다. 그래서 정의가 헤더에 없으면 해당 번역 단위에서 코드를 생성할 수 없어 링크 에러가 납니다. 클래스가 길어지면 선언과 정의를 나누고 싶을 수 있지만, 템플릿은 보통 한 헤더 파일에 선언과 정의를 함께 두는 방식이 일반적입니다.
정적 멤버
template <typename T>
class Counter {
static int count; // 선언
public:
Counter() { count++; }
static int getCount() { return count; }
};
// 정의 (헤더에 작성)
template <typename T>
int Counter<T>::count = 0;
int main() {
Counter<int> c1, c2, c3;
std::cout << Counter<int>::getCount() << "\n"; // 3
Counter<double> d1, d2;
std::cout << Counter<double>::getCount() << "\n"; // 2
// 타입마다 별도의 count 변수
}
위 코드 설명: 정적 멤버 count는 타입마다 한 번만 정의해야 하므로 template <typename T> int Counter<T>::count = 0; 형태로 헤더에 둡니다. Counter<int>와 Counter<double>은 서로 다른 count를 가지므로, 타입별로 객체 개수를 따로 셀 수 있습니다.
템플릿 클래스의 정적 멤버는 인스턴스화된 타입마다 따로 존재합니다. Counter<int>::count와 Counter<double>::count는 서로 다른 변수이므로, 정수 타입으로 만든 객체 개수와 double 타입으로 만든 객체 개수를 각각 셀 수 있습니다.
3. 부분 특수화
완전 특수화는 “이 한 타입만 다르게”일 때 쓰고, 부분 특수화는 “이런 패턴의 타입들은 다르게”일 때 씁니다. 예를 들어 “모든 포인터 타입”, “모든 배열 타입”, “두 타입이 같은 경우”처럼 조건을 걸 수 있습니다. STL의 vector<bool>이 비트 패킹으로 특수화된 것처럼, 특정 패턴에 대해 메모리나 동작을 최적화할 때 자주 쓰입니다.
포인터 타입 특수화
// 일반 템플릿
template <typename T>
class SmartPtr {
T* ptr;
public:
SmartPtr(T* p) : ptr(p) {}
~SmartPtr() { delete ptr; }
T& operator*() { return *ptr; }
T* operator->() { return ptr; }
};
// 배열 타입 특수화
template <typename T>
class SmartPtr<T[]> {
T* ptr;
public:
SmartPtr(T* p) : ptr(p) {}
~SmartPtr() { delete[] ptr; } // delete[] 사용
T& operator { return ptr[index]; }
};
int main() {
SmartPtr<int> p1(new int(42));
std::cout << *p1 << "\n";
SmartPtr<int[]> p2(new int[5]);
p2[0] = 10;
std::cout << p2[0] << "\n";
}
위 코드 설명: SmartPtr<T>는 단일 객체용(delete), SmartPtr<T[]>는 배열용(delete[])으로 부분 특수화했습니다. SmartPtr<int>는 일반 버전, SmartPtr<int[]>는 배열 버전이 선택되어, 포인터/배열에 따라 다른 소멸자와 operator[]를 사용할 수 있습니다.
두 타입이 같을 때 특수화
// 일반 템플릿
template <typename T, typename U>
class Pair {
public:
void print() {
std::cout << "Different types\n";
}
};
// 두 타입이 같을 때 특수화
template <typename T>
class Pair<T, T> {
public:
void print() {
std::cout << "Same type\n";
}
};
int main() {
Pair<int, double> p1;
p1.print(); // Different types
Pair<int, int> p2;
p2.print(); // Same type
}
위 코드 설명: Pair<T, U>는 서로 다른 두 타입용, Pair<T, T>는 같은 타입 두 개용으로 부분 특수화했습니다. Pair<int, double>은 “Different types”, Pair<int, int>는 “Same type”을 출력합니다. 조건에 맞는 특수화가 있으면 그 버전이 선택됩니다.
4. 템플릿 별칭
타입 이름이 길어지면 std::unordered_map<std::string, std::vector<int>>처럼 중첩된 템플릿이 읽기 어려워집니다. 템플릿 별칭으로 의미 있는 이름을 붙여 두면 가독성과 유지보수성이 좋아집니다. C++11의 using으로 타입 별칭을 만드는 방식이 typedef보다 템플릿과 잘 맞아서, 새 코드에서는 using을 쓰는 것이 관례입니다.
using으로 별칭 만들기
// 긴 타입 이름 단축
template <typename T>
using Vec = std::vector<T>;
template <typename K, typename V>
using Map = std::unordered_map<K, V>;
int main() {
Vec<int> numbers = {1, 2, 3};
Map<std::string, int> ages = {{"Alice", 25}};
}
위 코드 설명: using Vec = std::vector<T>처럼 템플릿 별칭을 두면 Vec<int>가 std::vector<int>와 동일한 타입이 됩니다. Map<K, V>도 unordered_map의 별칭이라 긴 타입 이름을 짧게 쓰거나, 나중에 구현을 바꿀 때 한 곳만 수정할 수 있습니다.
부분 적용
template <typename T>
using StringMap = std::unordered_map<std::string, T>;
int main() {
StringMap<int> ages;
ages["Alice"] = 25;
StringMap<std::string> names;
names["user1"] = "Alice";
}
위 코드 설명: StringMap<T>는 키가 항상 std::string인 map의 별칭입니다. 첫 번째 타입 인자(std::string)를 고정하고 두 번째만 T로 두는 “부분 적용”처럼 쓸 수 있어, 키 타입이 같은 여러 map을 간단한 이름으로 쓸 수 있습니다.
5. 실전 예제: 제네릭 컨테이너
아래 예제들은 클래스 템플릿으로 “타입에 구애받지 않는” 자료 구조를 만드는 패턴을 보여 줍니다. STL의 queue, optional 같은 타입이 내부적으로 어떻게 설계될 수 있는지 감을 잡는 데 도움이 됩니다. 실제 프로젝트에서는 표준 라이브러리를 우선 사용하고, 표준에 없는 동작이 필요할 때만 이런 식으로 래퍼나 작은 컨테이너를 만드는 편이 안전합니다.
예제 1: 타입 안전한 큐
template <typename T>
class Queue {
std::deque<T> data;
public:
void enqueue(const T& value) {
data.push_back(value);
}
T dequeue() {
if (empty()) {
throw std::logic_error("Queue is empty");
}
T value = data.front();
data.pop_front();
return value;
}
const T& front() const {
if (empty()) {
throw std::logic_error("Queue is empty");
}
return data.front();
}
bool empty() const {
return data.empty();
}
size_t size() const {
return data.size();
}
};
int main() {
Queue<int> q;
q.enqueue(10);
q.enqueue(20);
q.enqueue(30);
while (!q.empty()) {
std::cout << q.dequeue() << " ";
}
// 10 20 30
}
위 코드 설명: Queue<T>는 내부에 std::deque<T>를 두고 enqueue(deque::push_back), dequeue(front + pop_front)로 FIFO를 구현합니다. 빈 큐에서 dequeue/front를 호출하면 logic_error를 던지도록 했고, 타입만 바꿔서 Queue<int>, Queue<std::string> 등으로 재사용할 수 있습니다.
예제 2: 범위 체크하는 배열
template <typename T, size_t N>
class SafeArray {
T data[N];
public:
T& operator {
if (index >= N) {
throw std::out_of_range("Index out of range");
}
return data[index];
}
const T& operator const {
if (index >= N) {
throw std::out_of_range("Index out of range");
}
return data[index];
}
constexpr size_t size() const { return N; }
T* begin() { return data; }
T* end() { return data + N; }
const T* begin() const { return data; }
const T* end() const { return data + N; }
};
int main() {
SafeArray<int, 5> arr;
arr[0] = 10;
arr[1] = 20;
try {
arr[10] = 30; // 예외 발생
} catch (const std::out_of_range& e) {
std::cerr << e.what() << "\n";
}
// 범위 기반 for 지원
for (int& value : arr) {
value *= 2;
}
}
위 코드 설명: SafeArray<T, N>은 크기 N이 컴파일 시점에 고정된 배열로, operator[]에서 인덱스가 N 이상이면 out_of_range 예외를 던집니다. begin/end를 제공해 범위 기반 for에 쓸 수 있고, 타입과 크기를 템플릿 인자로 받아 타입 안전한 고정 크기 배열을 만드는 패턴입니다.
예제 3: 옵셔널 값 컨테이너
template <typename T>
class Optional {
bool hasValue;
alignas(T) unsigned char storage[sizeof(T)];
T* ptr() { return reinterpret_cast<T*>(storage); }
const T* ptr() const { return reinterpret_cast<const T*>(storage); }
public:
Optional() : hasValue(false) {}
Optional(const T& value) : hasValue(true) {
new (storage) T(value); // placement new
}
~Optional() {
if (hasValue) {
ptr()->~T(); // 명시적 소멸자 호출
}
}
bool has_value() const { return hasValue; }
T& value() {
if (!hasValue) {
throw std::logic_error("No value");
}
return *ptr();
}
const T& value() const {
if (!hasValue) {
throw std::logic_error("No value");
}
return *ptr();
}
T value_or(const T& defaultValue) const {
return hasValue ? *ptr() : defaultValue;
}
};
int main() {
Optional<int> opt1(42);
std::cout << opt1.value() << "\n"; // 42
Optional<int> opt2;
std::cout << opt2.value_or(0) << "\n"; // 0
try {
opt2.value(); // 예외
} catch (const std::logic_error& e) {
std::cerr << e.what() << "\n";
}
}
위 코드 설명: Optional<T>는 값이 있을 수도 없을 수도 있는 타입으로, storage에 placement new로 T를 만들고 소멸자에서 명시적으로 ~T()를 호출합니다. has_value(), value(), value_or(기본값)로 std::optional과 비슷한 인터페이스를 제공하며, 값이 없을 때 value()를 호출하면 logic_error를 던지도록 했습니다.
6. 완전한 예제: Stack·Array·타입 특성
완전한 Stack 템플릿
컨테이너 선택 가능, 이동 생성자·대입, 예외 안전성을 갖춘 완전한 Stack 예제입니다.
#include <vector>
#include <deque>
#include <stdexcept>
#include <utility>
template <typename T, typename Container = std::deque<T>>
class Stack {
Container data;
public:
using value_type = T;
using container_type = Container;
using size_type = typename Container::size_type;
Stack() = default;
explicit Stack(const Container& cont) : data(cont) {}
explicit Stack(Container&& cont) : data(std::move(cont)) {}
Stack(const Stack&) = default;
Stack(Stack&&) noexcept = default;
Stack& operator=(const Stack&) = default;
Stack& operator=(Stack&&) noexcept = default;
void push(const T& value) { data.push_back(value); }
void push(T&& value) { data.push_back(std::move(value)); }
template <typename... Args>
void emplace(Args&&... args) {
data.emplace_back(std::forward<Args>(args)...);
}
T pop() {
if (empty()) throw std::logic_error("Stack is empty");
T value = std::move(data.back());
data.pop_back();
return value;
}
T& top() {
if (empty()) throw std::logic_error("Stack is empty");
return data.back();
}
const T& top() const {
if (empty()) throw std::logic_error("Stack is empty");
return data.back();
}
bool empty() const { return data.empty(); }
size_type size() const { return data.size(); }
};
int main() {
Stack<int> s1;
Stack<int, std::vector<int>> s2; // vector 기반
s1.push(42);
s1.emplace(100);
}
위 코드 설명: Container 기본값으로 std::deque<T>를 쓰고, emplace로 불필요한 복사/이동을 줄입니다.
완전한 Array 템플릿 (고정 크기)
std::array와 유사한 고정 크기 배열 템플릿입니다. 컴파일 시점 크기, 반복자, at() 범위 검사, std::tuple_size 호환을 지원합니다.
#include <stdexcept>
#include <iterator>
#include <algorithm>
template <typename T, size_t N>
class Array {
T data[N];
public:
using value_type = T;
using size_type = size_t;
using difference_type = ptrdiff_t;
using reference = T&;
using const_reference = const T&;
using pointer = T*;
using const_pointer = const T*;
using iterator = T*;
using const_iterator = const T*;
T& operator { return data[i]; }
const T& operator const { return data[i]; }
T& at(size_type i) {
if (i >= N) throw std::out_of_range("Array index out of range");
return data[i];
}
const T& at(size_type i) const {
if (i >= N) throw std::out_of_range("Array index out of range");
return data[i];
}
T& front() { return data[0]; }
const T& front() const { return data[0]; }
T& back() { return data[N - 1]; }
const T& back() const { return data[N - 1]; }
T* data_ptr() { return data; }
const T* data_ptr() const { return data; }
iterator begin() { return data; }
iterator end() { return data + N; }
const_iterator begin() const { return data; }
const_iterator end() const { return data + N; }
const_iterator cbegin() const { return data; }
const_iterator cend() const { return data + N; }
constexpr bool empty() const { return N == 0; }
constexpr size_type size() const { return N; }
constexpr size_type max_size() const { return N; }
void fill(const T& value) { std::fill_n(data, N, value); }
void swap(Array& other) noexcept { std::swap(data, other.data); }
};
// std::tuple_size 지원 (구조화 바인딩)
namespace std {
template <typename T, size_t N>
struct tuple_size<Array<T, N>> : integral_constant<size_t, N> {};
}
위 코드 설명: at()에서 범위 검사, begin()/end()로 STL 호환, std::tuple_size로 구조화 바인딩 지원.
타입 특성(Type Traits) 클래스 템플릿
컴파일 시점에 타입의 특성을 조회하는 메타 프로그래밍 예제입니다. std::is_integral, std::remove_const 같은 패턴을 이해하는 데 도움이 됩니다.
#include <type_traits>
// 기본: false
template <typename T>
struct is_pointer_v : std::false_type {};
// 포인터 특수화: true
template <typename T>
struct is_pointer_v<T*> : std::true_type {};
template <typename T>
inline constexpr bool is_pointer = is_pointer_v<T>::value;
// 사용
static_assert(!is_pointer<int>);
static_assert(is_pointer<int*>);
static_assert(is_pointer<double*>);
위 코드 설명: 기본 템플릿은 std::false_type을 상속해 false를 반환하고, T* 패턴에 대한 부분 특수화는 std::true_type을 상속해 true를 반환합니다. static_assert로 컴파일 시점에 검증합니다.
// remove_const 구현
template <typename T>
struct remove_const {
using type = T;
};
template <typename T>
struct remove_const<const T> {
using type = T;
};
template <typename T>
using remove_const_t = typename remove_const<T>::type;
// 사용
static_assert(std::is_same_v<remove_const_t<const int>, int>);
static_assert(std::is_same_v<remove_const_t<int>, int>);
위 코드 설명: remove_const는 const T일 때 T를 추출하고, 그렇지 않으면 T 그대로 둡니다. remove_const_t는 typename remove_const<T>::type의 별칭으로, C++14에서 도입된 _t 접미사 패턴입니다.
7. 자주 발생하는 에러와 해결법
문제 1: “undefined reference” 링크 에러
원인: 템플릿 멤버 함수를 .cpp 파일에 정의하고, 헤더에는 선언만 둔 경우. 컴파일러가 사용하는 쪽에서 인스턴스화할 수 없어서 링크 에러가 납니다.
해결법:
// ❌ 잘못된 방식: Stack.cpp에 정의
// Stack.h
template <typename T>
class Stack {
std::vector<T> data;
public:
void push(const T& value); // 선언만
};
// Stack.cpp - 컴파일러가 main.cpp에서 Stack<int>를 인스턴스화할 때 이 정의를 못 봄
template <typename T>
void Stack<T>::push(const T& value) { data.push_back(value); }
// ✅ 올바른 방식: 정의를 헤더에 모두 포함
// Stack.h
template <typename T>
class Stack {
std::vector<T> data;
public:
void push(const T& value) { data.push_back(value); } // 인라인 정의
};
문제 2: “dependent name” 관련 에러
원인: 템플릿 안에서 T::value 같은 의존 타입을 쓸 때, 컴파일러가 value가 타입인지 값인지 구분하지 못합니다.
해결법:
template <typename T>
class Wrapper {
// ❌ 에러: 'value'가 타입인지 확인 불가
// T::value_type x;
// ✅ typename 키워드로 "타입"임을 명시
typename T::value_type x;
// ✅ template 키워드로 "멤버 템플릿"임을 명시
T obj;
obj.template foo<int>();
};
문제 3: >> 파싱 오류 (C++11 이전)
원인: Stack<std::vector<int>>처럼 중첩된 >>를 C++03에서는 >>(시프트 연산자)로 파싱할 수 있어 에러가 납니다.
해결법:
// ❌ C++03: ">>"가 shift로 해석됨
Stack<std::vector<int>> s;
// ✅ C++03: 공백으로 구분
Stack<std::vector<int> > s;
// ✅ C++11 이후: >> 자동 해석
Stack<std::vector<int>> s;
문제 4: CTAD로 타입 추론 실패
원인: 생성자 인자만으로는 템플릿 타입을 추론할 수 없는 경우(예: 기본 생성자).
해결법:
template <typename T>
class Box {
T value;
public:
Box() : value{} {}
Box(const T& v) : value(v) {}
};
int main() {
// ❌ 에러: T를 추론할 수 없음
// Box b;
// ✅ 타입 명시
Box<int> b;
}
문제 5: 특수화 순서/선택 오류
원인: 부분 특수화가 여러 개일 때, 더 구체적인 버전이 선택되어야 합니다. 컴파일러는 “가장 특수화된” 버전을 선택합니다.
해결법: Traits<T* const>가 Traits<T*>보다 더 구체적이므로 int* const에 대해 선택됩니다.
8. 모범 사례
1. 템플릿 매개변수는 typename 또는 class 사용
typename과 class는 타입 매개변수에 동일하게 사용됩니다. typename이 “타입”임을 더 명확히 하므로 최신 코드에서는 typename을 선호합니다.
template <typename T> class Stack {}; // 권장
template <class T> class Stack {}; // 동일
2. 기본 템플릿 인자로 사용 편의성 높이기
template <typename T, typename Alloc = std::allocator<T>>
class MyVector {};
// 사용
MyVector<int> v; // Alloc 생략 가능
3. using으로 타입 별칭 제공
template <typename T>
class Stack {
public:
using value_type = T;
using size_type = size_t;
using reference = T&;
using const_reference = const T&;
};
4. noexcept로 이동/스왑 명시
template <typename T>
class Stack {
Stack(Stack&& other) noexcept : data(std::move(other.data)) {}
void swap(Stack& other) noexcept { data.swap(other.data); }
};
5. if constexpr로 타입별 분기
template <typename T>
void process(T& value) {
if constexpr (std::is_arithmetic_v<T>) value *= 2;
else value.append(" processed");
}
6. static_assert로 컴파일 시점 제약
template <typename T>
class NumericStack {
static_assert(std::is_arithmetic_v<T>, "T must be arithmetic");
// ...
};
9. 프로덕션 패턴
패턴 1: CRTP (Curiously Recurring Template Pattern)
template <typename Derived>
class Comparable {
public:
bool operator!=(const Derived& other) const {
return !(static_cast<const Derived*>(this)->operator==(other));
}
};
class Point : public Comparable<Point> {
int x, y;
public:
bool operator==(const Point& other) const {
return x == other.x && y == other.y;
}
};
위 코드 설명: 파생 클래스가 자신을 템플릿 인자로 전달해, Comparable이 Derived 타입을 알고 operator==를 호출할 수 있게 합니다. operator!=를 한 번만 정의해 재사용합니다.
패턴 2: Policy 기반 설계
template <typename T, typename Container = std::vector<T>>
class Stack {
Container data;
public:
void push(const T& v) { data.push_back(v); }
T pop() {
T v = data.back();
data.pop_back();
return v;
}
};
// 사용: 컨테이너 정책 변경
Stack<int> s1; // vector
Stack<int, std::deque<int>> s2; // deque
Stack<int, std::list<int>> s3; // list
패턴 3: 타입 안전한 단위
template <typename T, typename Tag>
struct StrongType { T value; explicit StrongType(const T& v) : value(v) {} };
using Meter = StrongType<double, struct MeterTag>;
using Second = StrongType<double, struct SecondTag>;
// Meter(5.0) + Second(10.0); // ❌ 컴파일 에러
패턴 4: 외부 인스턴스화 (빌드 시간 단축)
// Stack.h - 선언
template <typename T>
class Stack { /* ... */ };
// Stack.cpp - explicit instantiation
#include "Stack.h"
template class Stack<int>;
template class Stack<std::string>;
// main.cpp - Stack<int>, Stack<std::string>만 사용 시
// 링크 시 Stack.cpp의 정의 사용
위 코드 설명: 자주 쓰는 타입만 .cpp에서 template class Stack<int>로 명시적 인스턴스화하면, 해당 타입에 맞는 코드가 한 번만 생성되고, 다른 번역 단위에서는 링크만 하면 됩니다. 빌드 시간을 줄일 수 있습니다.
구현 체크리스트
- 템플릿 정의는 헤더에 모두 포함
-
typename/template로 의존 이름 명시 -
static_assert로 타입 제약 검사 - 이동 생성자·대입에
noexcept적용 -
using으로 타입 별칭 제공 - 자주 쓰는 타입은 explicit instantiation 고려
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 템플릿 입문 | template
와 템플릿 컴파일 에러 해결법 - C++ 가변 인자 템플릿 | Variadic Templates와 Fold Expression
- C++ auto와 decltype | 타입 추론으로 코드 간결하게 만드는 방법
이 글에서 다루는 키워드 (관련 검색어)
C++ 클래스 템플릿, template class, 부분 특수화, 제네릭 컨테이너, 템플릿 별칭, 타입 특성, CRTP, Policy 기반 설계, Strong Type 등으로 검색하시면 이 글이 도움이 됩니다.
정리
| 항목 | 내용 |
|---|---|
| 기본 문법 | template <typename T> class C { }; |
| 사용 | C<int> obj; (타입 명시 필수, C++17 CTAD 예외) |
| 멤버 정의 | 외부 정의도 헤더에 작성 (링크 에러 방지) |
| 부분 특수화 | 포인터, 배열, 동일 타입 등 특정 패턴 특수화 |
| 별칭 | using Alias = Template<T>; |
| 타입 특성 | std::true_type/false_type 상속, 부분 특수화 |
| 실전 용도 | 컨테이너, 래퍼, 타입 안전성, CRTP, Policy 설계 |
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. C++ 클래스 템플릿 완벽 가이드. template<typename T> class 문법, 멤버 함수 정의, 부분 특수화(partial specialization), 템플릿 별칭(using), Stack·Array·타입 특성 구현, 흔한 에러와 프로덕션 패턴(CRTP, Policy 설계)까지 실무에 바로 적용할 수 있습니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
한 줄 요약: 클래스 템플릿으로 타입에 무관한 Stack·Queue를 만들고 부분 특수화로 예외를 다룰 수 있습니다. 다음으로 가변 인자 템플릿(#9-3)를 읽어보면 좋습니다.
다음 글: C++ 실전 가이드 #9-3: 가변 인자 템플릿
관련 글
- C++ 템플릿 특수화 완벽 가이드 | 완전·부분 특수화, 문제 시나리오, 프로덕션 패턴
- C++ 템플릿 입문 | template
와 템플릿 컴파일 에러 해결법 - C++ 가변 인자 템플릿 | Variadic Templates와 Fold Expression
- C++ 예외 처리 | try-catch-throw와 예외 vs 에러 코드, 언제 뭘 쓸지
- C++ 예외 안전성 |