타입변환 1, 2, 3, 4, 5

CJB_ny·2022년 8월 12일
0

C++ 정리

목록 보기
55/95
post-thumbnail

타입 변환 1

지난 시간

malloc -> void* 반환하고, 이를 우리가 (타입 변환)을 통해 사용했었다.

1. 타입 변환 유형 (비트열 재구성 여부)

    1. 값 타입 변환
    1. 참조 타입 변환

값 타입 변환

의미를 유지하기 위해, 비트열을 재구성

int a = 123456789; // 2의 보수법
float b = (float)a; // 부동소수점(지수 + 유효숫자), 명시적 형변환

계산기 두드리면 이런 값이 들어있다~인데

float같은 경우에는

완전히 다른 값이 들어가있는데

의미를 유지하기 위해서 원본객체와 다른 비트열을 재구성한다는 말이

의미 = a의 값(123456789라는 값)을 최대한 (유지, 표현 하기 위해서)

유지하기 위해서 float b에 들어갈 값의 비트열을 재구성 한다는 것이다.

1.23457어쩌구 들어가는데

이런식으로 &b들어가보면 이렇게 있다.

참조 타입 변환

비트열을 재구성 하지 않고, "관점"만 바꾸는 것.

int a = 123456789;
float b = (float&)a;

a의 값

b의 값

도 똑같이 비트열을 유지한체 값이 들어가있다.

근데 이것을 유지한체로 출력해보면

이상한 값이, 다른 값이 의미를 유지하지 못하고 출력됨.

=> 이런 경우 거의 쓸 일은 없지만,

포인터 타입 변환도 "참조 타입 변환"과 동일한 규칙을 따르니까

일석이조로 공부하자!

2. 안전도 분류

안전한 변환

  • 의미가 항상 100% 완전히 일치하는 경우
  • 같은 타입이면서 크기만 더 큰 바구니로 이동
  • 작은 바구니 -> 큰 바구니 이동 OK (업캐스팅)

ex) char -> short, short -> int, int -> __int64

불안전한 변환

  • 의미가 항상 100% 일치 한다고 보장하지 못하는 경우
  • 타입이 다르거나 (실수로 바꾸거나)
  • 같은 타입이지만 큰바구니 -> 작은 바구니 이동(다운 캐스팅)

ex)

int a = 123456789;
float b = a;
short c = a;

3. 프로그래머 의도에 따라 분류

암시적 변환

이미 알려진 타입 변환 규칙에 따라서 컴파일러가 "자동"으로 타입변환 해주는 것.

ex)

int a = 123456789;
float b = a;

이거같은 경우에는 의미를 유지하기 위해 비트열을 재구성하여 float에 암시적 변환을 한 것이다.

명시적 변환

컴파일러가 모든것을 암시적으로 변환해주지는 않는다.

ex)

int a = 123456789;
int* b = a; // 컴파일 에러

이경우는 논리적이지도 않고 위험하다.

그래서 이럴경우에 사용자가 "명시적"으로

int a = 123456789;
int* b = (int*)a; // 명시적 형변환

명시적 변환 가능.


위에처럼 포인텨 변수로 명시적 변환 할 경우 ❗

int main()
{
	int a = 123456789;
	
	int* b = (int*)a;

	int* c = &a;

	*b = 100;

	return 0;
}

정수 123456789ㄹ -> 16진수 75bcd15

메모리 위치변수의 주소(메모리 올라간 주소)변수명값(변수가 들고있는 데이타)
스택0x00000016A0D8FA24a123456789
스택0x00000016A0D8FA48b0x00000000075bcd15
스택0x00000016A0D8FA68c0x00000016a0d8fa24

말하고싶은게 b는 포인터 변수인데

주소를 답고있는 변수인데

a의 값부분(123456789)를 16진수로 변환 한 값을 주소로 들고있기 때문에

*b = 100; 이딴거하면 엑세스위반 에러 크래쉬 난다.


아무런 연관 관계가 없는 "클래스"사이의 변환

class Knight
{
public :
	int _hp = 100; // c++11 문법으로 초기화
};

class Dog
{

public:
	int _age = 1;
	int cutness = 2;
};

이렇게 C++11 문법 써서 초기화를 해주고

1. 연관없는 클래스 사이의 "값 타입"변환

Knight knight;

dog dog = (Dog)knight;

이렇게 명시적으로 하건 암시적으로 하건 "일반적으로" 안됨.

(빠져나올 구멍은 있다)

이작업을 정말 원한다면은 문법적으로 "예외 처리"가 가능하다.

타입 변환 생성자.

