[C++] 타입 변환과 얕은 복사, 깊은 복사
[ C++ : 타입 변환과 얕은 복사, 깊은 복사 ]
[ 안전도에 따른 분류 ]
[1] 안전한 변환
의미가 항상 100% 완전히 일치하는 경우
작은 바구니 → 큰 바구니로 이동 (업캐스팅 Upcasting)
ex) char → short, short → int, int → __int64
[2] 불안전한 별환
의마가 항상 100% 일치한다고 보장하지 못하는 경우
1) 타입이 다르거나 (정수 → 실수)
2) 같은 타입이지만 큰 바구니 → 작은 바구니로 이동 (다운캐스팅 Downcasting)
[ 프로그래머의 의도에 따른 분류 ]
[1] 암시적 변환
이미 알려진 타입 변환 규칙에 따라 컴파일러가 '자동'으로 타입 변환
int a = 123456789;
float b = a; // implicit
→ 경고 또는 컴파일러 설정에 따라 에러
[2] 명시적 변환
int a = 123456789;
int* b = (int*)a; // explicit
→ 컴파일러가 봤을 때, int → int*로의 변환은 일반적이지 않으니 막아주지만, 명시적으로 변환한다면 통과는 됨.
[ 아무런 연관 관계가 없는 클래스 사이의 변환 ]
[1] 연관없는 클래스 사이의 '값 타입' 변환
일반적으로는 불가능.
예외) 타입 변환 생성자, 타입 변환 연산자
/* ... */
class Dog
{
public:
// 타입 변환 생성자
Dog(const Knight& knight)
{
_age = knight._hp;
}
// 타입 변환 연산자
operator Knight()
{
return (Knight)(*this);
}
/* ... */
}
/* ... */
Knight knight;
Dog dog = (Dog)Knight;
Knight knight2 = dog;
[2] 연관없는 클래스 사이의 '참조 타입' 변환
위에서 만든, 타입 변환 생성자와 타입 변환 연산자는 '값 타입'에서만 적용되어 '참조 타입' 변환에서는 작동하지 않는다.
Knight knight;
Dog& dog = knight; // Error
Dog& dog = (Dog&)knight; // Non-Error
위는 에러를 내뱉게 되는 것.
아래는 통과되지만, 8바이트인 Dog에 4바이트 Knight를 변환했으므로 접근해서는 안되는 메모리를 건드리게 된다.
통과되는 이유는, 참조는 결국 어셈블리단에서 포인터와 똑같기 때문.
malloc의 void*와 같이, 포인터는 객체 값과 다르다.
실질적으로 데이터가 정해지지 않았고, 어떻게 변환해서 사용할지 결정해야 하기 때문에 주소를 타고가면 진짜 값이 무엇인지는 컴파일러가 여기서 확인을 하지 않기 때문에 통과가 되는 것.
그러므로 값 타입 변환은 실제 값을 다루는 것이므로 컴파일러가 변환시에 바로 통과 유무를 엄격하게 검사하게 되는 것이다.
[ 상속 관계에 있는 클래스 사이의 변환 ]
자식 → 부모 OK / 부모 → 자식 NO
[1] 상속 관계 클래스의 값 타입 변환
BullDog bulldog;
Dog dog = bulldog; // Bulldog은 Dog인가요? → OK
[2] 상속 관계 클래스의 참조 타입 변환
- 부모 → 자식 (명시적으로만 가능)
Dog dog;
BullDog& bulldog = dog; // Error
BullDog& bulldog = (BullDog&)dog; // OK
- 자식 → 부모 (OK)
BullDog bulldog;
Dog& dog = bulldog; // OK
이런 에러 유무를 계속 확인하는 이유는, 포인터 타입 변환을 할 때 기본적인 이해도를 끌어올리기 위함임.
기본적으로, 참조 변환에서 (상관없는 타입끼리의 변환) 또는 (부모 → 자식으로의 변환)이 성공했을 때 메모리 침범 위험이 있으므로 이 때는 암시적으로는 통과되지 않는다는 것을 기억하면 됨.
최종 결론)
[ 값 타입 변환 ] : 진짜 비트열도 바꾸고, 논리적으로 말이 되게 바꾸는 변환
논리적으로 말이 된다 (BullDog → Dog) : OK
논리적으로 말이 안된다 (Dog → BullDog, Dog → Knight) : Error (타입 변환 생성자와 연산자는 논외)
[ 참조 타입 변환 ] : 비트열을 제외하고 '관점'만 바꾸는 변환
명시적으로 변환하면 통과되지만, 암시적으로 통과 유무는 안정성 여부와 관련 있음.
- 안전 (BullDog → Dog&) : 암시적으로 OK
- 위험 (Dog → BullDog&) : 메모리 침범 위험이 있으므로, 암시적으로 Error
명시적 변환을 통해 정말 변환 하겠다고 하면, 그땐 통과 OK
============================================================================================
[ 타입 변환 (포인터) ]
[ 연관 없는 클래스 사이의 포인터 변환 ]
// Knight는 4바이트, Item은 8바이트
Knight* knight = new Knight();
Item* item = (Item*)knight;
item->_itemType = 2;
item->_itemDbId = 1;
위 코드에서, _itemType은 Knight의 _hp 자리에 2가 덮어 써지지만, 그 다음 부터는 유효한 메모리 범위를 넘어서 건드리게 되는 것이므로, 매우 위험한 행위가 된다.
일반적으로 디버그 단계에서 크래시가 나면 정말 다행이지만, 크래시가 나지 않고, 만약 골드같은 재화가 해당 메모리에서 미리 사용 중 이었다면 어떻게 될까?
→ 최악의 상황에는 특정 유저의 골드가 임의의 접근되어 복사가 일어날 수도 있을 것이다.
[ 연관 있는 클래스 사이의 포인터 변환 ]
1) 부모 → 자식 변환
Item* item = noew Item();
Weapon* weapon = (Weapon*)item; // explicit OK
weapon->_damage = 10;
delete item; // Crash : heap corruption
→ 명시적 변환을 해줬으므로 통과는 되지만, 잘못된 메모리 (weapon->_damage)를 건드려서 크래시 발생.
delete할 때 컴파일러가 Heap Corruption Detected로 크래시를 발생시킴.
2) 자식 → 부모 변환
Weapon* weapon = new Weapon();
Item* item = weapon; // implicit OK
delete weapon;
Weapon은 Item인가요? → 논리적으로 말이되기 때문에, 암시적으로도 통과가 된다.
명시적으로 타입 변환할 때는, 항상 조심해야 한다 !
암시적으로 변환될 때는 안전하다.
→ 평생 명시적으로 타입 변환(캐스팅)을 안하면 되지 않을까?
: Weapon, Armor 둘 다 Item이라는 최상위 부모 클래스로 관리할 수 있기 때문에 인벤토리에 무기가 들어갈 수도 있고, 방어구가 들어갈 수도 있기 때문에 각각의 Weapon*, Armor*로 만들어주기 보다, 둘 다 동시에 커버할 수 있는 Item*으로 만들어준다면, Weapon도 넣을 수 있고 Armor도 넣을 수 있는 편리함이 있다.
그렇기 때문에 Weapon*, Armor* 타입들을 Item*에 공통적으로 담아뒀다가, 사용할 일이 있을 때 다시 부모 → 자식으로 다운캐스팅을 해서 쓸 일이 있기 때문에 명시적 타입 변환이 필요하게 되는 것이다.
[ 소멸자 문제 ]
Item*에 Weapon* Armor* 등을 담아서 저장하고 있다가, 더 이상 쓸일이 없어 전부 delete 했다고 가정해보자.
전부 Item의 소멸자가 호출된다.
하지만, Weapon과 Armor는 Item을 상속받아 추가적인 메모리를 사용하는 파생 클래스이므로, Weapon 또는 Armor의 소멸자는 호출되지 않을 것이다.
→ Item의 메모리만 해제되어 나머지 부분에서 memory leak이 발생할 것이다.
다형성을 다룰 때, 상속 관계에서 본체에 대한 함수를 호출하는 가상 함수를 배운 적이 있었다.
* 상속 관계가 있을 때, 최상위 부모 클래스의 소멸자는 전부 virtual로 선언해야한다는 것.
이제, Item 소멸자가 호출되기 전에, 자식 클래스의 소멸자도 순서에 맞게 같이 호출된다.
[ 결론 ]
[1] 포인터 vs 일반 타입의 차이를 이해하자.
Knight knight; // stack : sizeof(Knight)
Knight* knight = new Knight(); // stack : 4bytes, heap : sizeof(Knight)
또한, 함수에 인자로 넘겨줄 때에도 복사 방식을 ㅗ넘겨주는 것과, 포인터 방식으로 넘겨주는 것은 완전히 다르다.
복사 방식 : 복사된 것 또한 별도의 생명체이기 때문에 복사 생성자 호출, 다시 소멸자 호출.
포인터 방식 : 주소만 넘겨줬기 때문에 호출되지 않음.
[2] 포인터 사이의 타입 변환(캐스팅)을 할 때는 매우 조심해야 한다 !
위험하긴 하지만 아예 안할 수 없는 이유는, 상속 관게에서 클래스들을 최상위 타입으로 캐스팅해서 저장하고 싶을 때, 예를들면 인벤토리와 Weapon, Armor같은 경우 어쩔 수 없이 (자식 → 부모), (부모 → 자식) 으로 자주 캐스팅하게 될 것이다.
[3] 부모 - 자식의 상속 관게에서 최상위 부모 클래스의 소멸자에는 항상 virtual을 붙이자 !
이유도 알아야 하는데,
상속 관계에 의해 함수를 재정의 해봤자 현재 타입에 따른 소멸자만 호출되기 때문에, 최상위 클래스의 소멸자에 virtual을 붙여주게 되면, 소멸자도 함수이므로 가상 함수 테이블이 만들어지면서 실제 원본 객체가 어떤 것이냐에 따라 해당 소멸자도 같이 호출하게 될 것이다.
만약 virtual이 없다면, 자식 클래스의 소멸자는 호출되지 않고 부모 소멸자만 호출되어, 자식 클래스에 할당된 메모리는 해제되지 않을 것임.
[ 얕은 복사와 깊은 복사 ]
class Knight
{
public:
// 기본 복사 생성자
// 기본 복사 대입 연산자
public:
int _hp = 100;
};
/* ... */
Knight knight; // 기본 생성자
knight._hp = 200;
Knight knight2 = knight; // 복사 생성자
Knight knight3; // 기본 생성자
knight3 = knight; // 복사 대입 연산자
복사 생성자와 복사 대입 연산자는, 복사와 연관성이 있기 때문에 특별 대우를 받는다.
→ 만들어주지 않으면 컴파일러가 '암시적으로' 만들어준다.
기본적으로 똑같은 데이터를 갖도록 해주는 것.
하지만, class 멤버 변수에 "참조 값"이나 "포인터"가 들어가게 된다면, 프로그래머가 복사 생성자 또는 복사 대입 연산자를 직접적으로 정의해야 하는 상황이 생길 수 있다.
예를 들어보자.
Knight에서 멤버 변수로 Pet 클래스를 포인터가 아닌 일반 변수로 들고 있게 된다면, Knight가 생성되면서 Pet도 같이 만들어지고, 소멸될 때도 같이 소멸된다.
→ 어셈블리를 까보면 Knight 생성자 중간에 Pet의 생성자도 실행된다.
단점:
[1] 생명 주기 관리가 굉장히 어려워진다.
[2] Pet에 있는 데이터가 Knight에 그대로 들어가게 된다. (Knight의 크기가 같이 비대해진다.)
[3] Pet을 상속받은 BrownPuppyPet HuskyPet 등이 있을 경우, 파생 클래스를 사용할 수 없게 됨. (매번 변수를 추가해줘야 한다.)
→ 일반적으로, 포인터 or 참조값을 들고있는게 더 낫다.
이제, 복사를 하게되면 문제가 발생한다.
knight에 힙 영역에 동적할당된 Pet을 주고, knight2, knight3에 복사해준다면 주소값 조차도 그대로 복사해주기 때문에 knight와 knight2, knight3가 멤버변수로 들고있는 펫이 전부 똑같다는 문제가 생긴다.
→ 멤버 변수에 참조값이나 포인터가 들어간다면, 기본 복사 생성자 또는 복사 대입 연산자로는 해결할 수 없는 문제가 생긴다.
[ 얕은 복사 Shallow Copy ]
멤버 데이터를 비트열 단위로 '똑같이' 복사 (원본 메모리 영역 값을 그대로 복사)
포인터는 주소값을 담는 바구니이므로, 주소값을 똑같이 복사하고, 동일한 객체를 가리키는 상태가 됨.
→ Knight가 Pet의 생명주기를 똑같이 관리한다고 가봉해보자. Knight의 생성자/소멸자에서 pet을 new/delete 해줄 경우, knight, knight2, knight3에서도 똑같은 객체에 대해 delete가 실행되어 "크래시"가 발생하게 된다. (double free)
[ 깊은 복사 Deep Copy ]
멤버 데이터가 참조(주소)값이면, 데이터를 새로 만들어준다.
원본 객체가 참조하는 대상까지 새로 만들어서 복사.
포인터는 주소값 바구니 → 새로운 객체를 생성 → 각자 다른 객체를 가리키는 상태가 됨.
[1] 복사 생성자에서의 깊은 복사
Knight(const Knight& knight)
{
_hp = knight._hp;
pet = new Pet(*(knight.pet)); // 깊은 복사, const &를 받기 때문에 값을 넘겨줘야 함.
}
[2] 복사 대입 연산자에서의 깊은 복사
Knight& operator=(const Knight& knight)
{
_hp = knight._hp;
pet = new Pet(*(knight.pet)); // 깊은 복사, const &를 받기 때문에 값을 넘겨줘야 함.
return *this;
}
이제 knight, knight2, knight3의 Pet*이 전부 다르다 !
[결론]
어떤 클래스에 "포인터 혹은 참조" 멤버 변수가 있다면, 대부분 깊은 복사를 먼저 생각 해야한다.
깊은 복사의 필요성은 [복사 생성자]와 [복사 대입 연산자]를 컴파일러가 암시적으로 만들어주던 버전의 한계 때문.
[ 얕은 복사와 깊은 복사 #2 ]
실험)
- 암시적 복사 생성자에 관한 Steps
1) 부모 클래스의 복사 생성자 호출
2) 멤버 클래스의 복사 생성자 호출 (포인터가 아닌 일반 형태로 들고 있을 때를 "멤버 클래스"라고 칭함.)
3) 두 경우가 모두 아니고, 멤버가 기본 타입일 경우 메모리 복사 (얕은 복사 Shallow Copy)
- 명시적 복사 생성자 Steps
1) 부모 클래스의 기본 생성자 호출
2) 멤버 클래스의 기본 생성자 호출
→ 기본 생성자만 호출 된다는 것을 주목해라.
멤버 변수가 클래스라고 해서 무조건 깊은 복사를 해야하는 것은 아니다.
모든 Knight가 하나의 Pet을 공통적으로 소유하는 것도 말이 안되는 것은 아니기 때문이다.
→ 명시적으로 만들어주는 순간, 모든 복사에 대한 것을 프로그래머가 컨트롤 해야 되기 때문에 기본 생성자만 호출해주고 그 다음은 프로그래머가 필요하면 알아서 잘 정해서 사용을 해야한다.
Knight(const Knight& knight) : Player(knight), _pet(knight._pet) // handle Player, _pet
{
cout << "Knight(const Knight&)" << "\n";
_hp = knight._hp;
}
- 암시적 복사 대입 연산자 Steps
1) 부모 클래스의 복사 대입 연산자 호출
2) 멤버 클래스의 복사 대입 연산자 호출
3) 두 경우가 모두 아니고, 멤버가 기본 타입일 경우 메모리 복사 (얕은 복사 Shallow Copy)
- 명시적 복사 대입 연산자 Steps
1) 알아서 해주는 것 없음.
Q) 왜 이렇게 혼란스러울까?
A) 객체를 '복사'한다는 것은 두 객체의 값들을 일치시키려는 것.
따라서 기본적으로 얕은 복사 (Shallow Copy) 방식으로 동작
명시적으로 [복사 생성자], [복사 대입 연산자]를 만들어주게되면 기본적으로 컴파일러가 알아서 처리하던 부분이 누락되다보니 복사가 절반만 일어나게 되는 것.
결국 깊은 복사를 선택할 경우, "명시적 복사 생성자 or 명시적 복사 대입 연산자"를 해야하므로 [모든 책임]을 프로그래머한테 위임하겠다는 의미
[ Reference ]
Rookiss Part1. C++ 입문