[ 동적 할당 #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

+ Recent posts