이런식의 타입 변환 생성자 (인자를 하나만 받는 기타 생성자)를 만들어주면은

(hp를 나이로 바꾸는 엉뚱한 기획 사안이 만약 존재한다면)

예외

타입변환 생성자, 타입 변환 연산자 사용해서 타입 변환이 가능.

타입 변환 연산자

class Dog
{
	// 타입 변환 생성자
	Dog(const Knight& knight)
    {
    	_age = knight._hp;
    }
    
    // 타입 변환 연산자
    operator Knight()
    {
    	return (Knight)(*this);
    }
};

Dog dog = (Dog)knight;
Knight knight = dog;

이렇게 변환이 가능하다

Dog라는 녀석을 대상으로 Knight로 강제변환을 할 때.

일어나는 행동들을 정의 하는 operator이다.

(return 타입이 없다)

Knight knight = dog; 그래서 이부분이 Dog를 대상으로

Knight로 강제로 변환을 해주는 것이다.

타입 변환 2

2. 연관 없는 클래스 사이의 참조 타입 변환

Ref는 knight라는 녀석 애다가 또다른 별칭을 붙이는 것이다.

타입 변환 생성자, 타입 변환 연산자를 구현해 주었지만 안되는 이유가

지금 "연관 없는 클래스 사이의 '값 타입' "에서만 적용이 되는 것이다.

이거 지금 명시적으로 형변환 해주면

이렇게 통과가 된다.

dog._cuteness

그런데 건드리게 되면

메모리를 초과해서 접근을 해서 사용을 할 것이다. => 문제

상속 관계에 있는 클래스 사이의 변환

상속 관계 클래스의 값 타입 변환

이렇게 있을 경우

자식 -> 부모 OK

부모 -> 자식 NO

상속 관계 클래스의 참조 타입 변환

Dog dog;

BullDog& bulldog = dog;

이게 된다고 하면은 bulldog의 cutness를 건드리면 다른 메모리 건드리는 위험이 있다. 그래서 컴파일 에러 내뱉어 준다.

그런데 참조도 거의 포인터 이기 때문에

이렇게하면 통과를 시켜준다.

결론

값타입 변환

진짜 비트열도 바꾸고 - 논리적으로 말이 되게 바꾸는 변환

  • 논리적으로 말이된다?
    ex) BullDog -> Dog Ok
  • 논리적으로 말이 안된다?
    ex) Dog -> bullDog, Dog -> Knight No

참조 타입 변환

비트열은 냅두고 우리의 "관점"만 바꾸는 변환

  • 땡깡 부리면 (명시적 요구) 해주긴 하는데, 말 안해도 "그냥" (암시적)으로 해주는지는 "안정성" 여부와 연관 있다.

  • 안전하다? : ex BullDog -> Dog& "그냥" 암시적으로 OK

  • 위험하다? : ex Dog -> BullDog&

    메모리 침범 위험이 잇는 경우는 "그냥" (암시적으로) 해주지 않음.
    명시적으로 정말 정말 하겠다고 최종 서명을 하면 OK

타입 변환 3

1, 2 에서 다룬 참조 타입 변환은 프로그램할 때 거의 쓴적이 없다고한다.

타입 변환 : 포인터

class Item
{
public :
	Item()
	{
		cout << "Item 기본 생성자 호출!" << endl;
	}
	Item(const Item& item)
	{
		cout << "Item 복사 생성자 호출!" << endl;
	}
	~Item()
	{
		cout << "Item 소멸자 호출!" << endl;
	}

public:
	int _itemType = 0;
	int _itemDbid = 0;

	char _dummy[4096] = {};
};

복사 생성과 복사비용 (복습)

Item item1;

Item* item2 = new Item();

이거 두개 봤을 때 머리속으로 바로 메모리가 그려져야한다.

Stack
주소값변수명타입데이터
0x0000001111111111item1Item_itemType(4), _itemDbid(4), dummy(4096)
0x0000002222222222item2포인터힙에 올라간 Item클래스 주소값 (8)
Heap
주소값변수명타입데이터
0x0000003333333333Item 클래스Item 클래스_itemType(4), _itemDbid(4), dummy(4096)

둘다 생성자하자 마자 생성자 호출이 됨.

근데 이 블록 벗어나면 더이상 "유효"하지 않기 때문에

소멸자를 호출 하게 될 것이다.

그런데 item2같은 경우에는 본체가 힙에 있기 대문에

블록을 벗어 나더라도 소멸자를 호출하지 않을 것이다.(그냥 아무것도 안함)

지금 메모리 누수 발생함. Leak발생.

delete item2 생략을 한다면...

이런 함수 두개 만들어주고

