이번 포스팅은 지금까지 배운 내용으로 TextRPG를 만들다가 느낀 C++에 관한 내용을 적어보려고 한다.
전방 선언
이전 포스팅에서 글로만 다뤘던 내용인데, 이 부분이 익숙하지 않았다.
그래서 Player 클래스와 Item 클래스가 각기 다른 Player.h, Item.h 파일에 이렇게 있다고 해보자.
Item.h
#pragma once
#include "Player.h"
class Item
{
public :
void UseItem(const Player* player);
};
Player.h
#pragma once
#include "Item.h"
class Player
{
public:
Item* _item;
};
위 코드는 플레이어가 아이템을 가지고 있고, 그 아이템은 능력을 사용하기 위해서 사용한 대상을 포인터로 받아 사용한다.
서로 각기 다른 파일에 존재하기 때문에 서로의 존재를 모르고 있다. 그렇기에 "그쪽 설계도(클래스) 내놓으시죠" 라는 의미로 서로 #include를 한 모습이다.
이렇게하면 문법적으로는 문제가 없이 해결이 될것이다. 자, 그러면 문법적으로 문제가 없으니 빌드를 해보겠다.
빌드 결과는 처참하다. 심지어, 오류 메세지가 뭔가 깔끔하지도 않고(오류가 무엇인지 정확히 나타나지 않음) 심지어 5개의 오류를 내뱉고 있는 모습이다.
이게 바로 파일을 분리하면서 C++에서 조심해야할 점이다.
서로의 설계도를 공유하는 것 까진 좋았지만 생각해보면 당연한 것이다. Player의 클래스의 정볼를 완성하는데 있어 Item 클래스는 필수적이다. 그리고 Item 클래스도 마찬가지로 완성하는데 있어 Player 클래스에 대한 정보가 필요하다. 서로에 대한 정보를 완성하는데 서로의 정보를 필요하다보니 빌드가 되지 않는 것이다.
물론 이 과정을 자세히 알기 위해선 C++ 이 어떻게 컴파일되는지 정확히 알아야하지만
그냥 서로의 파일을 include하면 안된다고 알고 있으면 된다.
이 문제를 해결하기 위해서 전방 선언을 해주는 것이다.
#include "Item.h"를 뺐다고 생각해보자. Player 클래스는 Item 클래스의 포인터를 들고 있다. 즉, Item클래스의 정보가 뭔지는 안 궁금하고 일단 8 바이트 만들게!(32비트 체계에서는 4바이트) 라는 의미다. 근데 그 8바이트 주소를 타고 가면 뭐가있어?? 나는 모르겠는데? 로 바뀌게된다. 그렇기 때문에 "일단 그건 무시하고 넘어가 나중에 알려줄게" 라는 것이 전방선언이다.
Player.h
#pragma once
class Item;
class Player
{
public:
Item* _item;
// class Item* _item; //이렇게 써도 된다.
};
이렇게 하고 빌드를 해보면 문제 없이 빌드가 되는걸 확인할 수 있다. 허나 만약에 내가 전방 선언한 클래스를 즉시 다루거나 해야할때는 #include가 필요하다. 또는 서로 상속관계인 클래스 간의 파일에는 어쩔 수 없이 #include를 해야한다.
그 것이 겹치지 않게 조심해야할 것이다.
메모리에 nullptr은 필수?!
우리가 알고 있듯이 어떤 클래스나 데이터를 new 키워드를 통해 동작할당을 한다면 delete는 필수적으로 해야한다고 알고 있다.
그래서 테스트를 한번 해보자.
Item.h
#pragma once
#include <iostream>
using namespace std;
class Item
{
public :
void ItemInfo()
{
cout << "id : " << _id << " 개수 : " << _num << endl;
}
public:
int _id = 1;
int _num = 5;
};
Player.h
#pragma once
#include "Item.h"
class Player
{
public:
Player()
{
_item = new Item();
}
public:
Item* _item;
};
Main.cpp
#include <iostream>
using namespace std;
#include "Player.h"
int main()
{
Player test;
test._item->ItemInfo(); // 1번 호출
test._item->_num++; // 증가
delete test._item; // 아이템 잃어버림
test._item->ItemInfo(); // 아이템이 없어졌지만 한번 호출
return 0;
}
메인 함수에서 Player 객체를 스택에 하나 생성하고 ItemInfo를 호출하면 정상적으로 출력될 것으로 예상된다. 그리고 아이템을 한번 사용했으니 그 아이템을 소멸시키기 위해 delete 를해서 힙 영역에서 밀어준다. 그리고 테스트로 다시한번 ItemInfo를 호출하면 다음과 같은 결과를 도출한다.
예상한대로 첫번째에는 결과가 제대로 나왔는데 두번째에는 쓰레기 값이 출력된 것을 볼 수 있다. 한번 메모리를 뜯어서 확인해보자.
test._item->num++;
까지 진행 했을때의 메모리 결과다.
00AAFEF0은 힙 영역중 item이 할당 받은 영역이다. id는 1이고 num은 6이니까 값이 잘 자리한 것을 확인할 수 있다.
그리고 delete를 하게되면 다음과 같이 영역이 밀리는걸 볼 수 있다.
이 상태에서 ItemInfo를 호출하게 되니까 값이 이상하게 나오는 것이다.
어찌보면 당연한 것이다 Player의 Item*는 주소값으로 00AAFEF0을 들고 있을 것이고 delete를 해준다고 해서 Player의 Item* 주소가 사라지는 것이 아니라 계속 같은 주소를 들고 있을 것이다.
그러니 이미 힙 영역에서 사라진 Item 영역에 접근해 데이터를 불러오는 아주아주아주아주아주아주 위험한 짓을 하고 있는 것이다.
그러니 꼭 데이터를 해제하고 nullptr로 밀어줘야 하는 것이다.(그 주소를 더이상 사용안할 것이라면)
nullptr과 delete의 순서
위의 본문에서 얘기한대로 nullptr을 통해 데이터를 밀어야 하는 이유를 알아봤다. 그래서 다음과 같이 데이터를 한번 nullptr로 해보자.
#include <iostream>
using namespace std;
#include "Player.h"
int main()
{
Player test;
test._item->ItemInfo(); // 1번 호출
test._item->_num++; // 증가
test._item = nullptr; // null로 밀어줌
delete test._item; // 아이템 잃어버림
test._item->ItemInfo(); // 아이템이 없어졌지만 한번 호출
return 0;
}
test._item = nullptr코드를 실행하기전 test의 메모리 값이다.
test의 값에는 00E8FF48이라는 item의 주소를 들고 있다.
그리고 nullptr 문장을 실행하면
으로 밀리는 모습이다.
이 상태로 delete를 하게되면
nullptr을 delete 할 수 없다는 에러 문구와 함께 프로그램이 종료된다.
심지어 아직 00E8FF48 주소에는 데이터들이 그대로 남아 있게된다.
그래서 값 들이 지워지지 않고 만약 다른 경로를 통해 저 주소에 접근하게 되었을 때, 자신이 예상하는 값이 아닌 다른 값이 있게되거나 덮어씌게되어 문제를 발생할 수 있다!!
그래서 결론은 데이터를 delete할 땐 nullptr을 해야하는데 nullptr은 delete를 한 후에 해야한다.
이로써 내가 오늘 TextRPG를 만들다가 깨지고 부서진 내용에 대해 적어봤다. 특히 전방선언에서 두둘겨 맞았으니 유의하자.
'C++ > 기초' 카테고리의 다른 글
[C++] 함수 객체, 템플릿, 콜백 함수 (0) | 2023.12.01 |
---|---|
[C++] 함수 포인터 (0) | 2023.12.01 |
[C++] 전방 선언 (0) | 2023.11.29 |
[C++] 복사, cast (0) | 2023.11.28 |
[C++] 타입 변환 - 2 (1) | 2023.11.28 |