C++: 내일배움캠프 3번 과제 - 인벤토리 구현
내일배움캠프에서의 3번과제는 STL과 템플릿을 바탕으로 아이템 인벤토리를 구현하는 과제이다.
조건
필수적으로 다음 기능들을 정의해야 한다.
- Invetory
클래스 - T* pItems_: 동적 배열을 가리키는 포인터
- new T[]
- int capacity_: 인벤토리의 최대 저장 가능 크기
- int size_: 현재 인벤토리의 저장된 아이템 갯수
- T* pItems_: 동적 배열을 가리키는 포인터
- 생성자/소멸자
- Inventory(int capacity = 10)
- 입력검사: 최소 용량 1
- new T[capacity_] 활용하여 힙에 할당
- ~Inventory()
- delete[] pItems_로 메모리 반환
- 해제 후 nullptr로 다시 초기화
- void AddItem(const T& item)
- 새로운 아이템 추가
- size_ 증가
- capacity_초과시 거부 및 경고
- void RemoveLastItem()
- 마지막 아이템 제거
- 메모리 지우지 말고 size_만 감소시켜 아이템 접근 차단.
- int GetSize() const: size_ 반환
- int GetCapacity() const: capacity_반환
- void PrintAllItems()
- 반복문으로 인벤토리 내에 모든 아이템 출력
- 각 아이템의 PrintInfo()활용하여 정보 출력
- 비었으면 비었다는 메세치 출력 도전과제로는 다음이 있다.
- Inventory(int capacity = 10)
- Inventory(const Inventory
& other) - 다른 객체의 데이터를 복사
- 주소만 복사하는게 아닌 값을 복사해 새로운 메모리 영역에 반영(깊은 복사 구현)
- Assign()함수를 구현해 값을 복사하는 것도 구현
- Resize()
- 재할당을 통해 새로운 메모리 공간 할당 후 기존 값 복사
- AddItem()에서 Resize()호출을 통해 자동 확장
- 정렬
- 클래스 외부에 비교함수 compareItemsByPrice() 추가
- 배열의 시작주소와 끝 주소를 지정했을 때, compareItemsByPrice() 를 전달하여 내부 객체들을 정렬.
- std::sort를 활용.
- 짧게 말하면 std::vector를 구현하는 과제이다. 근데 인벤토리라는 표현은 괜찮았나 잘 모르겠다. 게임의 인벤토리면 보통 한 종류의 아이템이 아닌 다양한 종류의 아이템이 담기는데 반해 저렇게 구현하면 Inventory
, Inventory 처럼 특정 클래스만 담을 수 있을테니 말이다. Item 클래스를 사용한 기능을 만들라는 조건이 있기는 한데, 특별히 Item의 맴버 변수나 기능에 대해 적힌 내용이 없어서 어느정도까지 구현해야 하는지 확실치는 않다. Item을 연결 리스트(Linked List)의 노드처럼 사용하고자 의도한 것이라면, 만든 후에 이를 인터페이스처럼 상속시켜 Potion, Weapon, Collectables같은 아이템 종류에 해당하는 자식/파생 클래스들을 구현하고, Inventory<item*>같이 정말로 아이템의 세부 종류에 상관없는 컨테이너를 만들 수도 있겠으나, 일단 핵심은 **벡터의 내부 동작 구조를 이해하고 스스로도 구현**할 수 있는지를 묻는 과제인 듯 하다. 그럼 Inventory 클래스의 기본 정의부터 들어가보자. Inventory 클래스, 정보조회, 생성자/소멸자
이미 클래스에 포함해야 하는 기능들과 함수들의 이름까지 과제에서 미리 정해주었다. 그에 따라 만들어야 하는 Inventory 클래스의 선언은 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//Inventory.h
#pragma once
#include <iostream>
template<typename T>
class Inventory {
private:
T* pItems_;
int capacity_;
int size_;
public:
Inventory(int capacity = 10) {
}
~Inventory() {
}
void AddItem(const T& item) {
}
void RemoveLastItem() {
}
int GetSize() const { return size_; }
int GetCapacity() const { return capacity_; }
void PrintAllItems() const {
}
};
위에서 언급한 모든 기능들이 들어갔다. size와 capacity의 정보를 얻는 getter는 단순 반환이기에 바로 포함시켜 놓았다. 일단 생성자부터 만들어보자. 생성자는 capacity를 받아서 그에 해당하는 용량을 미리 할당해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//Inventory.h
Inventory(int capacity = 10) {
//최소 용량 1 보장
if (capacity >= 1) {
std::cout << capacity << " 용량의 인벤토리를 생성합니다!" << std::endl;
capacity_ = capacity;
}
else {
std::cout << "설정하려는 용량이 1보다 작습니다! 최솟값인 1로 설정합니다!" << std::endl;
capacity_ = 1;
}
//배열 및 크기 할당
pItems_ = new T[capacity_];
size_ = 0;
}
소멸자에서는 생성자에서 할당했던 pItems_포인터의 객체를 삭제한다. pitems_에 배열을 할당했었기 때문에, delete가 아닌 delete[]를 사용해야 배열 전체를 삭제할 수 있다. 그 이후엔 pItems_포인터를 nullptr로 바꿔준다. nullptr로 바꿔줌으로서 혹시나 발생할 수 있는 삭제한 객체에 대한 접근, 댕글링 포인터 문제를 예방한다… 라고는 하는데, 소멸자가 호출될 정도면 Inventory 객체도 삭제되는 상태일테니 기존 pItems가 가리키던 주소 영역에 대한 접근법 자체가 사라졌을 텐데 의미가 있나 싶긴 하다. 다만 Inventory가 살아있고 pItems만 임시로 비워둔다거나 초기화해야하는 상황이라면 충분히 의미가 있다고 할 수 있겠다.
1
2
3
4
5
6
7
//Inventory.h
~Inventory() {
//배열값 전부 삭제
delete[] pItems_;
//nullptr로 초기화
pItems_ = nullptr;
}
아이템 추가/제거
vector와 동일한 기능으로 구현하는 것을 기준으로 한다면 삽입과 삭제 모두 후방에서의 조작인 push_back()과 pop_back()을 구현하라는 의미일 것으로 해석된다. 추가는 별것 없이, capacity_가 남아 있다면 size_에 해당하는 위치에 삽입,즉 비어있는 곳 중 가장 앞에 새 자료를 넣어주면 된다.
1
2
3
4
5
6
7
8
9
10
11
//Inventory.h
void Inventory<T>::AddItem(const T& item) {
//capacity 값 넘으면 메세지 출력후 종료
if (size_ >= capacity_) {
cout << "인벤토리가 꽉찼습니다! 더 넣을 수 없습니다!" << endl;
return;
}
//배열 마지막 위치에 item 저장
pItems_[size_] = item;
size_++;
}
삭제은 size_를 감소하는 것으로 구현하라고 요구했다. 별도의 랜덤 접근없이 뒤에서 구현할 PrintAllItems()를 통해서만 탐색하고, AddItem과 RemoveLastItem정도의 제한적인 삽입과 삭제를 요구한다면, 포인터 처리 없이 size_만 줄여주면 지금 Inventory 클래스에서는 해당 포인터에 대해 접근할 권한이 없다. 나중에 새로운 아이템이 삽입될 때, 자료가 남아있던 해당 인덱스는 새로운 객체로 덮어씌워질 것이다. 이미 삭제할 아이템이 있던 공간 자체도 pItems 가 할당받은 공간이고, Inventory 객체를 삭제한다면 소멸자에서 pItems도 같이 해제될테니 메모리 누수라고 볼 수는 없겠다.
1
2
3
4
5
6
7
void Inventory<T>::RemoveLastItem() {
if (size_ <= 0) {
std::cout << "인벤토리가 비어있습니다!" << std::endl;
return;
}
size_--;
}
목록 출력
드디어 Item 클래스가 필요한 시점이 왔다. 목록을 출력하려면 출력할 값들이 필요하지 않겠나? 빠르게 Item 클래스를 만들어보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//Item.h
#pragma once
#include<string>
class Item {
private:
std::string name_;
int price_;
public:
//생성자
Item() : name_(" "), price_(0) {}
Item(std::string name, int price) {
SetName(name);
SetPrice(price);
}
//정보 출력
void PrintInfo() {
std::cout << "[이름: " << name_ << ", 가격: " << price_ << "G]" << std::endl;
}
//Setters
void SetName(std::string val) { name_ = val; }
void SetPrice(int val) { price_ = val; }
//Getters
const std::string& GetName() const { return name_; }
int GetPrice() const { return price_; }
};
bool compareItemsByPrice(const Item& a, const Item& b) {
return a.GetPrice() < b.GetPrice();
}
과제의 출력 예시에서 보인 것처럼 이름 Name과 가격 price를 표시할 수 있도록 맴버 변수를 가지고 그에 대한 Getter와 Setter를 가지도록 해놓았다. Getter를 통해 값을 얻고 출력하지 않고 PrintInfo()를 이용해 바로 한 줄에 출력할수도 있다. 또한 뒤에 쓸 정렬 기능을 위해 compareItemsByPrice() 함수를 하나 추가해놨다. 이 함수는 클래스 외부에 정의되어 단순히 가격을 확인하고 앞에 아이템이 더 큰지 확인해 반환한다. 이제 아이템 객체들을 쭉 출력해줄 수 있는 PrintAllItems() 함수를 Inventory에 추가해보겠다. 단순히 PrintAllItems()에서 반복문을 통해서 가진 객체들의 PrintInfo()를 호출하면 끝이다. 물론 가진 원소가 없는 size_ == 0 상태에선 호출하지 않는다.
1
2
3
4
5
6
7
8
9
//Inventory.h
void PrintAllItems() const {
//반복문을 통한 printInfo()함수 호출
//pritnInfo()를 지원하지 않는 T에선 컴파일 오류를 통해 차단(암묵적 인터페이스)
for (int i = 0; i < size_; i++) {
pItems_[i].PrintInfo();
}
std::cout << std::endl;
}
여기까지 구현하면 main함수에서 호출해서 삽입, 삭제, 출력을 해볼 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include "Inventory.h"
#include "Item.h"
using namespace std;
int main() {
//3칸짜리 Inventory 생성
Inventory<Item> bag(3);
//4개 아이템 추가, 마지막 아이템은 용량초과로 추가 안됨.
bag.AddItem(Item("Sword", 100));
bag.AddItem(Item("Potion", 20));
bag.AddItem(Item("Bread", 15));
bag.AddItem(Item("Book", 50));
//Sword, Potion, Bread 출력
bag.PrintAllItems();
//Bread 삭제
bag.RemoveLastItem();
//Sword, Potion 출력
bag.PrintAllItems();
}
실행해보면 아이템의 추가, 용량 초과 여부 반영, 삭제, 출력 모두 정상적으로 작동하는 것을 볼 수 있다. 근데 우리는 인벤토리를 탬플릿에 기반하여 구현했다. 만약 T에 Item대신 int, string같은 다른 자료형을 넣으면 어떻게 될까? 현재 우리의 PrintAllItems()는 Item의 PrintInfo()를 호출하기 때문에, Item에 종속적인 상태이다. 즉 Inventory
그럼 Inventory
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include "Item.h"
template<typename T>
class Inventory {
private:
//...
public:
//...
void PrintAllItems() const;//함수 프로토타입 선언
};
//전체 호출 함수 기본구현
template<typename T>
void Inventory<T>::PrintAllItems() const {
for (int i = 0; i < size_; i++) {
std::cout << pItems_[i] << std::endl;
}
}
//Item이면 이 함수로 호출
template<>
void Inventory<Item>::PrintAllItems() const {
for (int i = 0; i < size_; i++) {
pItems_[i].PrintInfo();
}
}
다른 방법으로는 PrintAllItems() 함수 안에서 Item 타입인지 검사하는 코드를 사용하는 것도 방법이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//Inventory.h
#pragma once
#include <iostream>
#include "Item.h" //Inventory에서 Item 클래스에 대해 알아야함!
#include <type_traits> //is_same 활용을 위해 필요
template<typename T>
class Inventory {
private:
//...
public:
//...
void PrintAllItems() const {
for (int i = 0; i < size_; i++) {
if constexpr (std::is_same<T, Item>::value) {//만약 T가 Item이면
pItems_[i].PrintInfo();
}
else {
std::cout << pItems_[i] << std::endl;
}
}
}
};
위 두가지 버전은 모두 아래 main함수와 호환되어 작동한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include "Inventory.h"
#include "Item.h"
#include <string>
#include <vector>
using namespace std;
int main() {
Inventory<Item> bag(3);
//4개 아이템 추가, 마지막 아이템은 용량초과로 추가 안됨.
bag.AddItem(Item("Sword", 100));
bag.AddItem(Item("Potion", 20));
bag.AddItem(Item("Bread", 15));
bag.AddItem(Item("Book", 50));
//Sword, Potion, Bread 출력
bag.PrintAllItems();
//Bread 삭제
bag.RemoveLastItem();
//Sword, Potion 출력
bag.PrintAllItems();
//String 인벤토리 생성
Inventory<string> Strings(3);
//3개 문자열 추가
Strings.AddItem("You ");
Strings.AddItem("are ");
Strings.AddItem("Awesome");
//You Are Awesome 출력
Strings.PrintAllItems();
}
근데 정말로 다양한 자료형을 지원하고자 한다면 PrintAllItems는 순수한 컨테이너의 기능으로서는 적합하지 않다고 할수도 있겠다. 앞서말한 int, string같은 원시 자료형이 PrintInfo()를 가지진 않을 것이고, 다른 클래스에서도 있다는 장담을 못하는 PrintInfo()를 통한 전체 순회 출력을 하기보단, 출력은 이 컨테이너를 사용하는 설계에서 독자적으로 구현하는 편이 좋다고 본다. (이는 STL의 컨테이너들이 반복자를 사용하도록 함으로서 최소한 순회의 방법이라도 통일시켜놓은 배경이라 할 수도 있을 것이다.) 다만 정말로 언급된 다양한 자료형을 정말 담을 수 있는 구현은 과제의 요구사항을 읽어볼 때, 의도와는 다른 방향인 것으로 보인다. 때문에 실제 구현 코드에는 담지 않도록 하겠다. 그러면 여기까지가 구현 필수 요소였고, 이제 추가적인 도전 과제를 진행해보자.
복사 구현
보통 우리가 벡터나 배열을 사용하면 원소에 하나씩 접근도 하지만 통째로 복사하거나 해올때도 있다. int A[] = {1,2,3}이나, vector
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//Inventory.h
//복사 생성자
Inventory(const Inventory<T>& other) {
//other의 정보를 바탕으로 배열 초기화
capacity_ = other.GetCapacity();
size_ = other.GetSize();
pItems_ = new T[capacity_];
//원소 하나씩 복사해오기
for (int i = 0; i < size_; i++) {
pItems_[i] = other.pItems_[i];
}
std::cout << "인벤토리를 복사해왔습니다!" << std::endl;
}
other의 정보를 바탕으로 capacity_와 size_를 초기화시키고, 그에 맞는 배열인 T[capacity_]를 생성해 포인터에 할당한다. 이후에는 반복문으로 size_까지 차있는 객체들을 복사해오게 된다. Assign구현도 이와 유사하다. 이미 있는 인벤토리에 다른 인벤토리인 other을 가져와 값을 덮어 쓰는 것이다. 다만 처음에 소멸자에서 해주었던 삭제 작업이 추가된다는 차이만 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void Assign(const Inventory<T>& other) {
//배열값 전부 삭제
delete[] pItems_;
//nullptr로 초기화
pItems_ = nullptr;
//other의 정보를 바탕으로 배열 초기화
capacity_ = other.GetCapacity();
size_ = other.GetSize();
pItems_ = new T[capacity_];
//원소 하나씩 복사해오기
for (int i = 0; i < size_; i++) {
pItems_[i] = other.pItems_[i];
}
std::cout << "인벤토리를 복사해왔습니다!" << std::endl;
}
메인함수를 실행해보면 복사가 잘 작동함을 볼 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include "Inventory.h"
#include "Item.h"
using namespace std;
int main() {
Inventory<Item> bag(3);
//4개 아이템 추가, 마지막 아이템은 용량초과로 추가 안됨.
bag.AddItem(Item("Sword", 100));
bag.AddItem(Item("Potion", 20));
bag.AddItem(Item("Bread", 15));
//Sword, Potion, Bread 출력
bag.PrintAllItems();
//복사 생성자로 OtherBag 생성, Sword, Potion, Bread 출력
Inventory<Item> OtherBag(bag);
OtherBag.PrintAllItems();
//Bread 삭제, Sword, Potion 출력
OtherBag.RemoveLastItem();
OtherBag.PrintAllItems();
//기존 bag로 다시 초기화, Sword, Potion, Bread 출력
OtherBag.Assign(bag);
OtherBag.PrintAllItems();
}
그런데 만약 Assign() 함수에 어떤 인벤토리든 넣을 수 있으니, 자기 자신도 넣을 수 있지 않겠나? 넣게되면 어떻게 될까? 코드 처음부분에서 other을 초기화 하는 부분이 있으니 자기 자신을 삭제해버릴 것이다. 그리고 capacity_와 size_는 남아 있을 테니 그저 크기와 용량, 댕글링 포인터를 가지는 컨테이너가 되고, 이는 예상치 못한 문제를 발생 시킬 염려가 있다. 이를 방지하기 위해서 자신인지 검사하는 코드만 하나 추가해 두도록 하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void Assign(const Inventory<T>& other) {
if (this == &other) return; //인벤토리 증발 방지!
//배열값 전부 삭제
delete[] pItems_;
//nullptr로 초기화
pItems_ = nullptr;
//other의 정보를 바탕으로 배열 초기화
capacity_ = other.GetCapacity();
size_ = other.GetSize();
pItems_ = new T[capacity_];
//원소 하나씩 복사해오기
for (int i = 0; i < size_; i++) {
pItems_[i] = other.pItems_[i];
}
std::cout << "인벤토리를 복사해왔습니다!" << std::endl;
}
자동 확장
두번째 도전 과제인 자동 확장 기능이다. 먼저 사이즈를 키울 수 있는 기능부터 있어야하지 않겠나? 용량을 늘려주는 Resize() 함수를 추가해보자. (capacity를 늘리는 것이니 엄밀히는 틀린 네이밍 일 수도 있겠다.) 새로운 용량을 입력받으면, 그에 맞는 새로운 배열을 생성해 기존 pItems를 옮겨간다. 즉 위에서 구현한 Assign과 유사한 방식이라 볼 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//Inventory.h
void Resize(int val) {
//용량이 더 작으면 거부
if (val < capacity_) {
std::cout << "인벤토리 용량을 줄일수는 없습니다!" << std::endl;
return;
}
//새 배열 생성 후 값 복사
T* temp = new T[val];
capacity_ = val;
for (int i = 0; i < size_; i++) {
temp[i] = pItems_[i];
}
//기존 배열 삭제 후 새 배열로 포인터 재할당
delete[] pItems_;
pItems_ = temp;
}
이제 이 Resize()를 기존 AddItem()에서 용량을 초과할 때 출력한 메세지 대신 넣어주면 된다. 새로 추가할 때 용량을 초과하면, 자동적으로 2배 크기의 새 배열로 초기화 될 것이다.
1
2
3
4
5
6
7
8
9
10
11
//Inventory.h
void AddItem(const T& item) {
//capacity 값 넘으면 두배로 확장
if (size_ >= capacity_) {
std::cout << "인벤토리가 꽉찼습니다! 용량을 2배로 늘립니다!" << std::endl;
Resize(capacity_ * 2);
}
//배열 마지막 위치에 item 저장
pItems_[size_] = item;
size_++;
}
main함수에서 작동시켜 보면 이제는 용량을 초과해서도 잘 작동하는 추가와 늘어난 용량을 확인할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//main.cpp
#include "Inventory.h"
#include "Item.h"
using namespace std;
int main() {
//인벤토리 생성
Inventory<Item> bag(3);
cout << "size: " << bag.GetSize() << ", cap: " << bag.GetCapacity() << endl;
//4개 아이템 추가, 용량 확장, Sword, Potion, Bread, Book 출력
bag.AddItem(Item("Sword", 100));
bag.AddItem(Item("Potion", 20));
bag.AddItem(Item("Bread", 15));
bag.AddItem(Item("Book", 15));
cout << "size: " << bag.GetSize() << ", cap: " << bag.GetCapacity() << endl;
//용량 줄이기 시도, 더 작다며 거부
bag.Resize(2);
}
정렬
마지막 도전 과제인 정렬이다. 이미 Algorithm 라이브러리에서 제공하고 있는 sort를 활용하라고 했기에 크게 어려울 것은 없다. 해당 라이브러리를 포함 시키고, 아까 Item 클래스에 정의 해놓았던 compareItemsByPrice()함수를 넘겨주는 일만 하면 된다. 그러면 price값에 따라 오름차순으로 정렬 될 것이다. 그럼 SortItems()함수를 구현해보자. sort() 한줄이면 끝나기에 별거 없다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//Inventory.h
#pragma once
#include <iostream>
#include <algorithm> //sort 사용을 위해 필요
#include "Item.h" //compareItemsByPrice 함수를 Item.h에 구현했기에 포함
template<typename T>
class Inventory {
private:
//...
public:
//...
void SortItems() {
sort(pItems_, pItems_ + size_, compareItemsByPrice);
}
};
마찬가지로 main함수에서 작동시켜 보면 잘 작동한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//main.cpp
#include "Inventory.h"
#include "Item.h"
using namespace std;
int main() {
//인벤토리 생성
Inventory<Item> bag(3);
//아이템 추가, 가격 100 20 15 17순으로 출력
bag.AddItem(Item("Sword", 100));
bag.AddItem(Item("Potion", 20));
bag.AddItem(Item("Bread", 15));
bag.AddItem(Item("Book", 17));
bag.PrintAllItems();
//정렬 후 출력, 가격 15 17 20 100 순으로 출력
bag.SortItems();
bag.PrintAllItems();
}
여기까지 Inventory 클래스와 기능들을 구현해보았다. Vector의 작동 원리를 이해하고 있다면 충분히 구현해볼법 하다고 생각된다.
오늘의 경험: 헤더와 소스의 분리가 불필요한 상황, 탬플릿에 제약 조건 부여, 그리고 private으로도 막을 수 없는 접근.
이번 과제를 진행함에 있어 위에 코드들을 전부 보면 main.cpp를 제외하면 별도의 소스파일을 만들지 않고 모든 기능을 헤더에서 구현까지 마무리했다. 다만 처음 코드를 만드는 중에는 Inventory.cpp와 Item.cpp까지 만들어서 별도로 구현했었다.
이번 과제의 핵심이라 할 수 있는 탬플릿의 활용은 임의의 자료형 T를 가정하고, 이를 바탕으로 클래스의 맴버들을 작성하는데 있다. 일반적인 클래스 작성이라면 헤더에 선언부를, 소스에 구현부를 위치해 분리시킨다. 하지만 템플릿 클래스에서는 그것이 불필요하고 오히려 오류를 야기할 수 있다. 다음 예제를 보자:
1
2
3
4
5
6
//temp.h
template<typename T>
class temp{
public:
void PrintValue(T val);
};
1
2
3
4
5
6
7
//temp.cpp
#include "temp.h"
using namespace std;
template<typename T>
void temp<T>::PrintValue(T val){
cout << val << endl;
}
1
2
3
4
5
6
//main.cpp
#include "temp.h"
int main(){
temp<int> X;
X.PrintValue(100);
}
아주 간단한 템플릿 클래스이다. 구조만 먼저 보노라면 .h 파일에서 탬플릿 클래스 temp의 선언부를 가지고, .cpp 파일에서 temp의 구현부를 가지는 보통의 분리 원칙을 충족하는 구조다. 메인에서는 temp 객체를 만들고 PrintValue()를 통해 숫자를 출력하고자 한다. 하지만 위 코드는 실행되지 않는다. 탬플릿은 임의의 자료형을 가정하는 것이고 컴파일 시점에 확정된다. 즉 temp.cpp가 컴파일 되는 시점에서는 cpp파일의 T가 어떤 것인지 모르고, 때문에 컴파일 되어야 하는데도 컴파일 될 수 가 없다. 반면 메인함수에서 컴파일 되지 않은 temp.cpp의 함수를 찾으려 해도 불가능하기에 링킹되지 않고 에러로 이어진다. 내가 처음 나누어서 구현했을 떄 본 에러메세지는 “main 함수에서 참조되는 확인할 수 없는 외부 기호”였다. 존재하기 않기에 확인/참조할 수 없다는 말이다. 부가적으로는 소스와 헤더를 나누면 헤더에서 구현하면 생략해도 됬을 template
1
2
3
4
5
6
7
8
//temp.h
#pragma once
#include "temp.cpp" //헤더에 소스를 포함, #pragma once에 의해 순환관계는 되지 않음.
template<typename T>
class temp{
public:
void PrintValue(T val);
};
아니면 헤더파일에서 전방선언하는 것과 유사하게, 소스파일에서 어떤 클래스를 사용하겠다고 명시하는 방법이 있다. 하지만 이 또한 자료형에 관계없이 사용하려고 탬플릿을 사용한 것인데, 일일히 적고있노라면 사용 의도에 맞지 않지 않겠나?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//temp.cpp
#include "temp.h"
using namespace std;
//수많은 명시적 선언...
template class temp<consumable>;
template class temp<wearable>;
template class temp<collecable>;
//...
template<typename T>
void temp<T>::PrintValue(T val){
cout << val << endl;
}
때문에 탬플릿 클래스를 사용해야 한다면 소스파일에서 구현까지 모두 구현하도록 하고 되도록이면 .cpp파일은 없애도록 하자.
한편 탬플릿에서 그렇게 다양한 자료형에 대해 모두 작동을 보장하는 코드를 만드는 건 소모적이고 일관성 없다는 생각도 한편 들었다. 모든 자료형이 탬플릿 클래스의 구현에 쓰일 특정 기능을 포함하고 있다 보장을 해줄 수 없는것 아니겠는가? 이를 위해서는 탬플릿 대신 상속과 업캐스팅을 이용한 방법, 즉 추상 클래스나 부모 클래스를 이용해 수많은 자식 클래스를 다루는 방법이 하나 있고, 또 다르게는 탬플릿에 조건을 명시하는 방법이 있다. 후자는 콘셉트(concept) 라는 키워드를 사용해 탬플릿이 갖춰야할 조건을 구현해놓을 수 있다. 전자는 런타임 시점에 확정되는 자료형에 대해 조건을 확실히 하고, 후자는 컴파일 시점에 미리 탬플릿의 조건을 명시한다.
1
2
3
4
5
6
7
8
9
10
11
//temp.h
#pragma once
template<typename T>
concept Printable = requires(T t){
std::cout << t; //이 식을 만족할 수 없으면 사용할 수 없도록 제약
};
template<Printable T> //cout으로 출력할 수 있는 모든 자료형 T
class temp{
public:
void PrintValue(T val);
};
또 하나 이번에 한 경험이 있다면, 같은 클래스인 객체들 간에는 서로의 private인 맴버들에도 접근이 가능하다는 것이다. 복사 생성자과 Assign()에서 원래는 GetItem(int i)를 만들어 pItems의 배열 원소를 얻게 하려고 했었다. 내 생각으론 private으로 설정해놨으니 외부에서의 접근이 애초에 안될 것으로 생각했는데, 분명 매개변수를 통해 얻은 같은 클래스의 다른 객체인 other를 통해서 other.pItems[0] 처럼 접근할 수 있었다. 보안과 프로그램의 안정성에 있어 별로 좋아 보이진 않았는데 생각해보면 수많은 래퍼 클래스(Wrapper class)를 사용하는 이유라고도 할 수 있겠다. 보통은 이런 컨테이너는 단독으로 존재하는 객체보단, 어떤 객체에서 정보를 저장하기 위한 도구로서 사용하게 된다. 그럼 결국 그 컨테이너에 접근하기 위해서는 소유자 객체를 통해야 하고, 이는 소유자 객체에서 접근 제어를 충분히 할 수 있다는 소리일 것이다.