실행을 해보도록 하자.

여기서 중요한게 ❗

void TestItem(Item item)
{}

인자값으로 포인터가 아니라 Item을 받는 경우에는

사실상 데이터가 "복사"되는 개념이다.

임시 땜빵으로 사용하는 애 같지만 이녀석도 객체는 객체이다.

그래서 생성자 호출하고 소멸자도 호출한다.

Item item1;

Item* item2 = new Item();

TestItem(item1);
TestItem(*item2);

TestItemPtr(&item1);
TestItemPtr(item2);

Item 기본 생성자 호출! // item1
Item 기본 생성자 호출! // item2
Item 복사 생성자 호출! // TestItem(item1); 생성자
Item 소멸자 호출! // TestItem(item1); 소멸자
Item 복사 생성자 호출! // TestItem(item2); 생성자
Item 소멸자 호출! // TestItem(item1); 소멸자
Item 소멸자 호출! // item1 소멸자 호출

이런순서로 호출하고 소멸자 호출한다.

그래서 복사 생성하는 부분의 sizeof가 ㅈㄴ 크면 부담이 되기 때문에

주의해서 집중해서 봐주어야한다 ❗❗❗

이런문제가 배열에도 있다.

Item* item4 같은 경우에는 기본생성자가 아무것도 안뜨는 것을 볼 수 있다.

그래서 말하고싶은것이

Item item은 실제로 객체를 만든 것이지만

Item* item의 경우에는 가르키는 주소가 있을 수도 있고 없을 수도 있다는 것이다.

Item* itme4[100] = {}; 같은 경우에는 동적 할당으로 아무것도 안만들어서

가르키는 게 아무것도 없는 것이다.

그래서 만들려면 이렇게 만들어 줄 수 있다.

타입 변환 4

타입변환 포인터

Knight클래스 하나 만들어 주도록 하자.

암시적으로는 형변환이 안되는데

명시적으로는 형변환이 가능하다.

이게 cpp의 무서운 점이다.

k1은 주소를 가지고있고 그 주소가 힙의 정보를 가르키는데

item = (Item*)k1;을 해주었기 때문에

item도 주소를 가지는데 그 주소가 힙을 가르키는 묘한 상태가됨.

사실 힙에서 할당받은 크기는 4바이트 인데

(Item*)k1을 하였을 때의 주소를 타고가면 Item이 있다고 주장하는 꼴이다.

Item이라는 클래스 자체는

이렇게 큰 크기를 가지는데 말이다...

이렇게 접근으르 한다면 어떻게 될까?

현재 k1의 주소는

이렇고, 그영역에 해당하는 값을 0으로 할당받았다.

하지만 (Item*)k1을 해주었다는 의미가

이 주소부터 밑에 존나크게 Item이 있다고 본다는 것이라 이렇게 잡힐 것이다.

실제로 Item클래스의 멤버 변수들의 값을 보면 이상한 값으로 할당되어 있는 것을 확인 할수 있다.

이값이 지금

이 값이랑 똑같다.

명시적으로 캐스팅해줄 때 주의해야 한다.

자식 -> 부모 변환 테스트

class Item
{
public:
	Item()
	{
		cout << "Item 기본 생성자 호출!" << endl;
	}
	Item(int itemType)
		:
		_itemType(itemType)
	{

	}
	Item(const Item& item)
	{
		cout << "Item 복사 생성자 호출!" << endl;
	}
	~Item()
	{
		cout << "Item 소멸자 호출!" << endl;
	}

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 생성자 호출!" << endl;
	}
	~Weapon()
	{
		cout << "Weapon 소멸자 호출!" << endl;
	}
public:
	int _attack = 20;
};

class Armor : public Item
{
public:
	Armor()
		:
		Item(IT_Armor)
	{
		cout << "Armor 생성자 호출!" << endl;
	}
	~Armor()
	{
		cout << "Armor 소멸자 호출!" << endl;
	}
public:
	int _defense = 10;
};

https://velog.io/@starkshn/CPP%EC%96%B4%EC%86%8C83%EB%8B%A4%ED%98%95%EC%84%B1

명시적으로 형변환을 해주고

우리는 파생클래스의 객체는 이런 메모리가 직렬형태로 잡혀있기 때문에

weapon->_damage를 접근을 하면 크래쉬가 난다.

부모 -> 자식 변환 테스트

지금 이거 같은 경우에는

명시적으로 형변환을 해주지 않아도 통과가 되는 모습인데

Item* item = (Item*)weapon; // 이렇게 안해도 통과가 됨.

[Item 클래스][Weapon 클래스]

