C++ 에서는 타입별로 코드를 만들어내는 반복을 줄이기 위해서 template 을 지원하고 있다.
또한, 템플릿은 컴파일 타임에 타입과 관련된 오류를 체크할 수 있다.
vector 를 template 를 사용해 작성한 코드는 다음과 같다.
// 템플릿 첫 활용
#include <iostream>
#include <string>
template <typename T>
class Vector {
T* data;
int capacity;
int length;
public:
// 생성자
Vector(int n = 1) : data(new T[n]), capacity(n), length(0) {}
// 맨 뒤에 새로운 원소를 추가한다.
void push_back(T s) {
if (capacity <= length) {
T* temp = new T[capacity * 2];
for (int i = 0; i < length; i++) {
temp[i] = data[i];
}
delete[] data;
data = temp;
capacity *= 2;
}
data[length] = s;
length++;
}
// 임의의 위치의 원소에 접근한다.
T operator[](int i) { return data[i]; }
// x 번째 위치한 원소를 제거한다.
void remove(int x) {
for (int i = x + 1; i < length; i++) {
data[i - 1] = data[i];
}
length--;
}
// 현재 벡터의 크기를 구한다.
int size() { return length; }
~Vector() {
if (data) {
delete[] data;
}
}
};
int main() {
// int 를 보관하는 벡터를 만든다.
Vector<int> int_vec;
int_vec.push_back(3);
int_vec.push_back(2);
std::cout << "-------- int vector ----------" << std::endl;
std::cout << "첫번째 원소 : " << int_vec[0] << std::endl;
std::cout << "두번째 원소 : " << int_vec[1] << std::endl;
Vector<std::string> str_vec;
str_vec.push_back("hello");
str_vec.push_back("world");
std::cout << "-------- std::string vector -------" << std::endl;
std::cout << "첫번째 원소 : " << str_vec[0] << std::endl;
std::cout << "두번째 원소 : " << str_vec[1] << std::endl;
}
일단 클래스 상단에 템플릿이 정의된 부분을 살펴보면 템플릿 인자로 T 는 반드시 어떠한 타입의 이름을 명시하고 있다.
아래의 경우 클래스에 대한 템플릿을 명시하는데, 만약에 밑에 오는 것이 함수라면 함수에 대한 템플릿을 정의할 수 있다.
template <typename T>
class Vector {
T* data;
int capacity;
// ...
간혹 템플릿 정의 시 아래와 같이 typename 이 아닌 class 를 사용하는 경우도 있는데, typename 을 사용하는 것을 권장한다.
-> 기능은 정확히 동일하다.
C++ 을 처음 만들었던 사람은 템플릿 정의 시 새로운 키워드를 만들고 싶지 않아 class 를 사용했는데, 이후 시간이 흘러 C++ 위원회는 의미의 혼동을 막기 위해서 typename 이라는 키워드를 만들었다.
( class T 라고 하면 클래스 템플릿만 사용 가능한 것처럼 보여진다)
template<typename T>
template<class T>
템플릿을 사용할 때는 아래와 같이 템플릿에서 사용할 자료형을 명시해주면 해당 자료형을 템플릿에 전달하게 된다.
아래와 같이 클래스 템플릿에 인자를 전달해서 실제 코드를 생성하는 것을 클래스 템플릿 인스턴스화(class template instantiation) 이라고 한다.
-> 템플릿은 반드시 인스턴스화 되어야만 컴파일러가 실제 코드를 생성하게 된다.
템플릿 클래스는 컴파일러에 의해서 중복된 코드를 더 많이 사용하기 때문에, 코드 크기와 메모리 사용량이 증가하게 된다.
Vector<int>
Vector<std::string>
템플릿 특수화
특정 타입의 템플릿 사용에 대한 요구사항이 있을 때 템플릿 특수화를 통해 요구사항을 반영할 수 있다.
예를 들어 아래와 같은 템플릿이 정의되어 있을 때,
template<typename A, typename B, typename C>
class test {};
A 는 int 고, C 가 double 일 때 일을 따로 처리하고 싶다면, 아래와 같이 같이 특수화 하고 싶은 부분에 원하는 타입을 전달하고, 나머지는 일반적인 템플릿을 사용할 수 있다.
한가지 중요한 점은 전달하는 템플릿 인자가 없더라도 특수화 하고 싶다면 template<> 라도 남겨야 한다는 것이다.
// 템플릿 인자가 있는 경우
template<typename B>
class test<int, B, double> {};
// 템플릿 인자가 없는 경우
template<>
class Vector<bool>{
...
}
1비트만 사용하는 bool 타입을 위해 템플릿 특수화를 사용해보자
Vector<bool> 을 구현하기 위해 int 배일을 사용해보자.
1개의 int 는 4바이트 이므로, 32 개의 bool 데이터를 한데 묶어서 저장할 수 있다.
-> 따라서 N 번째 bool 데이터는 N / 32 번째 int 안에 저장되어 있게 된다.
이를 통해 bool 이 1바이트로 저장되는 것을 1비트로 저장할 수 있다.
-> 메모리 관리 측면에서는 매우 효율이 높지만, 구현하는데는 조금 더 복잡하다.
...
함수 템플릿
클래스가 아닌 함수에도 템플릿을 사용 가능하다.
함수 템플릿 또한 함수가인스턴스화 되기 이전까지는 컴파일 시에 아무런 코드로 변환되지 않는다.
#include <iostream>
#include <string>
template <typename T>
T max(T& a, T& b) {
return a > b ? a : b;
}
int main() {
int a = 1, b = 2;
std::cout << "Max (" << a << "," << b << ") ? : " << max(a, b) << std::endl;
std::string s = "hello", t = "world";
std::cout << "Max (" << s << "," << t << ") ? : " << max(s, t) << std::endl;
}
위 코드를 살펴보면 함수를 아래와 같이 호출하는 것을 볼 수 있다.
컴파일러는 생각보다 똑똑해서, a 와 b 의 타입을 보고서 알아서 max<int>(a, b) 로 인스턴스화 해준다.
max(a, b); // 실제 사용하는 코드
max<int>(a, b); // 이렇게 사용해야 할 것 같은데;;
함수 개체(Function Object - Functor) 의 도입
아래 코드를 살펴보자
-> 아래 함수는 Comp 라는 클래스를 템플릿 인자로 받고,
-> 함수 자체도 Comp 개체를 따로 받는다.
아래 if 문에서 마치 함수를 호출하는 것 처럼 사용되는데, cont[i] 와 cont[j] 를 받아서 내부적으로 크기를 비교한 후 그 결과를 리턴하고 있다.
한 가지 중요한 사실은 comp 는 함수가 아닌 개체이고, Comp 클래스에서 () 연산자를 오버로딩 한 버전이다.
if(!comp(cont[i], cont[j])) {
자세한 사용 코드는 아래와 같다.
#include <iostream>
template <typename T>
class Vector {
T* data;
int capacity;
int length;
public:
// 어떤 타입을 보관하는지
typedef T value_type;
// 생성자
Vector(int n = 1) : data(new T[n]), capacity(n), length(0) {}
// 맨 뒤에 새로운 원소를 추가한다.
void push_back(int s) {
if (capacity <= length) {
int* temp = new T[capacity * 2];
for (int i = 0; i < length; i++) {
temp[i] = data[i];
}
delete[] data;
data = temp;
capacity *= 2;
}
data[length] = s;
length++;
}
// 임의의 위치의 원소에 접근한다.
T operator[](int i) { return data[i]; }
// x 번째 위치한 원소를 제거한다.
void remove(int x) {
for (int i = x + 1; i < length; i++) {
data[i - 1] = data[i];
}
length--;
}
// 현재 벡터의 크기를 구한다.
int size() { return length; }
// i 번째 원소와 j 번째 원소 위치를 바꾼다.
void swap(int i, int j) {
T temp = data[i];
data[i] = data[j];
data[j] = temp;
}
~Vector() {
if (data) {
delete[] data;
}
}
};
template <typename Cont>
void bubble_sort(Cont& cont) {
for (int i = 0; i < cont.size(); i++) {
for (int j = i + 1; j < cont.size(); j++) {
if (cont[i] > cont[j]) {
cont.swap(i, j);
}
}
}
}
template <typename Cont, typename Comp>
void bubble_sort(Cont& cont, Comp& comp) {
for (int i = 0; i < cont.size(); i++) {
for (int j = i + 1; j < cont.size(); j++) {
if (!comp(cont[i], cont[j])) {
cont.swap(i, j);
}
}
}
}
struct Comp1 {
bool operator()(int a, int b) { return a > b; }
};
struct Comp2 {
bool operator()(int a, int b) { return a < b; }
};
int main() {
Vector<int> int_vec;
int_vec.push_back(3);
int_vec.push_back(1);
int_vec.push_back(2);
int_vec.push_back(8);
int_vec.push_back(5);
int_vec.push_back(3);
std::cout << "정렬 이전 ---- " << std::endl;
for (int i = 0; i < int_vec.size(); i++) {
std::cout << int_vec[i] << " ";
}
Comp1 comp1;
bubble_sort(int_vec, comp1);
std::cout << std::endl << std::endl << "내림차순 정렬 이후 ---- " << std::endl;
for (int i = 0; i < int_vec.size(); i++) {
std::cout << int_vec[i] << " ";
}
std::cout << std::endl;
Comp2 comp2;
bubble_sort(int_vec, comp2);
std::cout << std::endl << "오름차순 정렬 이후 ---- " << std::endl;
for (int i = 0; i < int_vec.size(); i++) {
std::cout << int_vec[i] << " ";
}
std::cout << std::endl;
}
일단 아래 두 클래스에서 Comp1 과 Comp2 모두 아무 것도 하지 않고 단순히 operator() 만 정의하고 잇다.
그리고 Comp1, Comp2 개체들은 bubble_sort 함수 안에서 마치 함수인양 사용된다.
-> if(!comp(cont[i], cont[j])) {
이렇게 함수는 아니지만, 함수 인 척을 하는 개체를 함수 개체(Function Object) or Functor 라고 부른다.
struct Comp1 {
bool operator()(int a, int b) { return a > b; }
};
struct Comp2 {
bool operator()(int a, int b) { return a < b; }
};
Functor 를 사용하는 방법이 코드를 하나하나 작성하는 방법보다 훨씬 편리한 점이 많다.
Functor 의 장점
상태 유지 : 함수 개체는 내부 상태를 유지할 수 있다.
-> 함수 포인터나 람다식과 비교할 때 큰 장점이다.
class Counter {
int count;
public:
Counter() : count(0) {}
int operator()() {
return ++count;
}
};
int main() {
Counter counter;
std::cout << counter() << std::endl; // 1
std::cout << counter() << std::endl; // 2
}
더 나은 최적화 : 컴파일러는 함수 개체의 인라인화 같은 최적화를 더 잘 진행할 수 있다. 이는 함수 포인터와 비교할 때 성능상의 이점을 제공한다.
-> operator() 자체를 인라인화 시켜 매우 빠르게 작업을 수행한다.
class Adder {
int increment;
public:
Adder(int inc) : increment(inc) {}
int operator()(int x) const {
return x + increment;
}
};
int main() {
Adder add5(5);
std::cout << add5(10) << std::endl; // 15
}
...
타입이 아닌 템플릿 인자(non-type template argument)
템플릿 인자로 타입만 받을 수 있는 것은 아니다.
템플릿 인자로 기본형 타입을 받을 수 있는데, 템플릿 사용시 명시적으로 어떤 값을 사용할 것인지 지정해 주어야 한다.
-> 지정해주지 않는다면 컴파일 에러가 발생하는데, 컴파일러가 어떤 값이 들어가는지 알 수 없기 떄문이다.
아래 코드는 사용 예시이다.
#include <iostream>
template <typename T, int num>
T add_num(T t) {
return t + num;
}
int main() {
int x = 3;
std::cout << "x : " << add_num<int, 5>(x) << std::endl;
}
타입이 아닌 템플릿 인자가 가장 많이 사용되는 예제는 컴파일 타임에 값들이 정해져야 하는 상황이다.
이를 가장 잘 활용하는 예제는 배열이다.
C 스타일의 배열의 가장 큰 문제점은 함수에 배열을 전달할 때 배열의 크기에 대한 정보를 잃어버린다는 점이다.
템플릿 인자로 배열의 크기를 명시한다면(어차피 배열의 크기는 컴파일 타임에 정해지는 것이다), 이 문제를 완벽하게 해결할 수 있다.
이와 같은 기능을 가진 배열을 C++ 11 부터 제공하는 std::array 를 통해 사용할 수 있다.
#include <iostream>
#include <array>
int main() {
// 마치 C 에서의 배열 처럼 {} 을 통해 배열을 정의할 수 있다.
// {} 문법은 16 - 1 강에서 자세히 다루므로 여기서는 그냥 이렇게
// 쓰면 되는구나 하고 이해하고 넘어가면 됩니다.
std::array<int, 5> arr = {1, 2, 3, 4, 5};
// int arr[5] = {1, 2, 3, 4, 5}; 와 동일
for (int i = 0; i < arr.size(); i++) {
std::cout << arr[i] << " ";
}
std::cout << std::endl;
}
위 코드에서 재미있는 부분은 arr 은 런타임에 동적으로 크기가 할당되는 것이 아니라는 점이다.
-> 컴파일 시에 int 5개를 가지는 메모리를 가지고 스택에 할당 된다.
-> std::array 는 고정 크기의 배열을 포함하는 템플릿 클래스로 컴파일 타임의 배열의 크기가 결정된다.
또한, 이 배열을 함수에 전달하기 위해서 그냥 std::array 를 받는 함수를 만들면 안된다.
-> std::array<int, 5> 자체가 하나의 타입이다.
array 크기 별로 타입이 다르기 때문에, 크기 별로 함수를 만들어줘야 할 것 같은 상황에서, 아래와 같이 템플릿을 사용하면 유연하게 문제를 해결할 수 있다.
#include <iostream>
template <typename T, int num = 5>
T add_num(T t) {
return t + num;
}
int main() {
int x = 3;
std::cout << "x : " << add_num(x) << std::endl;
}
아래 코드는 add_num<int, 5> 를 한것과 동일하다.
-> 참고로 int 는 컴파일러가 자동으로 추론해준 것이다.