클래스 템플릿
타입과 비타입 매개변수로 재사용 가능한 제네릭 클래스를 정의하고 구현 분리와 인스턴스화 규칙을 이해합니다.
정수를 담는 스택과 문자열을 담는 스택은 push와 pop의 동작이 똑같고, 담는 원소의 타입만 다릅니다. 함수 템플릿이 함수를 타입에 대해 일반화했듯이, 클래스 템플릿(class template)은 클래스 전체를 타입에 대해 일반화합니다. 지금까지 써 온 std::vector<int>의 <int>도 클래스 템플릿에 타입을 넘긴 것입니다.
이 글은 클래스 템플릿을 정의하고 쓰는 방법, C++17의 클래스 템플릿 인수 추론, 템플릿의 정의를 헤더에 두어야 하는 이유, 타입이 아닌 값을 받는 비타입 템플릿 매개변수를 다룹니다.
클래스 템플릿의 정의와 사용
클래스 앞에 template <typename T1, typename T2>를 붙이면, 클래스 안에서 T1과 T2를 실제 타입처럼 쓸 수 있습니다. 다음은 서로 다른 타입의 값 두 개를 묶는 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) {}
T1 getFirst() const { return first; }
T2 getSecond() const { return second; }
void setFirst(T1 f) { first = f; }
void display() const {
std::cout << "first " << first << ", second " << second << '\n';
}
};
int main() {
Pair<int, double> p1(10, 20.5);
p1.display();
Pair<std::string, char> p2("Hello", 'W');
p2.display();
Pair p3(3, 2.5); // C++17: Pair<int, double>로 추론
std::cout << p3.getFirst() + p3.getSecond() << '\n';
}first 10, second 20.5
first Hello, second W
5.5Pair<int, double>처럼 클래스 이름 뒤에 타입을 적으면, 컴파일러가 T1을 int, T2를 double로 바꾼 클래스를 만듭니다. 이것도 함수 템플릿과 같은 인스턴스화이며, Pair<int, double>과 Pair<std::string, char>는 서로 다른 타입입니다. 타입을 만드는 일은 컴파일할 때 한 번 일어나고, 그 타입의 객체를 만드는 일은 실행할 때마다 일어나므로 둘은 다른 사건입니다.
C++17부터는 Pair p3(3, 2.5);처럼 타입을 적지 않아도 생성자의 인자로 템플릿 인수를 추론할 수 있으며, 이를 클래스 템플릿 인수 추론(CTAD, class template argument deduction)이라고 합니다. std::vector v{1, 2, 3};이 std::vector<int>가 되는 것도 같은 기능입니다.
멤버 함수를 클래스 밖에 정의하기
클래스 템플릿의 멤버 함수를 클래스 밖에 정의할 때는, 함수마다 template <...>를 다시 적고 클래스 이름에 템플릿 인수를 붙입니다. 클래스 밖의 정의 하나하나도 템플릿이어서, 그 안의 T가 무엇인지 컴파일러에 알리려면 정의마다 템플릿 매개변수를 다시 선언해야 하기 때문입니다. 아래 Box는 클래스 안에 선언만 두고, 생성자와 get의 본문을 클래스 아래에 따로 정의합니다.
template <typename T>
class Box {
private:
T value;
public:
explicit Box(T v);
T get() const;
};
template <typename T>
Box<T>::Box(T v) : value(v) {}
template <typename T>
T Box<T>::get() const {
return value;
}Box<T>::get의 Box<T>가 이 함수가 어느 클래스의 멤버인지 알려 줍니다. 클래스 안에서는 Box라고만 써도 Box<T>를 뜻하지만, 클래스 밖에서는 템플릿 인수까지 적어야 합니다.
정의를 헤더에 두는 이유
클래스 템플릿의 멤버 함수는 그 타입으로 실제로 쓰일 때 만들어지며, 그러려면 쓰는 곳에서 함수의 정의가 보여야 합니다. 일반 클래스처럼 선언은 헤더에, 정의는 .cpp에 나누면, 헤더만 포함한 main.cpp는 Box<int>::get을 만들 수 없고 정의가 있는 .cpp에서는 int가 필요한지 모르므로 아무도 그 함수를 만들지 않습니다. 결국 링크 단계에서 정의를 찾지 못해 실패합니다.
C++ · TEMPLATE DEFINITION VISIBILITY
템플릿 정의를 보이게 하거나, 특정 조합을 따로 생성한다
아래 두 패널은 서로 다른 구현 배치 방식입니다. 템플릿 정의를 단순히 .cpp에 옮기는 것만으로는 필요한 타입의 코드를 확보할 수 없습니다.
일반 경로 · 정의를 헤더에 둔다
- 사용 지점에서 헤더 포함
main.cpp가 MyContainer의 선언과 필요한 멤버 정의를 볼 수 있습니다. - 필요한 조합을 암시적으로 생성
MyContainer<int>처럼 실제로 사용하는 타입·멤버의 코드를 인스턴스화합니다.
선택한 조합 · 명시적 인스턴스화
- 정의가 있는 번역 단위에서 생성
template class MyContainer<int>;처럼 지원할 조합을 명시적으로 생성합니다. - 소비 측은 생성된 정의에 연결
소비 측의
extern template class MyContainer<int>;는 암시적 생성을 억제합니다. 생성된 코드를 포함한 목적 파일을 링크해야 합니다.
본문은 일반 경로: MyContainer.h에 모든 정의를 두고 main.cpp가 포함합니다. 명시적 생성 방식은 지원할 타입 조합과 멤버 정의의 가시성을 함께 관리해야 합니다.
그래서 보통은 위 그림의 왼쪽처럼 클래스 템플릿의 선언과 정의를 모두 헤더에 두고, 쓰는 소스가 그 헤더를 포함합니다. 템플릿의 정의는 인라인 함수에서 본 단일 정의 규칙의 예외여서, 여러 소스에 같은 정의가 들어가도 문제가 없습니다.
지원할 타입이 정해져 있다면 그림의 오른쪽처럼 정의를 .cpp에 두고, 그 파일에서 template class Box<int>;로 필요한 조합을 직접 만드는 명시적 인스턴스화(explicit instantiation)를 쓸 수 있습니다. 쓰는 쪽에서는 extern template class Box<int>;로 다른 곳에서 만들어 둔 것을 쓰겠다고 알리고, 정의가 있는 목적 파일도 함께 링크합니다. 이 방식은 컴파일 시간을 줄일 수 있지만, 미리 만들어 두지 않은 타입으로는 쓸 수 없습니다.
쓰는 멤버만 만들어집니다
클래스 템플릿의 멤버 함수는 호출될 때만 인스턴스화됩니다. 그래서 Pair의 display()는 <<로 출력할 수 없는 타입에는 쓸 수 없지만, display()를 부르지 않는다면 그런 타입으로도 Pair를 만들고 getFirst()를 쓸 수 있습니다. 아래 코드는 pair_template.cpp의 Pair 정의 아래에 두고, 그 파일의 main 대신 씁니다.
struct NoPrint {
int id;
};
int main() {
Pair<NoPrint, int> pair(NoPrint{1}, 2);
int id = pair.getFirst().id; // 가능
// pair.display(); // 컴파일 오류: NoPrint는 << 로 출력할 수 없음
(void)id;
}이 성질 덕분에 std::vector<T>도 T에 <가 없어도 쓸 수 있고, 정렬처럼 <가 필요한 기능을 쓰는 순간에만 오류가 납니다. 반대로 말하면, 템플릿을 만들어 두고 몇몇 멤버를 한 번도 호출하지 않았다면 그 멤버의 오류는 아직 드러나지 않았을 수 있습니다.
비타입 템플릿 매개변수
템플릿 매개변수에는 타입뿐 아니라 정수 같은 값도 올 수 있으며, 이를 비타입 템플릿 매개변수(non-type template parameter)라고 합니다. 값은 컴파일할 때 정해져야 하므로, 배열의 크기처럼 컴파일할 때 알아야 하는 상수에 씁니다. std::array<int, 5>의 5가 그 예입니다.
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 <cstddef>
#include <iostream>
template <typename T, std::size_t N>
class FixedSizeStack {
static_assert(N > 0, "N must be positive");
private:
T elements[N]{};
std::size_t count = 0;
public:
bool push(const T& item) {
if (count == N) {
return false;
}
elements[count++] = item;
return true;
}
bool pop(T& out) {
if (count == 0) {
return false;
}
out = elements[--count];
return true;
}
bool isFull() const { return count == N; }
std::size_t size() const { return count; }
};
int main() {
FixedSizeStack<int, 3> s;
s.push(10);
s.push(20);
int value{};
s.pop(value);
std::cout << value << ' ' << s.size() << '\n';
s.push(30);
s.push(40);
std::cout << std::boolalpha << s.isFull() << ' ' << s.push(50) << '\n';
while (s.pop(value)) {
std::cout << value << ' ';
}
std::cout << '\n';
}20 1
true false
40 30 10위 그림처럼 FixedSizeStack<T, N>은 아직 타입이 아니고, T와 N이 정해진 조합마다 서로 다른 구체 타입이 만들어집니다. 그래서 FixedSizeStack<int, 3>과 FixedSizeStack<int, 5>는 원소의 타입이 같아도 다른 타입이며, 서로 대입할 수 없습니다. static_assert(N > 0, ...)은 크기 0으로 인스턴스화하려는 코드를 컴파일할 때 막습니다.
push와 pop이 성공 여부를 bool로 돌려주므로, 가득 찬 스택에 넣으려는 push(50)이 false를 돌려주고 스택은 바뀌지 않습니다. 실패를 원소의 기본값 같은 특별한 값으로 알리면 그 값이 실제로 저장된 값인지 구별할 수 없으므로, 이처럼 성공 여부와 값을 따로 돌려줍니다. 특정 타입에 대해서만 다르게 동작하게 하는 특수화는 다음 글에서 다룹니다.
더 깊이: C++20에서 넓어진 비타입 템플릿 매개변수
C++17까지 비타입 템플릿 매개변수의 타입은 정수와 열거형, 포인터, 참조 같은 것으로 한정되었고, C++17은 template <auto N>처럼 그 타입을 인수에서 추론하게 하는 것까지 더했습니다. C++20은 여기에 부동 소수점 타입과 조건을 갖춘 리터럴 클래스 타입을 더했습니다. 클래스가 비타입 매개변수의 타입이 되려면 모든 기반 클래스와 비정적 데이터 멤버가 public이고 mutable이 아니어야 하며, 그 멤버들의 타입도 다시 같은 조건을 만족해야 합니다. 이렇게 쓸 수 있는 타입을 구조적 타입(structural type)이라고 부릅니다.
이 덕분에 template <double Scale>처럼 실수 값으로 동작을 바꾸거나, 멤버가 모두 공개된 작은 구조체를 템플릿 인수로 넘길 수 있습니다. C++20의 template <std::array arr>처럼 클래스 템플릿의 인수를 템플릿 인수 자리에서 추론하게 할 수도 있습니다. cppreference의 C++20 지원표에 따르면 GCC는 클래스 타입 인수(P0732R2)를 9부터 지원하고, 관련 규칙을 정리한 P1907R1은 10에서 일부, 11에서 모두 지원합니다.
정리
- 클래스 템플릿은
template <typename T>로 타입을 매개변수로 받아, 여러 타입에 대해 하나의 클래스를 정의합니다. Pair<int, double>처럼 인수를 넘기면 그 조합의 클래스가 만들어지며, 인수 조합마다 서로 다른 타입입니다.- C++17부터는 생성자의 인자로 템플릿 인수를 추론하는 CTAD를 쓸 수 있습니다.
- 멤버 함수는 쓰는 곳에서 정의가 보여야 만들어지므로, 보통 선언과 정의를 모두 헤더에 둡니다.
- 멤버 함수는 호출될 때만 인스턴스화되므로, 부르지 않는 멤버가 요구하는 연산은 그 타입에 없어도 됩니다.
- 비타입 템플릿 매개변수는 컴파일할 때 정해지는 값을 받으며, 값이 다르면 서로 다른 타입이 됩니다.
연습 문제
1. 같은 타입의 두 객체와 복사본
Pair<int, double> a(10, 20.5), b(10, 20.5); 다음에 a.setFirst(99);를 하면 두 객체의 display()는 무엇을 출력하나요? Pair<int, Pair<std::string, double>> p(1, Pair<std::string, double>("Nested", 3.14));에서 p.getSecond().display();가 출력하는 값을 바꾸려고 p.getSecond()의 결과에 어떤 함수를 불러도 안 되는 이유는 무엇인가요?
해설 보기
a는 first 99, second 20.5, b는 first 10, second 20.5입니다. 같은 타입의 객체라도 멤버는 각자 따로 있습니다. getSecond()는 T2를 값으로 돌려주므로 안쪽 Pair의 복사본을 받게 되고, 그 복사본을 바꿔도 p 안의 값은 그대로입니다. 안쪽 값을 바꾸려면 참조를 돌려주는 함수를 두거나, 바꾼 Pair를 새로 넣는 함수를 만들어야 합니다.
2. 작은 스택에 넣고 꺼내기
FixedSizeStack<int, 2> s;에 5, 0, 9를 차례로 push하면 각 호출은 무엇을 돌려주나요? 그 뒤 pop을 세 번 하면 무엇을 얻나요? 크기를 0으로 바꾸면 어떻게 되나요?
해설 보기
5와 0의 push는 true이고, 가득 찬 뒤의 9는 false이며 저장되지 않습니다. pop은 0, 5를 차례로 꺼내며 true를 돌려주고, 세 번째는 비어 있어 false를 돌려주며 out을 바꾸지 않습니다. 크기를 0으로 바꾸면 static_assert가 실패해 컴파일 오류가 납니다.
3. 크기가 다른 스택끼리의 대입
FixedSizeStack<int, 2> a; FixedSizeStack<int, 3> b; 다음에 a = b;는 컴파일되나요?
해설 보기
컴파일되지 않습니다. 비타입 인수 2와 3이 달라 두 스택은 서로 다른 타입이고, 컴파일러가 만든 복사 대입은 같은 타입끼리만 받습니다. 크기가 다른 스택 사이에 값을 옮기려면 원소를 하나씩 꺼내 넣는 함수를 따로 만들어야 합니다.
다음 글 템플릿 특수화에서는 특정 타입이나 타입의 형태에 대해서만 템플릿을 다르게 정의하는 명시적 특수화와 부분 특수화를 다룹니다.