이렇게 있는데 뒷부분을 뚝 잘라서 보는 것임.

Item부분만 잘라서 보겠다라는 의미이다.

그러면 이제 이렇게만 만들어 주면 되는거 아닌가❓

근데 이게 또 그렇게 항상 할 수 있는 것은 아니다.

명시적으로 타입 변환할 때는 항상 항상 조심해야한다 ❗

암시적으로 될 때는 안전하다 ❓

그렇다면 평생 명시적으로 타입 변환(캐스팅)은 안하면 되는거 아닌가???

예를들어 만들어보기

1/2 확률로 무기를 만들어 준다고 가정을 하자.

	Item* item2[20] = {};

	srand((unsigned)time(nullptr));

	for (int i = 0; i < 20; ++i)
	{
		int result = rand() % 2; // 0~1

		switch (result)
		{
		case 0:
			item2[i] = new Weapon();
			break;
		case 1:
			item2[i] = new Armor();
			break;
		default:
			cout << "Error!" << endl;
			break;
		}
	}

잘 이해가 안가는 부분

int arr[3] = {};

이것은 정수형 배열이라 전체 크기는 12바이트 이고 0번째 주소값을 4바이트로 크기를 할당한 것.

int* arr2[3] = {};

이것은 포인터 배열(정수형)

이고 0번째 주소부터 접근을 하면

그곳에는 정수형으로 해석할 주소값이 들어가있다라는 의미.
(어떠한 정수형 데이터의 주소값이 있을 것.)

이렇듯 배열도 배열의 자료형 == 타입(크기)과 들어올 데이터의 자료형 == 타입(크기)이 일치 해야 하는데

이해가 안가는 부분이 현재

  • Itme 클래스 크기
    sizeof(Item) = itemType(4), ItemDbid(4), _dummy(4096) => 4104Byte
  • Weapon 클래스 크기
    sizeof(Weapon) = sizeof(Item) + _damage(4) => 4108Byte

  • Armor 클래스 크기
    sizeof(Weapon) = sizeof(Item) + _defense(4) => 4108Byte

이상태에서

rand값에 따라 switch 분기를 하여 1이 나왔을 경우

Item[0] = new Weapon를 해주게 되는데

이럴경우 아까 포인터 배열과 똑같이 해석을 하면

"0번째 주소부터 접근을 하면 그곳에는 Itme으로 해석할 주소값이 들어가있다라는 의미."

파생 클래스의 메모리 구조는 직렬형태라

[Item클래스 시작 주소값][Weapon클래스 시작 주소값]

형태라 Weapon을 동적할당하여 Item 포인터 배열에 데이터를 넣을 수 있는 것 까지는 이해하였습니다.

그런데

4108바이트 짜리를 4104바이트로 해석을 한다는 의미이니

Item[0]->_damage = 10; 이렇게 값을 접근을 못하는것 아닌가요? (Weapon클래스의 _damage 부분이 짤렷으니)

이렇게하면

Weapon이거 Armor이건 위화감 없이 사용할 수 있다

파생클래스 메모리 구조는 직렬형태이기때문에

타입 변환 5

근데

	Item* item2[20] = {};

	srand((unsigned)time(nullptr));

	for (int i = 0; i < 20; ++i)
	{
		int result = rand() % 2; // 0~1

		switch (result)
		{
		case 0:
			item2[i] = new Weapon();
			break;
		case 1:
			item2[i] = new Armor();
			break;
		default:
			cout << "Error!" << endl;
			break;
		}
	}

이렇게만 해주면 Weapon의 멤머 변수에는 접근하지 못한다.

왜 => 니가 이해를 해야함.

그래서

	for (int i = 0; i < 20; ++i)
	{
		Item* item = inventory[i];

		if (item == nullptr)
			continue;
		
		if (item->_itemType == IT_Weapon)
		{
			Weapon* weapon = (Weapon*)item;
		}
	}

이렇게 다시 캐스팅을 해주게된다면 정상적으로 동작할 것이다.

그런데 Weapon* weapon = item; 이코드 자체만 본다면은

만 해주게 된다면 끔찍한 일이 발생한다.

항상 캐스팅을 할 경우에는 매우매우매우 조심해야한다❗❗❗

매우 매우 매우 중요한 부분 ❗❗❗

우리가 지금 4108바이트이느Weapon을 동적 할당 하였음에도

관리를 하는 부분에서는 Item*으로 보고있었기 때문에

이것을 delete한다면??

(Weapon의 크기만큼 delete를 해주어야하는데)

이게 왼벽하게 delete될려면

Weapon의 소멸자가 호출이 되고 그다음에 Item의 소멸자가 호출이 되는게 맞다

