대메뉴 바로가기 본문 바로가기

데이터 기술 자료

데이터 기술 자료 상세보기
제목 C++ 프로그래밍 : 클래스 포인터 타입 변환
등록일 조회수 5642
첨부파일  

C++ 프로그래밍

클래스 포인터 타입 변환



C++ 클래스의 타입 변환은 중요하고 빈번하고 사용된다. 특히 클래스의 타입 변환은 주로 포인터 타입 변환 형태로 이루어진다는 점에서 클래스 포인터 타입 변환을 자세히 살펴보는 것은 C++ 프로그래밍을 좀 더 깊이 이해하는 데 도움이 될 수 있다.



지난 호에는 ‘클래스 사이의 타입 변환’에 대한 C++의 원리와 원칙을 살펴봤다. 주로 다뤘던 주제는 클래스의 값 타입과 참조 타입의 변환에 대한 것이었는데, 변환의 근본적인 성질을 보여주고 이해하는데 많은 도움을 주기 때문이다. 실제적인 프로그래밍에서 클래스의 타입 변환은 주로 포인터를 대상으로 행해진다. ‘포인터’가 나와서 머리가 아파올 수 있는 독자도 있겠으나, 크게 걱정을 할 필요는 없다. 지난달의 클래스 참조 타입 변환 내용을 충분히 기억하고 이해한다면 포인터의 변환도 굉장히 쉽게 받아들일 수 있기 때문이다. 혹시라도 이 글을 처음 읽거나 기억이 나지 않는 독자라면 본지 9월호를 먼저 읽어보시길 부탁드린다.



1. 포인터 사이의 타입 변환

C/C++을 배운 많은 개발자들이 상식처럼 잘못 알고 있는 것이 있다. 즉, 알고 있다고 생각하지만 사실 제대로 아는 것이 아닌 것 말이다. 바로 포인터이다. 포인터란 무엇인가? 보통 대부분의 개발자들이 포인터를 메모리 주소를 담는 4바이트(32비트 x86) 혹은 8바이트(64비트 x64)의 객체(변수) 정도로만 생각한다. 사실 이 정도만 알아도 큰 무리는 없다. 많은 프로그래밍에서 이 정도 지식이 불충분한 상황을 맞이하는 경우는 거의 없기 때문이다. 그러나 이런 불완전한 지식을 기본으로 삼아서 프로그래밍을 하게 될 경우 위험한 상황이 도래할 수 있다.

포인터가 단지 메모리 주소를 담는 늘 같은 크기의 변수 정도라면 모든 포인터 사이의 변환은 큰 어려움 없이 가능할 것이라고 생각할 수 있다. 주소 타입에서 주소 타입으로 변환하는데 사실 무슨 변환이 필요할까 의문이 들 수도 있다. 따라서 클래스를 포함해서 모든 타입의 포인터 타입은 상호 변환이 무제한으로 이루어져도 큰 문제가 없을 것 같다. 그러나 이런 무제한적인 변환 허용은 프로그래밍에 있어서 상당한 자유를 주기도 하지만, 큰 문제를 발생시킬 수 있는 위험도 함께 가지고 있다.

이 세상 모든 C/C++ 개발자들이 문제의 소지가 없는 정확한 포인터만을 사용할 것이라고 기대하는 것은 모든 운전자들이 교통 신호를 항상 잘 지킬 것이라고 기대하는 것과 같기 때문이다. 먼저 컴파일러가 제공하는 클래스 포인터 타입 변환의 위험성을 막는 안전장치부터 살펴보자!



