C++ 캐스팅 연산자 소개 (1) - static_cast

Programming/C/C++ Programming
2009. 8. 17. 19:18, Posted by ScottRhee

캐스팅은 기존 C에서도 지원하는 기능이며, 형변환을 할 때 마구잡이로 사용하던 기술이기도 했다. 상식적으로 불가능한것만 아니면 웬만한 변환은 다 지원해버렸기 때문에 마구마구 썼었지.. 별도의 키워드조차 필요치 않고 타입에다 괄호만 사용해주면 되니 사용법도 무지 간단하다.  C++을 배우고 나서도 C++을 위한 별도의 캐스팅 연산자가 있는줄도 몰랐으니 말 다했지 뭐. 

그런데 이건 어떨까. 

class CClass1
{
public:
int m_intTest;
};

class CClass2
{
public:
int m_intTestTmp;
int m_intAdditional;
};


void main()
{
CClass1 cl1;
CClass2* pcl2 = (CClass2*)&cl1;

pcl2->m_intAdditional = 0;
}

이 코드의 경우 컴파일타임에는 전혀 에러가 나지 않는것이 당연하다. C의 캐스팅은 적당한 캐스팅연산자만 써주면 그냥 만사 다 OK거든.. 심지어는 char szTest = (char)"어버버"; 이런거나 int *pintTest = (int*)'a'; 이런 괴상한 코드도 경고 없이 다 OK해버린다. 

암튼 위 코드를 실행하면, 그 결과는 당연히 크래시..... CClass2의 인스턴스가 갖게 될 메모리 사이즈가 CClass1보다 더 클테고, 따라서 CClass2의 포인터로 CClass1의 인스턴스를 액세스하게 되면 메모리크기의 차이때문에 fault가 생기게 되는 것이다. 이것이 C캐스팅의 한계라고 할 수 있다. 물론 미리 코드를 잘 검사해서 저런 코딩을 할 확률을 낮춰줄 수 있으면 좋은데, 그게 어디 쉬운가.. 슬픈 것은 상당수의 C++소스가 이런식으로 되어 있어서 디버깅이 무지 어렵다는 것이다. 물론 나도 단지 귀차니즘을 이유로 저런식의 코딩을 참 많이 했는데.. 일단 지금부터라도 가급적 C++전용의 캐스팅 연산자만 써보려고 한다. 코딩할 때 약간 귀찮은 것이, 디버깅하느라 밤새는 것보다는 훨씬 이익이니까. 

그럼, 포인터끼리 캐스팅을 할 때 가장 문제가 되는 상황이 어떤 것일까? 내 생각에는 대충 두 가지인 것 같다. 

첫째, 아예 다른 클래스끼리의 캐스팅.
둘째, 상속/피상속 관계에 있는 클래스에서, 자식클래스의 포인터가 부모클래스의 인스턴스를 가리키도록 하는 캐스팅. (또는 그 역)

첫째는 웬만하면 그냥 무조건 막으면 되니까 이해하기 쉬우나 둘째의 경우는 좀 더 세밀한 판단(상속/피상속 관계 확인)이 요구됨을 예상할 수 있을 것이다. 

아무튼 첫번째 상황을 체크하기 위해서 main 코드 부분을 다음과 같이 바꿔봤다. 

CClass1 cl1;
CClass2* pcl2 = static_cast<CClass2 *>(&cl1);

pcl2->m_intAdditional = 0;

아까와는 달리, 컴파일 타임에 에러가 발생한다. 

error C2440: 'static_cast' : cannot convert from 'CClass1 *__w64 ' to 'CClass2 *'
        Types pointed to are unrelated; conversion requires reinterpret_cast, C-style cast or function-style cast

