타입 변환
타입 변환은 크게 두 가지로 구분할 수 있다.
1. 값 타입 변환
의미를 유지하기 위해서 원본 객체와 다른 비트열로 재구성한다.
즉 최대한 원본 값의 의미를 최대한 유지하는 선에서 물리적으로 저장된 비트열을 재구성하는 것이다.
2. 참조 타입 변환
원본 데이터는 그대로 유지하고 우리가 바라보는 관점만 바꾼다.
즉 동일한 데이터를 다르게 해석해서 다른 값을 출력한다는 뜻이다.
안전도를 기준으로 한 분류
1. 안전한 변환
의미가 항상 완전히 일치하는 경우를 말한다.
어떤 상황에서 의미가 완전히 일치하는 변환이 일어날까?
예를 들어 같은 타입이면서 크기만 더 큰 바구니로 이동했을 때가 있다.
2. 불안전한 변환
의미가 항상 완전히 일치한다고 보장하지 못하는 경우
예를 들어 타입을 바꾸는 경우, 같은 타입이지만 큰 바구니에서 작은 바구니로
변환하는 경우 등이 있다.
프로그래머 의도에 따른 분류
1. 암시적 변환
이미 알려진 타입 변환 규칙에 따라서 컴파일러가 자동으로 변환해주는 것을 말한다.
2. 명시적 변환
암시적과는 반대 개념이다. 사용자가 명시적으로 변환을 해주는 것을 말한다.
다음은 아무런 연관 관계가 없는 클래스 사이의 변환에 대해 알아보자.
1. 연관 없는 클래스 사이의 값 타입 변환
일반적으로 정상 작동하지 않는다.
일반적이지 않게 특수한 방법을 사용해서 변환하면 되지 않을까?라고 생각이 드는데
결론은 가능하다.
a 클래스와 b 클래스가 존재하고 둘은 완전히 연관성이 없다고 가정해보자.
a 클래스를 값 타입 변환을 통해 b 클래스로 변환하려고 할 때 에러가 발생하지만 사용자가 의도적으로
b 클래스 내부에 타입 변환 생성자를 만들어서 a 클래스를 받아들이면 일단 정상적으로 컴파일은 된다.
물론 예외적인 상황이다. 타입 변환 생성자 말고도 하나 더 가능한 방법이 있는데 타입 변환 연산자가 그것이다.
operator를 이용해서 클래스 변환을 명시적으로 해주면 클래스 변환이 가능하다.
2. 연관 없는 클래스 사이의 참조 타입 변환
a 클래스를 b 클래스로 변환할 때
a xx;
b& x = (b&) xx;
다음과 같이 명시적으로 지정해주면 일단 정상적으로 통과되는 것을 확인할 수 있다.
하지만 b 클래스에만 존재하는 변수를 잘못 건드리면 원치 않게 메모리를 오염시킬 수 있기 때문에
조심해야 한다.
상속 관계에 있는 클래스 사이의 변환
1. 상속 관계 클래스의 값 타입 변환
자식 클래스를 부모 클래스로 변환하는 것 가능
부모 클래스를 자식 클래스로 변환하는 것은 불가능
2. 상속 관계 클래스의 참조 타입 변환
자식 클래스를 부모 클래스로 변환하는 것 가능
부모 클래스를 자식 클래스로 암시적 변환은 불가능 명시적 변환은 가능
연관성이 없는 클래스 사이의 포인터 변환
이번에는 포인터 변환이다.
서로 연관성이 없는 클래스 a, 클래스 b가 있다고 가정하고
a 클래스를 포인터로 선언하고 동적 메모리에 할당한다.
그리고 a 클래스를 명시적 변환을 통해 b 클래스 포인터에 집어넣을 수 있다.
위 상황처럼 했을 때 서로 다른 클래스이기 때문에 원치 않게 다른 메모리 영역을 오염시킬 수 있다.
메모리를 오염시켰을 때 바로 에러가 발생하면 다행이지만 며칠 뒤 혹은 몇 달 뒤에
갑작스레 문제가 될 수도 있다. 그렇기 때문에 타입 변환을 신중하게 해야 한다.
다음은 상속관계에 있는 클래스의 변환에 대해 알아보자
부모 -> 자식 변환
논리적으로 생각해봐도 부모에서 자식으로 변환하는 건 불가능하다.
하지만 명시적 변환을 통해 억지로 변환시킬 수 있는데 이렇게 진행하면 위와 마찬가지로
의도치 않게 메모리 영역을 오염시킬 수 있다.
자식 -> 부모 변환
자식에서 부모 클래스로 변환하는 것은 암시적, 명시적 둘 다 가능하다.
부모에서 자식으로 변환하는 것과는 다르게 어느 정도 안정적이다.
실제 게임 제작을 하다 보면 암시적 및 명시적으로 포인터 타입 변환을 해주어야 하는 상황이
발생하기 때문에 실제 타입 변환을 해야 한다면 정말 주의를 요해야 한다.
중요한 개념
a라는 클래스가 최상위 부모 클래스라고 가정하고 a 클래스를 상속받는 자식 클래스들이 여럿 있다고
생각해보자. 동적 할당을 이용해서 메모리를 사용하고 이후에 메모리를 반환하려고 하는데
최상위 부모 클래스만 delete 명령을 이용해서 반환을 하게 되면 정상적으로 메모리 반환이 이루어지지 않는다.
자식에서 부모로 타입 변환해서 사용했던 클래스들을 다시 부모에서 자식으로 변환을 하고 각각 메모리를 반환
해야 하는 번거로움이 존재한다.
위의 상황을 한 번에 해결해줄 수 있는 게 있는데 바로 최상위 부모 클래스 내부의 소멸자를 가상 함수로
만들어주는 것이다. 가상 함수는 예전에도 공부했듯이 가상 함수 테이블에 의해 원본 객체의 함수를 찾아가기 때문에
최상위 클래스의 소멸자를 가상 함수로 만들어주면 해당 최상위 부모 클래스를 상속받고 타입 변환을 했던 자식
클래스들은 메모리를 반환할 때 가상 함수에 의해 자동으로 원본 객체의 소멸자를 호출하게 된다.
이렇게 되면 이용했던 모든 동적 할당 메모리를 정상적으로 반환할 수 있다.
(최상위 부모 클래스 소멸자에만 virtual을 붙여주어도 정상 작동하지만 웬만하면 부모 클래스의 소멸자에는
virtual을 붙이자)
개념 다시 한번 정리! 소멸자에 virtual을 붙여야 하는 이유! 아래 메모는 무조건 이해하고 외우자 상속 관계에 의해 함수를 재정의 해도 타입에 따라 실행이 되기 때문에 실제 객체, 즉 원본 자체를 찾아가고 싶다면 virtual을 붙이자 virtual에 의해 가상 함수 테이블이 만들어지고 해당 테이블을 참고해서 원본으로 이동해 원본의 함수를 실행할 수 있다.
C++에서 캐스팅(타입 변환)
C++에서는 아래 네 가지 방법으로 타입 변환 연산을 하게 된다.
1. static_cast : 타입 원칙에 비춰볼 때 상식적인 캐스팅만 허용해준다.
1) int <-> float // 정수 타입과 부동소수점 타입 사이의 변환
ex) int a = 100;
int b = 200;
float x = static_cast<float>(a) / b;
2) 부모클래스* -> 자식클래스* (다운캐스팅) // 단, 안정성 보장 못함
ex) A, B, C 클래스가 있다고 가정. 그 중에서 A가 부모 클래스 B, C가 A클래스를 상속받는 자식 클래스이다.
A* a = new B(); // 원본 타입이 B 클래스, A 클래스가 부모이기 때문에 가능
B* b = static_cast<B*>(a); // 원본이 B라는 걸 확신한 상태에서 B* 타입으로 다운 캐스팅. 해당 방법이
안전한 방법이다.
A* a = new C(); // 원본 타입이 C 클래스, A 클래스가 부모이기 때문에 가능
B* b = static_cast<B*>(a); // 원본이 C 클래스임을 제대로 확인하지 못하고 B 클래스로 변환 시도
변환 후 데이터 변경을 할 때 의도치 않은 이상한 메모리 영역의 값을 바꿀 수도
있기 때문에 아주 위험함.
2. dynamic_cast : 상속 관계에서의 안전한 형변환을 지원해준다.
1) RTTI (RunTime Type Information) : 실시간으로 코드가 동작할 때 타입을 확인할 수 있는 기법이다. 다시 말하면
다형성을 활용하는 방식이다. dynamic_cast를 사용하려면 이러한 다형성을 사용해야 하고 이러한 다형성을
활용하는 방법은 virtual 함수를 사용하는 것이다. 다시 말하면 dynamic_cast를 사용하고 싶다면 최소 1개 이상의
virtual 함수를 사용해야 한다. virtual 함수를 사용하면 객체 메모리에 가상 함수 테이블 주소가 기입되고 그 상태에서
dynamic_cast를 사용해서 캐스팅을 할 때 맞는 타입인지 원본을 체크해서 캐스팅을 해준다. 만약 잘못된 타입으로
캐스팅을 했다면 nullptr을 반환해준다.
2) static_cast와의 차이점 : 두 캐스팅 모두 상속 관계에서의 형변환을 지원해주지만 잘못된 타입으로 캐스팅을 한 경우에
차이가 발생한다. static의 경우 잘못된 타입으로 캐스팅하면 별 다를 것 없이 캐스팅이 되지만 dynamic의 경우 잘못된
타입으로 캐스팅한 경우 nullptr을 반환하기 때문에 올바른 타입으로 캐스팅했는지 확인하는 용도로 자주 사용된다.
하지만 static은 dynamic에 비해 속도가 빠르기 때문에 원본 타입을 알고 있다면 static을 사용하는 것이 유리하다.
3. const_cast : const를 붙이거나 떼어내거나 할 때 사용된다.
1) 다음과 같이 사용할 수 있다.
ex) void PrintName(char* str) // 캐릭터형 포인터를 받아서 그대로 출력
{
cout << str << endl;
}
PrintName("Hello"); // 이렇게 사용할 시 에러가 발생한다. PrintName 함수는 캐릭터형 포인터를 받지만 매개값으로
넣어주는 값은 const 캐릭터형 포인터이기 때문이다. 이런 경우 사용할 수 있는 것이
const_cast이다.
PrintName(const_cast<char*>("Hello")); // 대부분의 경우 이 const_cast를 사용하는 경우는 매우 매우 드물다.
일단 이러한 것이 존재한다는 것을 알아두자.
4. reinterpret_cast : 가장 위험하고 강력한 형태의 캐스팅이다. 포인터랑 전혀 관계없는 다른 타입으로
변환 등에 사용된다.
1) __int64 qqq = reinterpret_cast<__int64>(b); // 이런 식으로 사용할 수 있는데 여기서 b는
B* b = static_cast<b*>(a); 이것을 의미한다. 즉 말 그대로
전혀 다른 타입으로 캐스팅을 가능하게 해 준다.
2) 포인터와 정수 사이에서도 변환이 가능하고 혹은 전혀 상관없는 클래스 포인터끼리의 변환도
가능하다.
re-interpret_cast를 그나마 사용하는 케이스를 소개하자면
void* q = malloc(1000); // 1000바이트 크기만큼 동적 할당. 반환 타입은 보이드형 포인터이다.
(보이드형 포인터는 사용자가 어떤 타입으로 사용할지 모르니 알아서 변환해서 사용하라는 뜻이다.)
A* a2 = q; // 맨 위에 A 클래스를 가지고 왔다. 보이드형 포인트인 q와 A*가 아무런 연관성이 없다 보니
기본적으로 실패 처리를 해주고 이러한 경우 사용하는 것이 reinterpret_cast이다.
A* a2 = reinterpret_cast<A*>(q); // 이 reinterpret_cast 또한 아주 아주 드물게 사용된다.