<리스트 1> 상관없는 클래스 사이의 포인터 변환 class CTestA { }; class CTestB { }; void main() { CTestA* pTA = new CTestA; CTestB* pTB1 = pTA; // ① Error CTestB* pTB2 = (CTestB*)pTA; // ② OK CTestB* pTB3 = static_cast< CTestB*>(pTA);// ③ Error CTestB* pTB4 = reinterpret_cast< CTestB*>(pTA); // ④ OK delete pTA; }



<리스트 1>은 클래스 포인터 타입 CTestA*에서 CTestB*로 타입 변환 결과를 보여준다. 지난 호에 설명한 클래스 참조 타입 변환과 결과가 그대로 일치함을 알 수 있다. 왜냐하면 포인터 타입 변환이 곧 참조 타입 변환이기 때문이다. 즉, 포인터 타입 변환을 하게 되면 기존 객체가 차지하는 메모리 영역을 적절히 조정해 주소를 반환하는 것이다. 기존에 객체가 차지하던 메모리 영역의 주소를 그대로 반환하기 때문에 ①과 같이 전혀 상관없는 클래스 포인터 타입 변환은 허용되지 않는다. 비록 CTestA와 CTestB가 클래스 정의만을 따져보면 그 구조가 완전히 일치함에도 불구하고 컴파일러는 변환을 거부하는 것이다. 그러나 ②와 같이 사용자가 강제로 타입 변환 연산을 수행할 경우 컴파일러는 사용자를 존중하여 에러를 발생시키지 않는다. ③의 static_cast는 오직 상속 관계에 있는 클래스 사이 타입 변환만을 허용하기 때문에 에러가 발생한다.

④의 reinterpret_cast는 이전 호에 소개한 것처럼 참조 타입과 포인터 타입 변환만을 허용한다. reinterpret_cast의 특징이 있는데 거의 대부분의 타입 변환을 수행한다는 점이다. 따라서 일종의 강제 타입 변환으로 서로 관련이 없는 클래스 간 포인터 타입 변환도 과감히 허용한다.



<리스트 2> 상속 관계 클래스 포인터 변환 class CParent { public: int m_Parent; }; class CChild : public CParent { public: int m_Child; }; void main() { CChild* pC = new CChild; CParent* pP1 = pC; // ① OK CParent* pP2 = (CParent*)pC; // ② OK CParent* pP3 = static_cast< CParent*>(pC); // ③ OK CParent* pP4 = reinterpret_cast< CParent*>(pC); // ④ OK CParent* pP = new CParent; CChild* pC1 = pP; // ⑤ Error CChild* pC2 = (CChild*)pP; // ⑥ OK CChild* pC3 = static_cast< CChild*>(pP); // ⑦ OK CChild* pC4 = reinterpret_cast< CChild*>(pP); // ⑧ OK delete pC; delete pP; }



<리스트 2>는 상속 관계에 있는 클래스 CParent와 CChild의 상호간 포인터 타입 변환 결과를 보여준다. 당연히 자식 클래스에서 부모 클래스로의 변환은 허용된다. 주의해서 보아야 할 부분은 바로 ⑤, ⑦이다. ⑤~⑧은 부모 클래스에서 자식 클래스로 포인터 타입 변환을 하는데, 이중에서 암묵적 변환인 ⑤의 경우는 컴파일러에 의해서 허용되지 않는다. 변환이 허용된다면 변환된 포인터를 가지고 잘못된 메모리 접근을 할 수도 있기 때문이다. 그러나 ⑥~⑧은 명시적으로 타입 변환 연산자를 사용하기 때문에 변환이 허용된다. 특히 ⑦의 경우 static_cast인데, 오직 상속 관계의 클래스 사이에만 변환이 허용된다는 것을 기억해야 한다. <리스트 1>에서는 서로 관련 없는 클래스 사이의 포인터 타입 변환으로 인해 컴파일 에러가 발생했지만 <리스트 2>에서는 아무 문제가 없음에 주의해야 한다.

간단히 정리하면 상속 관계에 있는 클래스 포인터 타입들은 서로 자유롭게 타입 변환이 되는 것을 기본으로 하지만 부모 클래스 포인터에서 자식 클래스 포인터로 암시적 타입 변환은 허용되지 않는다는 사실만 잘 기억하면 될 것 같다. 기억한다기보다는 이해하도록 노력해야 한다.



2. 클래스 포인터 타입 변환과 메모리 주소

앞에서 포인터를 단순히 메모리 주소를 담는 변수 정도로만 해석해서는 안 된다고 강조했는데, 지금부터 왜 그러한지를 살펴보겠다.



<리스트 3> 클래스 포인터 변환 CParent* pP = new CParent; CChild* pC = (CChild*)pP;



<리스트 3>을 살펴보자! pP, pC 모두 포인터이므로 4바이트다(편의상 32비트 x86 환경이라고 가정하자!). 또한 포인터는 메모리 주소를 담는 것이기 때문에 부호 없는 정수형(unsigned int)처럼 비트 배열이 해석된다. 즉, 포인터 사이의 변환 과정에서 비트 배열을 바꿀 필요가 없을 것 같다. 그래서 의외로 많은 개발자들이 클래스 포인터 타입 변환이 일어날 때 포인터 값 자체(주소)가 바뀌지 않을 것이라고 잘못 알고 있다. 사실 잘못 알고 있을 수밖에 없기도 하다. 대부분의 클래스 포인터 타입 변환이 일어날 때 포인터 값(주소)이 변하지 않기 때문이다. 포인터 값(주소)이 변경되는 경우는 몇몇 특수한 경우에 한정되는데, 주로 다중 상속이나 가상 상속이 사용될 때 발생한다. 물론 아직은 이해가 되지 않을 수 있겠지만 심지어 단일 상속일 때도 어떤 경우는 포인터 값(주소)이 변경될 수 있다. 이제 이런 점에 주목하면서 실제 예제를 살펴보자!



<리스트 4> 다중 상속 메모리 구조 class CParentA { public: CParentA() { m_Val = 1; } int m_Val; }; class CParentB { public: CParentB() { m_Val = 2; } int m_Val; }; class CChild : public CParentA, public CParentB { public: CChild() { m_Val = 3; } int m_Val; };





<리스트 4>에서 클래스 CChild는 CParentA와 CParentB를 다중 상속한다. 다중 상속을 가장 잘 이해하는 방법은 바로 다중 상속 클래스의 메모리 구조를 정확하게 이해하는 것이다. <그림 1>은 CChild의 메모리 구조를 나타낸다. 그림에는 /Zp1이 있는데, 클래스의 멤버를 배치할 때 Padding이 발생하지 않도록 1바이트 단위로 조밀하게 꽉 채우라는 의미다. 설명의 편의를 위해 /Zp1을 사용하지만, 실제 컴파일러의 기본 설정은 /Zp4 or /Zp8인 경우가 많다. 이제 CChild*와 CParentB* 사이 타입 변환을 살펴볼 것이다. 포인터 값(주소)이 어떻게 변하는지 잘 살펴보자!



<리스트 5> 다중 상속 클래스 포인터 변환 1 void main() { CChild* pC = new CChild; // ⓐ CParentB* pB1 = pC; // ① CParentB* pB2 = (CParentB*)pC; // ② CParentB* pB3 = static_cast< CParentB*>(pC); // ③ CParentB* pB4 = reinterpret_cast< CParentB*>(pC); // ④ cout < < "pC:" < < pC < < endl; cout < < "pB1:" < < pB1 < < endl; cout < < "pB2:" < < pB2 < < endl; cout < < "pB3:" < < pB3 < < endl; cout < < "pB4:" < < pB4 < < endl; cout < < "pB1:" < < pB1->m_Val < < endl; cout < < "pB2:" < < pB2->m_Val < < endl; cout < < "pB3:" < < pB3->m_Val < < endl; cout < < "pB4:" < < pB4->m_Val < < endl; delete pC; }



<리스트 5>에서 먼저 살펴볼 것은 CChild*에서 CParentB*로 타입을 변환하는 것이다. ①~④에서 CChild* 타입을 두 번째 부모 클래스인 CParentB* 타입으로 변환한다. 동시에 변환된 포인터를 통해서 CParentB의 멤버인 m_Val의 값도 확인해보자! CParentA, CParentB, CChild 모두 m_Val이라는 같은 이름의 멤버 변수를 가지고 있다. 그러므로 CParentB의 m_Val의 값을 얻기 위해서는 범위 연산자를 이용해 CChild* pC에 대해서 pC->CParentB::m_Val처럼 사용할 수도 있으나 먼저 CChild*를 CParentB*로 타입 변환을 하는 것이 더 일반적인 방법이다. 여기서 중요한 것은 CChild*가 CParentB*로 타입 변환을 할 때 포인터 값(주소)이 변한다는 사실이다. 실제로 코드가 실행된 결과를 살펴보자!

pC:005797F0 // ⓐ - CChild의 메모리 주소
pB1:005797F4 // ① - 타입 변환 후 4바이트 증가
pB2:005797F4 // ② - 타입 변환 후 4바이트 증가
pB3:005797F4 // ③ - 타입 변환 후 4바이트 증가
pB4:005797F0 // ④ - 포인터 값이 변하지 않았다.
pB1:2
pB2:2
pB3:2
pB4:1 // ⑤ 이 경우만 잘못된 값 1이 출력된다.

ⓐ는 CChild* pC의 값이다. 이것을 각각 4가지 방식으로 CParentB*로 변환한 포인터 값이 각각 ①~④이다. 눈여겨볼 것은 ①~③의 값은 pC의 값보다 4만큼 크다는 사실이다. <그림 1>에서 볼 수 있듯이 CParentB의 주소는 CChild의 주소를 기준으로 오프셋 4만큼 떨어져서 시작된다. 여기서 확실히 알아야 할 점은 클래스 포인터 타입 변환으로 인해 포인터 값이 변할 수 있다는 것이 아니라 실제 객체의 메모리 시작 주소를 구하기 위해 클래스 포인터 타입 변환을 한다는 것이다. 즉, 주객이 전도된 것 같지만 실제 객체의 정확한 메모리 주소를 구하는 과정이 바로 클래스 포인터 타입 변환의 핵심인 것이다. 그렇다면 결과 ④는 왜 포인터 값이 변하지 않고 그대로 있는지 궁금할 것이다.

바로 그것이 reinterpret_cast의 핵심이다. reinterpret_cast의 목적은 클래스의 상속 관계에 상관없이 절대적으로 포인터 값을 유지하면서 타입 변환을 하는 것이다. 즉, 원래 그런 목적으로 만들어진 것이라는 의미다. 그래서 결과 ⑤에서 pB4로 접근한 m_Val이 2가 아닌 1로 잘못 나오게 되는 것이다. 따라서 reinterpret_cast는 정확한 용법을 모르는 상태로 함부로 사용하다가는 며칠간 버그로 인해 밤을 지새울 수도 있게 된다. 그렇다면 도대체 reinterpret_cast가 왜 존재하는 것일까? 거의 쓸 일이 없을 것 같지만 의외로 많은 부분에서 상당히 중요하게 사용된다. 대표적으로는 COM(Component Object Model)의 통합을 구현하는 과정에서 인터페이스를 뒤바꾸는데 reinterpret_cast가 사용된다. COM은 무엇이고 통합은 무엇인지 무척 궁금할 수도 있는데, 아쉽게도 지면이 절대적으로 부족해 여기서는 충분한 설명을 할 수 없음을 이해해주시기 바란다.



<리스트 6> 다중 상속 클래스 포인터 변환 2 void main() { CParentB* pPB = new CParentB; // ⓐ //CChild* pC1 = pPB; // ① Error CChild* pC2 = (CChild*)pPB; // ② OK CChild* pC3 = static_cast< CChild*>(pPB);// ③ OK CChild* pC4 = reinterpret_cast< CChild*>(pPB);// ④ OK cout < < "pPB:" < < pPB < < endl; cout < < "pC2:" < < pC2 < < endl; cout < < "pC3:" < < pC3 < < endl; cout < < "pC4:" < < pC4 < < endl; delete pPB; }



<리스트 6>에서는 반대로 CParentB*에서 CChild*로 타입 변환하는 것을 살펴보자! 이미 앞에서 설명했듯이 ①은 암시적 변환으로 컴파일러에 의해서 변환 허용이 되지 않는다. 따라서 결과 확인을 위해 문장 자체를 주석처리했다. 실행 결과를 살펴보자!

pPB:003197F0 // ⓐ - CParentB의 메모리 주소
pC2:003197EC // ② - 타입 변환 후 4바이트 감소
pC3:003197EC // ③ - 타입 변환 후 4바이트 감소
pC4:003197F0 // ④ - 이곳만 주소가 변하지 않았다.

<그림 1>에서 알 수 있듯이 CParentB의 주소는 CChild의 주소를 기준으로 오프셋 4만큼 떨어져서 시작된다. 따라서 CChild의 주소는 CParentB를 기준으로 4만큼 작다 그래서 포인터 타입 변환의 결과로 포인터 값(주소)이 4바이트만큼 감소하는 것이다. 물론 위에서도 충분히 얘기했듯이 reinterpret_cast의 경우 ④처럼 포인터 값(주소) 변화 없이 강제 타입 변환을 하게 된다.

간단히 요약을 하자면 클래스 포인터 타입 변환의 주목적은 변환되는 목적 클래스 객체의 실제 메모리 주소를 구하는 것이다. 즉, 타입 변환 과정에서 포인터 값(주소)이 변한다고 보는 것보다 정확한 객체의 주소를 구하기 위해 타입 변환을 하는 것이라고 이해하는 편이 더 합리적이라는 의미다.

이런 관점에서 약간의 상상력을 발휘해보자! 만일 실제 객체의 메모리 주소를 구할 수 없다면 클래스 포인터 타입 변환은 어떻게 될까? 답은 간단하다. 컴파일러가 허용하지 않을 것이다. 그런 경우가 과연 존재할 수 있는지 궁금할 것 같아서 실제 예제를 준비했다. 바로 가상 상속의 경우다.



3. 가상 상속 클래스 포인터 타입 변환

<리스트 7> 가상 상속 타입 변환 class CParent { public: int m_Parent; }; class CChild : virtual public CParent // ⓐ { public: int m_Child; }; void main() { CChild* pC = new CChild; CParent* pP1 = pC; // ① OK CParent* pP2 = (CParent*)pC; // ② OK CParent* pP3 = static_cast< CParent*>(pC); // ③ OK CParent* pP4 = reinterpret_cast< CParent*>(pC); // ④ OK CParent* pP = new CParent; CChild* pC1 = pP; // ⑤ Error CChild* pC2 = (CChild*)pP; // ⑥ Error CChild* pC3 = static_cast< CChild*>(pP); // ⑦ Error CChild* pC4 = reinterpret_cast< CChild*>(pP); // ⑧ OK delete pC; delete pP; }



<리스트 7>과 앞에서 제시한 <리스트 2>는 다른 점이 딱 한군데 있다. ⓐ에서 virtual 키워드를 이용해 가상 상속하고 있는 것이다. 일반 상속이 가상 상속으로 바뀌면서 포인터 타입 변환 허용 여부가 크게 달라지는 것을 확인할 수 있다. 역시 가상 상속은 골치를 썩인다는 것을 깨달을 수 있을 것이다. 신경 쓸 것이 너무나 많아지기 때문이다. 그래서 필자는 가능하면 가상 상속을 거의 사용하지 않는다. 눈여겨보아야 할 곳은 ⑥, ⑦처럼 가상 상속으로 바뀌면서 변환 허용이 되지 않는 경우다. 일반 상속일 경우 ⑥, ⑦은 변환 허용이 됐다. 이제부터 그 이유를 알아보기로 하자! 먼저 CChild의 메모리 구조를 보여주는 그림을 살펴보자!



<그림 2>는 CParent와 CChild의 가상 상속 메모리 구조를 보여준다. 어떻게 이런 구조가 만들어졌는지 궁금할 수 있는데, 가상 상속을 처리하는 몇 가지 규칙에 의해서 그림과 같은 메모리 구조도가 만들어진다(어떻게 만들어지는지 보다 이런 식으로 만들어진다는 것만 알아도 충분하다). CParent는 CChild를 기준으로 오프셋 8만큼 떨어져 있다. 이것은 CChild의 vbptr이 가리키는 오프셋 테이블의 두 번째 오프셋 값으로 확인할 수 있다(가상 상속의 경우 가상 기저 클래스의 위치 정보를 기록하기 위해 가상 기저 클래스 오프셋 테이블이 생성되며 해당 테이블을 가리키는 포인터 vbptr이 객체 메모리에 올려진다. ‘vbptr’은 컴파일러에서 특별한 이름으로 확인할 수 없는 영역이라 필자가 지은 이름이다).

이해하기 어려울 수 있겠으나 CChild*에서 CParent*로 포인터 타입이 변경될 때, 컴파일러는 vbptr을 통해서 상대 오프셋 8바이트를 확인할 수 있고, 그 값을 기준으로 포인터 값(주소)을 바꿀 수 있다. 결국 가상 상속의 경우 오프셋 테이블을 이용해 쉽게 자식 클래스 포인터에서 부모 클래스 포인터로 변환할 수 있다는 사실이다. 그러므로 ①~④의 경우 타입 변환은 아무 문제없이 허용될 수 있다.



그러나 반대의 경우 즉, ⑥, ⑦과 같이 CParent*에서 CChild*로의 타입 변환이 허용되지 않는 것은 쉽게 이해가 되지 않을 것이다. 필자도 꽤 당황하면서 오랫동안 생각의 늪에 빠져야만 했다. 그런데 변환이 허용되지 않는 이유는 사실 단순했다. CParent*에서 CChild*로 변환하기 위한 오프셋 계산을 할 수 없기 때문이다. 그 똑똑한 컴퓨터가 계산을 못한다는 것이 이해가 되지 않을지도 모르지만, 좀 더 정확히 얘기하면 오프셋이 무한대로 많을 수 있기 때문에 어느 한가지로 정할 수 없다는 것이 핵심이다.

현재 <그림 2>에서는 CChild와 CParent의 메모리 상대 위치가 8바이트 차이가 나도록 정해져있지만 그런 관계가 오직 단 하나만 존재하는 것은?의 CChild와 CParent의 구조도가 나올 수 있다. 가령 새로운 클래스 CTest가 개입해 CTest가 CChild를 또 다시 가상 상속을 하게 된다면, CTest의 메모리 구조도에서는 CChild와 CParent의 오프셋 차이가 다르게 된다. 적절한 예인지는 모르겠으나 비유를 하자면 다음과 같다. 청계천, 탄천, 중랑천 등 수많은 하천은 각각의 지리적 요건으로 인하여 자연스럽게 한강으로 모일 수 있다. 즉, 모든 하천의 물은 한강의 물로 변환된다. 그러나 한강의 물이 어떤 하천에서 왔는지를 구분할 수 없다. 왜냐하면 원천을 알 수 없기 때문이다. 가상 상속의 경우가 바로 그러하다.



정리

지난 호에 이어서 2회에 걸쳐서 클래스 포인터 타입 변환에 대해서 살펴보았다. 클래스 포인터 타입 변환에서 제일 중요한 것은 바로 포인터의 값(주소)이 변할 수 있다는 것이다. 그리고 주소가 변한다는 의미는 실제 객체의 메모리 주소를 구하기 위한 것임을 이해할 수 있었다. 클래스 포인터 타입 변환은 정말 많이 사용된다. 그만큼 주의를 기울여서 제대로 사용해야 한다. 이번 기고가 안전한 프로그래밍을 하는데 도움이 될 수 있길 바란다.



출처 : 마이크로소프트웨어 10월호

제공 : 데이터 전문가 지식포털 DBguide.net