대충 보면, 두 클래스간 관계가 없어서 캐스팅에 실패했다고 한다. C스타일 캐스팅을 사용하든지, reinterpret_cast를 사용하라고 되어 있다. C스타일 캐스트는 말 그대로 C의 캐스팅이니 앞서 본 소스처럼 코딩을 하라는 것이고, reinterpret_cast는 C++에 존재하는 기존 C식 캐스팅이라고 생각하면 될것 같다. (다만 이녀석은 기본타입간 변환은 불가하고, 포인터와 관계된 변환만 제공하므로 분명한 차이가 있기는 하다.) 함수식 캐스팅은.. 음 기회가 되면 다음에.. 

단지 캐스팅 연산자만 바꿨을 뿐인데도, 전혀 다른 클래스를 실수로 캐스팅에 우겨넣는 실수는 하지 않게 되었다. Olleh!

여기까지는 이해하기가 쉬운데, 만약 두 클래스가 부모/자식 관계라면 어떨까? (위에서 살펴 본 두 번째 상황에 해당) 어쨌든 서로 다른 클래스이므로 캐스팅을 거부할 수도 있고, 연관성이 있으니 캐스팅을 허용할 수도 있을 것이다. 백문이 불여일견! 고고싱 

class CParent
{
public:
int m_intTest;
};

class CChild : public CParent
{
public:
int m_intTest2;
int m_intAdditional;
};

void main()
{
CParent clParent;
CChild clChild;

CParent *pclParent; 
CChild *pclChild;

pclParent = &clChild; // OK
pclChild = &clParent; // ERROR
pclChild = static_cast<CChild*>(&clParent); // OK
}

1. 부모클래스의 포인터에서 자식클래스의 인스턴스를 받을 때 : 캐스팅 안해도 OK (당연하지.. 소멸자 문제만 빼면)
2. 자식클래스의 포인터에서 부모클래스의 인스턴스를 받을 때 : 캐스팅 안하면 에러 (역시 당연함.. 자식클래스 고유의 멤버가 사용될 우려가 있음) 
3. 자식클래스의 포인터에서 부모클래스의 인스턴스를 받으면서 static_cast를 사용한경우 : OK 

즉, static_cast의 경우는 자식클래스 포인터에서 부모의 인스턴스를 받아도 경고없이 그냥 허용한다는거다. 아예 다른 타입의 경우 변환을 불허하지만, 상속관계에 있는 클래스의 경우에는 C스타일의 캐스트처럼 그냥 다 허용한다는 것. 결국 부모클래스의 인스턴스로 차일드 클래스의 멤버를 호출하면 문제가 발생하게 되는 것이다. 

그럼 static_cast를 아예 안쓰면 안전하지 않냐고? 인스턴스 관리에 있어서 항상 최하위 클래스의 포인터로만 작업하기 어렵다는 것은 경험상 알고 있을 것이다. (사과, 복숭아, 오이, 마늘 등을 모아놓고, 한큐에 "껍질까기"메서드를 호출하고 싶을 때처럼..) 

과거에 상속이 얽히고 설킨 상황에서 포인터로 멤버 함수를 호출하고자 했을 때 함수 선언을 virtual로 하여 런타임에 인스턴스 종류를 파악하게 하여 메서드 호출에서의 폴트를 피했던 것처럼, 캐스팅과 관련해서도 유사한 방법이 필요하다. 캐스팅 연산자에서는 dynamic_cast가 그런 역할을 하고 있어서, 상속관계에 있는 클래스의 포인터간에 안전한 타입변환을 해주고, 문제의 소지가 있는 형변환에 대해서는 null을 전달하여 오동작을 방지하게 해준다. dynamic_cast에 대해서는 다음 시간에..

아 그럼 여기서 또 질문이 나올 수 있는데. dynamic_cast로 안전하게 상속 클래스간 포인터변환을 할 수 있다면, static_cast가 굳이 상속관계 클래스 포인터간 캐스팅을 지원하는 이유가 뭐냐? 그냥 지원 안해버리면 되는데.... 맞는 말이다. 