(원래 생성자 함수 순서는 부머->자식, 소먈자는 자식 -> 부모)

그래서 이렇게 delete를 해준다면 이 문제는 해결이 될 것이다.

이게 맞는거임.

근데 컴파일러는 이러한 과정을 왜 자동으로 해주지 않을까?

이거는 공부를 한적이 있다.

상속관계속에서의 함수 호출 관계

함수 오버라이딩

void TestItem(Item item)
{

}

void TestItemPtr(Item* item)
{
item->Test();
}

이 Test라는 함수가 부모에게도 있고 파생클래스에게도 있다고 할때

TestItemPtr를 통해서 Test를 호출 할 경우

인자로 들어온 녀석이 뭐든

Weapon을 넘겨주든 Armor를 넘겨주든 Item을 넘겨주든

Item의 Test함수를 호출을 하게된다.

이게 이전에 배웠던 "정적 바인딩"


< 정적 바인딩 >

들어온게 뭔지는 모르겠고 그냥 Test함수 호출 할게~

https://velog.io/@starkshn/%EB%8B%A4%ED%98%95%EC%84%B1-1-2#%EC%A0%95%EC%A0%81-%EB%B0%94%EC%9D%B8%EB%94%A9


virtual

그래서 원본객체의 Test함수를 호출 하고싶을 경우에

부모 클래스에 Test함수에

virtual void Test() {}

를 같다 붙여서 파생클래스에 이것을 override하도록 하였다.

그러면 파생클래스는 생성자가 객체가 만들어질때

.vftable 테이블을 만들어서 내가 실행해야할 함수가 몇번테이블인지 일종의 표지판을 만들어 주는 것이다.

그 주소에 맞는 Test함수를 호출하게된다.

그래서 부모 클래스의 Test함수에 virtual을 붙여주게 되면

파생클래스의 Test함수에도 virtual이 사실상 붙어있는 것이랑

똑같다.

그래서

	for (int i = 0; i < 20; ++i)
	{
		Item* item = inventory[i];

		if (item == nullptr)
			continue;
		
		if (item->_itemType == IT_Weapon)
		{
			Weapon* weapon = (Weapon*)item;
            delete weapon;
		}
        else
        {
        	Armor* armor = (Armor*)item;
            delete armor;
        }
	}

이런식으로 할게 아니라 부모 클래스의 소멸자에

virtual을 붙여 주게 되면은

파생클래스의 소멸자에도 역시

이렇게 붙은거랑 마찬가지이기 때문에

(virtual키워드 안 붙여도 됨)

	for (int i = 0; i < 20; ++i)
	{
		Item* item = inventory[i];

		if (item == nullptr)
			continue;

		delete item;
    }
        

이렇게 item을 delete한다고 하여도

각자의 오버라이딩 한 소멸자가 잘 호출이 된다.

원본 타입의 소멸자를 잘 호출해주는데

이것은 생성자가 객체를 만들대 가상함수 테이블 주소를

객체의 첫번째 주소로 넣고 객체를 만들어주기 때문에

가능한 것이다.

까먹으면 안되는것 ❗❗❗❗

상속 관계에서는 부모클래스의 소멸자에 virtual 키워드를 붙이는 것을 잊지말자!!!

결론

  • 포인터 vs 일반 타입 : 차이를 이해
Knight k1;

Knight* k2 = new knight(); 
  • 포인터 사이의 타입 변환 (캐스팅)을 할 때는 매우매우 조심해야한다.

ex) 여러개의 클래스를 최상위의 부모 클래스로 바꾸어서 관리를 하고 싶을 경우

  • 부모-자식 관계에서는 부모 클래스의 소멸자에는 virtual을 붙여야한다.

    이유를 기억해야한다!!!

virtual 이유

부모 자식 클래스가 있을 경우 동적할당한 데이터들을

관리를 하기 편하게 하기 위해서

부모 클래스로 캐스팅하여 관리를 할경우

나중에 delete를 하여 동적할당된 녀석을 지울때 깔끔하게 안 지워지는 현상이 발생을 한다.

따라서 부모 클래스의 소멸자에 virtual을 붙여주어야지

자식 클래스 객체를 만들때 생성자가 객체주소 제일 앞에

가상함수 테이블 주소를 박아두기 때문에

나중에 부모 클래스 주소를 delete하더라도

객체에 제일앞에 가상함수 테이블이 있기 때문에

원본 객체에 맞는 가상함수테이블 주소로가서 원본의 소멸자를 호출하게 해준다.

profile
https://cjbworld.tistory.com/ <- 이사중

0개의 댓글