[ 동적 할당 #1 ]
- 실행할 코드가 저장되는 영역 -> 코드 영역
- 전역/ 정적 변수 - > 데이터 영역
- 지역 변수/ 매개 변수 -> 스택 영역
- 동적 할당 -> 힙 영역
- 스택 영역은 불안정한 메모리이다 ( 밀물과 썰물처럼 왔다 갔다 하기 때문에 )
- 메모리 영역
프로그램이 실행되는 도중에는 '무조건' 사용된다.
- 힙 영역
필요할때만 사용하고, 필요 없으면 반납할 수 있는 그런 메모리 없나? ( 우리가 생성/ 소멸 시점을 관리할 수 있는 )
=> 동적 할당
C++ 에서는 기본적으로 CRT ( C 런타임 라이브러리) 의 [ 힙 관리자] 를 통해 힙 영역을 사용한다.
단, 정말 원한다면 우리가 직접 OS에서 제공하는 API를 통해 커널영역으로 부터 힙을 생성하고 관리할 수 도 있다. ( MMORPG 서버 메모리 풀링 같은 데 쓰일 수 있다. )
[ 동적 할당 #2 ]
Heap overflow - 유효한 힙 범위를 초과해서 사용
동적할당을 한 다음에 반납하지 않으면 메모리 누수가 발생한다.
Double free -> free를 한 다음에 free를 다시 한번 하면 crush가 난다.
Use-After-Free : free를 한 다음에 free 한 메모리를 사용하면 정말 엉뚱한 쓰레기 값을 건드릴 수 있다.
( 바로 Crush가 나지 않아서 프로그래머 입장에서는 정말 큰 문제다. )
[ 동적 할당 #3 ]
new / delete 와 malloc / free 와의 차이점은
malloc / free 는 함수이고
new / delete 는 연산자이라는 점이다.
그러나 가장 큰 차이점은
new/ delete는 생성타입이 클래스일 경우 생성자/ 소멸자를 호출해준다는 점이다!! ( 객체지향과 잘 맞는다 )
[ 타입 변환 #1 ]
타입 변환 유형
1. 값 타입 변환
- 의미를 유지하기 위해서 , 원본 객체와 다른 비트열 재구성
a 의 값인 1234567 을 표현하기위해 가장 근접한 1.23457e+06 으로 표현해줬다. ( 비트열 재구성 )
2. 참조 타입 변환
- 비트열을 재구성 하지 않고, '관점' 만 바꾸는것
값이 완전히 달라지게 된다.
그럼 왜 참조 타입 변환을 하냐? ( 포인터 변환도 '참조 타입 변환' 과 동일한 룰을 따른다. )
업캐스팅은 괜찮
다운캐스팅은 필연적으로 데이터 손실이 일어날 수 밖에 없다.
암시적 변환
명시적 변환
Q. 아무런 연관관계가 없는 클래스 끼리 타입 캐스팅 하면 어떻게 되나?
컴파일러 차원에서 오류를 띄어준다. ( 일반적으로 안된다.)
참고로 위의 캐스팅은 '값 타입' 캐스팅이다.
우리가 정말 원한다면 가능하다.
Dog 클래스 안에다 1. 타입 변환 생성자를 만들어준다.
혹은 2. 타입 변환 연산자 를 만들어준다.
위 오류난 코드가 오류 없이 실행되는것을 볼 수 있다.
그렇다면 '참조 타입 캐스팅' 은 어떻게 될까? ( 이 부분이 조금 헷갈리는 부분이다. )
컴파일러 차원에서 거부하고 있다. ( 일반적으로 안됨 )
그리고 참고로 타입 변환 연산자나, 타입 변환 생성자는 값 타입 캐스팅에서만 적용이 되는거지,
레퍼런스 캐스팅에서는 적용 받지 않는다.
그러면 통과하려면 어떻게 해야될까?
이렇게 명시적인 거짓말로 타입 캐스팅 해주면 통과된다.
물론, Knight 가 메모리를 더 잡아먹는 더 큰 클래스라고 한다면,
dog.mp 에 접근한다면 건드리지 말아야할 메모리에 접근하게 된것이다.
그렇다면 이게 왜 통과 되는건데?
어셈블리 입장에서는 레퍼런스 = 포인터 라 문법적으로는 얘가 진짜 Dog 인지 아닌지는 컴파일러에서 체크할 수 없게 된다.
반면 값 타입 캐스팅은 진퉁을( 값 자체를 ) 가지고 타입 변환을 하기 때문에 조금더 엄격하게 관리 된다.
참고로 아래 그림 처럼 non-const 레퍼런스 타입은 non-const 으로 초기화 할 수 있다. ( const, rvalue 안됨 )
( 희한하게 반대로 const 레퍼런스 타입은 non-const l-value, cosnt-lvalue , rvalue 전부 가능. )
Q. 상속 관계에 있는 클래스 사이의 변환은 어떻게 될까?
1. 상속 관계 클래스의 값 타입 변환.
기본적으로는 변환 해주려고 하면은 안된다. ( 논리적으로도 안된다. )
모든 Dog 가 Bulldog 가 아니기 때문에 BullDog의 _french 변수는 어떻게 알것인가.
심지어 명시적으로 (BullDog)dog; 를 해줘도 안된다.
그럼 반대로는 어떨까?
문제 없이 실행되는걸 알 수 있다. ( 어느 정도 융통성이 있는 얘기 이다 BullDog 도 Dog 니깐 ) - 업캐스팅?
2. 상속 관계 클래스의 참조 타입 변환.
기본적으로는 안된다. ( 개는 개인데 항상 BullDog 는 아니니깐 )
만약 통과가 된다면 bulldog의 멤버변수에 접근할때 메모리의 엉뚱한 값을 접근하게 될것이다.
그렇지만, 위와 동일하게 명시적으로 캐스팅 해주게 된다면, 비록 위험할 지라도 컴파일러는 일단 Okay 해주게 된다.
[ 타입 변환 #3 ]
강사님 프로그래머 인생에서 참조 타입 변환으로 뭘 한적이 없었다고 함.
그러나 이제 나올 포인터 타입 변환은 정말로 자주 자주 사용한다고 함.
#3은 지난번 내용 복습
포인터나 참조로 넘겨 줬을때는 생성자를 호출 시키지 않는다 ( 기존 값을 변경하는것이기 때문에 )
마지막을 실제 아이템을 만드려면
이렇게 해줘야 한다.
★★ [ 타입 변환 #4 ] ★★
그렇다면
아무런 연관성이 없는 클래스 사이의 포인터 변환을 해보자.
일단은 컴파일러가 그나마 에러를 잡아주지만 명시적으로 하면은 통과가 된다.
// 암시적으로 NO
// 명시적으로 Okay
이게 C++의 무서운 점이다.
만약 위에 처럼 item 의 멤버 변수를 건드리려고 하면 어떻게 될까?
우선은 Knight 가 4byte를 차지하고 있기 때문에
운 좋게도 itemType 은 정상적으로 3 이 변경되고 ( offset + 4 ) 까지는 동일.
그리고 itemDbId 를 건들때에는 엉뚱한 메모리를 건들게 되고,
바로 알아차리면 좋은데 못 알아차려서 나비효과가 엄청 나중에 오게 될 수 도 있다.
강사님도 이렇게 누군가 캐스팅을 잘 못해놔서 4년후에 망할뻔한 경험을 했다고 한다.
결론 : 명시적으로 타입 변환할 때는 항상 항상 조심해야 한다!!
이제는 연관성 있는 클래스 사이의 포인터 변환을 해보자.
다운캐스팅( 부모 -> 자식 ) 하려고 하면 컴파일러가 오류를 뿜뿜한다.
weapon이 엉뚱한 메모리를 건드릴수 있기 때문에.
근데 그럼에도 명시적으로
해주게 되면 통과 시키는것을 알 수 있다. ( 위험 )
그러면 자식-> 부모 ( 업캐스팅은 ) 어떨까?
논리적으로도 맞는 말이고 컴파일러도 암시적으로 자연스럽게 통과를 시켜준다.
#include<iostream>
using namespace std;
enum ITEMTYPE {
IT_ARMOR=1,
IT_WEAPON,
};
class Item {
public:
Item() {
cout << "Item()" << endl;
}
Item(int ItemType) :m_ItemType(ItemType) {
}
Item(const Item& item) {
cout << "Item(const Item&)" << endl;
}
~Item() {
cout << "~Item()" << endl;
}
public:
int m_ItemType = 0;
int m_ItemDBId = 0;
char m_dummy[4096] = {};
};
class Weapon :public Item {
public:
Weapon() :Item(IT_WEAPON){
cout << "Weapon()" << endl;
}
~Weapon() {
cout << "~Weapon()" << endl;
}
public:
int m_damage = 0;
};
class Armor : public Item {
public:
Armor() :Item(IT_ARMOR){
cout << "Armor()" << endl;
}
~Armor() {
cout << "~Armor()" << endl;
}
public:
int m_defence = 0;
};
int main() {
// 부모 -> 자식 변환 테스트 ( 다운 캐스팅 )
{
Item* item = new Item();
Weapon* weapon = item; // 암시적으로 하면 안 된다.
Weapon* weapon2 = (Weapon*)item; // 명시적으로 해줘야 된다.
}
// 자식 -> 부모 변환 테스트 ( 업캐스팅 )
{
Weapon* weapon = new Weapon();
Item* item = weapon; // 암시적으로도 가능
}
}
* 매번 헷갈려서 나만의 알아보는 방법..
상식적으로 생각해보면 당연한거다. ( 무기는 Item이 될 수 있는데 모든 Item이 무기이지는 않다 )
그리고 나의 경우 매번 헷갈려서 이런식으로 생각한다.
Weapon이라는 큰 그릇에 Item이라는 작은 그릇을 대입하면 Waepon의 남은 부분은 어떻게 할 것인가?
혹여나 접근이라도 하면 오류를 낸다.
반대로
부모의 작은 그릇에 자식을 대입하면 부모의 작은 그릇만 사용하기 때문에 전부 존재한다.
★★ [ 타입 변환 #5 ] ★★
| 정리
- 기본(built-in) ex) primitive 자료형의 형변환에는 static_cast 를 사용한다.( dynamic_cast 는 오류 뜸 )
- static_cast 는 Base-> Derived / Derived -> Base 클래스 형변환 둘다 가능하다.
- 상속관계에서 안정적인 형변환을 원한다면 dynamic_cast 를 사용하면 된다.
- 단, Dynamic_cast 는 Derived -> Base 클래스의 형변환만 가능하다.
Base-> Derived 클래스로 형변환은 오류 뜬다.
- 하지만 하나 이상의 가상함수를 가진 다향성 클래스에 한해서는 부모 클래스의 참조/포인터 형식에서 자식 클래스의 참조/포인터로 형변환을 허용한다.
- 상속관계에서 때로는 형변환을 강제해야 하는 상황이라면 static_cast 를 사용하면 된다. ( B->D / D-> B 둘다 가능하니깐)
- 포인터/참조 타입에 상관없이 무조건 형변환을 강제하고 싶다면 reinterpret_cast 를 사용하면 된다.
- const 성향을 없애고 싶다면 const_cast 를 사용하면 된다.
- 아래 사진 처럼 옳바르지 않은 형변환일때는 nullptr을 반환한다.
이렇듯 dynamic_cast는 안정적인 형 변환을 보장한다. 하지만, 컴파일 시간이 아닌 런타임에 안정성을 검사하도록 컴파일러가 바이너리 코드를 생성한다.
실행 속도가 늦어지지만, 그만큼 안전적인 형 변환이 가능하다. 그래서 dynamic으로 연산자의 이름이 정해졌다.
staic_cast는 안전성을 보장하지 않는다. 컴파일러는 무조건 형 변환이 되도록 바이너리 코드를 생성하기에 결과에 따른 책임은 프로그래머가 져야 한다. 그렇지만 실행 속도는 빠른 장점이 있다. 그래서 static으로 연산자의 이름이 정해졌다.
추가로
아래의 코드를 보면
Inventory[i] = new Weapon();
Inventory[i] = new Armor();
가 나오는데
자식-> 부모로 가는 업캐스팅은 명시적 타입변환 없이도 잘 수행된다.
그리고 이런식의 Base 클래스를 인터페이스 처럼 활용하는걸 굉장히 많이 사용하게 될 것이다.
그리고 이제 내가 인벤토리에 들고 있는 목록을 출력해보고 싶다고 할 때,
이런식으로 inventory[i] 가 가리키고 있는 item을 아이템 타입을 확인한 후 ,
다시 원래 형태로 다운캐스팅해줘서 사용할 수 있을 것이다.
엄밀히 말하면 부모-> 자식 ( 다운캐스팅) 은 위험한 작업이다. 그러나 우리는 아이템 타입을 확인하는 작업을 거쳤으니 어느정도는 안정성을 확보한것이다. ( 우리는 원본 자체가 weapon이라는것을 알고 있다. )
#include <iostream>
using namespace std;
class Item {
public:
Item() {
cout << "item 기본 생성자 호출" << '\n';
}
Item(int itemType) : _itemType(itemType) { cout << "Item(itemType) 생성자 호출 " << '\n'; }
Item(const Item& item) {
cout << "item 복사 생성자 호출 " << '\n';
}
virtual ~Item()
{
cout << "item 소멸자 호출 " << '\n';
}
public:
int _itemType = 0;
int _itemDbId = 0;
char _dummy[4096] = {}; // 이런 저런 정보들로 인해 비대해진
};
enum ItemType {
IT_WEAPON = 1,
IT_ARMOR =2
};
class Weapon : public Item {
public:
Weapon() : Item(IT_WEAPON) {
cout << "Weapon 생성자 호출 " << '\n';
_damage = rand() % 100;
}
~Weapon() {
cout << "Weapon 소멸자 호출 " << '\n';
}
public:
int _damage = 0;
};
class Armor : public Item {
public:
Armor() : Item(IT_ARMOR){
cout << "Armor 생성자 호출 " << '\n';
}
~Armor() {
cout << "Armor 소멸자 호출 " << '\n';
}
int _defence = 0;
};
int main() {
{
Item* inventory[20] = {};
srand((unsigned int)time(nullptr));
for (int i = 0; i < 20; i++) {
int randValue = rand() % 2; // 0 - 1
switch (randValue) {
case 0:
inventory[i] = new Weapon(); // 암시적으로 타입 캐스팅이 이루어진다.
break;
case 1:
inventory[i] = new Armor();
break;
}
}
// inventory의 값을 보려면
for (int i = 0; i < 20; i++) {
Item* item = inventory[i];
if (item == nullptr) continue; // 물론 위에서 전부 생성해줬지만 항상 nullptr 체크하는 습관을 기르자
if (item->_itemType == IT_WEAPON) {
Weapon* weapon = (Weapon*)item; // 여기서 다시한번 역으로 weapon으로 바꿔줄수 있을 것이다.
// 사실 우리는 itemType을 넣어놨으니깐 어느정도 안전하다는것을 알 수 있지만, (다운 캐스팅은)엄밀히 말하면 위험한거다.
// 혹시라도 원본이 Weapon 타입이 아니라 다른 타입이였다면 잘못된 타입 변환이 일어날수 있다는것이다.
// 안전한 업캐스팅만 사용할 수 있는게 아니라 다운 캐스팅도 이 처럼 왔다 갔다 하면서 쓰일 수 있다.
// 어찌 됐든 지금은 안전하다. 그래서 아래 처럼 출력할 수 있다.
cout << "Weapon Damage : " << weapon->_damage << '\n';
}
else if (item ->_itemType == IT_ARMOR) {
Armor* armor = (Armor*)item;
cout << "Armor defence : " << armor->_defence << '\n';
}
}
}
}
그리고 이제 매우 매우 매우 중요한 부분이다.
이제 생성한거를 지우려고 delete 연산자를 사용하게 되면
아래처럼 Weapon인지 Armor 인지 상관 없이 Item의 소멸자만 호출되는것을 볼 수 있다.
그러나, 원본 객체 자체가 weapon 이다 보니깐 깔끔하게 정리 되기 위해서는 item 의 소멸자만 호출 되면 안되고, weapon의 소멸자가 호출되고 그 다음에 item 의 소멸자가 호출되어야 한다.
정적으로 바인딩되어 있기 때문이다. ( 소멸자도 결국에는 함수이기 때문에 함수 오버라이딩시 컴파일러는 안에 들어있는 포인터 값이 어떤 값이기 모르기 때문에 바인딩 할때 해당 타입의 함수와 바인딩 시키기 때문이다. )
알맞은 소멸자를 호출 시키기 위해서는
위와 같이 확인해서 다시 다운캐스팅으로 item을 맞게 캐스팅한다음에 delete 하는게 옳바른 delete 방법일것이다.
동적으로 바인딩 되게 하기 위해서는 virtual 을 사용해야된다.
그래서 이때 virtual 이라는 문법을 사용하면은 되고 Item 의 소멸자에 항상 virtual을 붙혀 주는것을 잊지 말아야한다.
( 어떤 상속 관계가 있다고 할때 그 최상위 class 소멸자에는 항상 virtual을 붙혀주는것을 잊지 말자 )
이렇게 되면 item의 소멸자가 호출 될때 딱 맞는 소멸자가 호출 되게 된다.
'C++과 언리얼로 만드는 게임 개발 > Part1. C++ 문법' 카테고리의 다른 글
Day9 ( 71.42% ) (0) | 2022.04.04 |
---|---|
Day8 - ② ( 64.28% ) (0) | 2022.03.31 |
Day7 ( 54.46% ) (0) | 2022.03.30 |
Day6 ( 52.67% ) (0) | 2022.03.30 |
Day5 ( 47.32% ) (0) | 2022.03.28 |