이것은 virtual 함수의 특성과 비슷한 답변을 하면 될 것 같다. virtual 함수를 사용하게 되면, 실시간 타입검사(RTTI)가 들어가기 때문에 아무래도 속도상 손해가 있다. 결국 퍼포먼스가 무지막지하게 중요한 소프트웨어라서 RTTI를 빼버리고 컴파일하고 싶거나, 컴파일러가 특이해서 RTTI옵션을 아예 지원하지 않는다면 virtual 함수를 쓸 수 없듯이, dynamic_cast역시 RTTI를 쓸 수 없는 환경에서는 사용할 수가 없다. 이것은 dynamic_cast를 소개할 때 한 번 더 언급하게 될 것 같다. 아무튼 이런 상황에서 만능열쇠와도 같은 (=위험하기 짝이 없는) C스타일 캐스팅보다는 그나마 static_cast를 사용해주는게 차선책이 될 수 있음을 이해할 수 있을 것이다. 이쯤되면 왜 한놈은 스태틱이고 한놈은 다이내믹이라는 이름을 갖고 있는지도 충분히 이해가 될 듯. 

한 가지 재미있는 것은, static_cast의 경우 기본적으로 상속관계가 아닌 클래스끼리는 포인터변환을 할 수 없지만, 중간에 void* 로 한 번 변환을 해두면 자유롭게 캐스팅이 가능하다는 것. 

class CClass1
{
public:
int m_intTest;
};

class CClass2
{
public:
int m_intTest2;
int m_intAdditional;
};

void main()
{
CClass1 *pcl1; 
CClass2 *pcl2;
void *ptemp;

pcl1 = static_cast<CClass1*>(pcl2); // ERROR

ptemp = static_cast<void*>(pcl2); // OK
pcl1 = static_cast<CClass1*>(ptemp); // OK
}

나의 미천한 실력으로 정확한 이유는 알 수 없으나, 모든 클래스를 void를 상속한 걸로 간주하기 때문에 그런것이 아닐까 추측한다. 아니면, 파일 처리나 통신 등에서 serialize를 지원하게 하기 위해서일지도 모르고.. 또는, malloc등의 함수를 통해 고정된 타입으로 메모리주소를 넘겨받는 경우가 있어서 그런지도 모르겠다.  

암튼 이렇게까지 할거면 걍 C캐스팅 쓰는거나 무슨 차이냐 싶을 수도 있지만, 명시적으로 강제 캐스팅이 불가피하다는 것을 보여주기 위해선 나쁘지 않을것 같기도 하다. 

사실 여기서 소개한 것 외에도 몇 가지 특성이 더 있기는 한데.. 나중에 생각나면 추가해 보도록 하겠다.
다음 시간에는 dynamic_cast에 대해서 살펴볼 것이다. 

그리고 끝으로 언급해야 될 것이 한 가지 있다면.. C++에는 오늘 살펴본 static_cast처럼 C++특유의 네 가지 정도의 캐스팅 연산자가 있으며, 앞서 대강 살펴봤듯이 각각의 역할이 뚜렷이 구분되어 있다. 이것은 프로그래머에게 실수를 방지하도록 돕는 것과 동시에, 타인이 소스를 읽어봤을 때 프로그래머의 의중을 쉽게 파악할 수 있도록 하는 역할도 겸한다. 그런데 이런 상황에서 갑자기 중간에 C스타일의 캐스팅이 툭 튀어나온다면? (워3 용어로, 갑툭튀라고 할 수 있겠다) 그동안 쌓아놓은 무결성과 가독성이 와르르 무너지게 되는 것이다. 일반 타입 변환에 static_cast를 쓰라고까지는 못하겠지만(그러나 실제로 이렇게 프로그래밍하는 사람도 많음), 클래스 또는 포인터 변환에서만이라도 C++ 캐스팅 키워드를 반드시 사용해보면 어떨까 싶다. 그리고 이 캐스팅 기법들은 포인터뿐만 아니고 레퍼런스에도 동일한 방식으로 지원을 하고 있으니 레퍼런스 변환에도 적극적으로 사용해보도록 하자. 


: