2016년 3월 19일 토요일

[c++기초] 9. 타입 정보

33-1.RTTI

33-1-가.실시간 타입 정보

RTTI는 RunTime Type Information의 약자이며 번역하자면 실시간 타입 정보라는 뜻이다. 일반적으로 변수의 이름이나 구조체, 클래스의 타입은 컴파일러가 컴파일을 하는 동안에만 필요할 뿐이며 이진 파일로 번역되고 나면 이 정보들은 필요가 없다. 변수는 언제나 번지로만 참조될 뿐이지 예쁜 이름을 붙여 봐야 그 이름이 실행 파일에 남을 필요가 없고 구조체의 멤버들도 오프셋으로만 참조된다. 변수의 타입은 읽어들일 길이와 비트를 해석하는 정보로만 사용되며 기계어 수준에서는 길이와 비트해석 방법에 따라 생성되는 기계어 코드가 달라진다.
클래스도 마찬가지로 기계어로 바뀌면 구조체와 똑같되 다만 가상 함수가 있을 때 vtable을 가리키는 포인터를 하나 더 가진다는 정도만 다르다. 멤버 함수는 일반 함수와 동일하되 다만 첫 번째 인수가 this로 고정되어 있는 호출 규약을 사용하므로 이 함수를 호출할 때는 항상 호출 객체의 포인터가 같이 전달되도록 컴파일될 뿐이다.
기계(CPU)는 어차피 타입이라는 것을 인식하지 않으며 메모리에 있는 값을 지정한 길이만큼 읽고 쓰고 할 뿐이다. 그러나 상속된 클래스의 계층을 다루는 C++에서는 가끔 이런 타입에 대한 정보가 실행중에 필요한 경우가 흔하지는 않지만 가끔 있다. 어떤 경우에 타입에 대한 정보가 필요한지 예제를 만들어 보자. 이 장 전반에 걸쳐 사용할 클래스 구조이므로 잘 봐 두도록 하자. 예제 클래스가 눈에 익숙해야 이론 파악이 쉬워진다.

  : RTTI
#include <Turboc.h>

class Parent
{
public:
     virtual void PrintMe() { printf("I am Parent\n"); }
};

class Child : public Parent
{
private:
     int num;

public:
     Child(int anum=1234) : num(anum) { }
     virtual void PrintMe() { printf("I am Child\n"); }
     void PrintNum() { printf("Hello Child=%d\n",num); }
};

void func(Parent *p)
{
     p->PrintMe();
     ((Child *)p)->PrintNum();
}

void main()
{
     Parent p;
     Child c(5);

     func(&c);
     func(&p);
}

두 개의 클래스가 정의되어 있는데 Parent는 PrintMe라는 가상 함수만을 가진다. Parent로부터 파생된 Child는 정수형의 num 멤버 변수와 이 멤버를 초기화하는 생성자 그리고 PrintNum이라는 비가상 멤버 함수를 가지며 상속받은 PrintMe 가상 함수는 다른 문자열을 출력하도록 재정의했다. 두 개의 클래스로 구성된 아주 간단한 계층이다. func 함수는 Parent 또는 그 파생 객체의 포인터를 인수로 전달받아 PrintMe 가상 함수를 호출한다. 그리고 객체가 Child 타입일 때 이 객체의 PrintNum이라는 비가상 함수도 호출한다.
Parent 객체 뿐만 아니라 그 파생 객체도 전달받아야 하기 때문에 최상위 클래스인 Parent 타입의 포인터를 전달받을 수밖에 없다. PrintNum이라는 함수는 Child에만 있으므로 이 함수를 호출하려면 Parent *타입의 인수 p를 Child * 타입으로 강제 캐스팅해야 한다. main에서는 각 클래스의 객체 p와 c를 선언하되 c.num은 5로 초기화했다. 그리고 func 함수로 이 두 객체를 차례대로 전달해 보았다. 실행 결과는 다음과 같다.

I am Child
Hello Child=5
I am Parent
Hello Child=1245120

func(&c) 호출로 차일드의 번지를 전달할 때는 PrintMe나 PrintNum 두 호출이 모두 성공적이다. PrintMe는 가상 함수이므로 객체의 타입에 맞는 함수가 호출될 것이고 PrintNum은 비가상 함수지만 Child의 멤버 함수인 것은 분명하므로 p를 Child * 타입으로 캐스팅하면 호출할 수 있고 동작도 제대로 한다. PrintNum에서 참조하는 this->num이 존재한다.
반면 func(&p)로 p의 번지를 전달할 때는 그렇지 않다. 가상 함수인 PrintMe는 vtable에서 실제 번지를 찾으므로 제대로 동작하지만 비가상 함수인 PrintNum 호출은 엉뚱하게 동작한다. 왜냐하면 실인수 p가 가리키는 객체는 num이라는 멤버를 가지고 있지 않은데 이 객체를 강제로 Child *로 캐스팅했기 때문이다. 캐스팅을 했으므로 일단 컴파일은 되지만 이때 PrintNum이 읽는 num 멤버는 p객체에 존재하지 않는다. p의 타입인 Child 클래스의 num 멤버에 대한 오프셋 위치(this->num)를 무조건 읽는 것이며 이 번지에 제대로 된 값이 있을 리가 없으므로 엉뚱한 쓰레기값이 출력되는 것이다.
그렇다면 PrintNum을 가상 함수로 바꾸면 어떻게 될까? Child 클래스의 PrintNum 함수 앞에 키워드 virtual을 넣고 컴파일해 보자. 깔끔하게 잘 컴파일 되는 걸로 봐서 무난히 동작할 것 같지만 실제로 실행해 보면 즉사할 것이다. 왜냐하면 객체 p가 가리키는 vtable(곧 Parent 클래스의 vtable)에는 PrintNum이라는 함수의 번지가 없기 때문이다. 메모리 내부를 좀 들여다 보면 다음과 같다.
Parent 클래스의 vtable에는 자신의 가상 함수 PrintMe에 대한 정보만 들어 있고 Child 클래스의 vtable에는 PrintMe와 PrintNum의 번지가 들어 있다. 객체 p는 생성될 때 Parent 타입으로 생성되었으므로 p의 선두에는 Parent의 vtable을 가리키는 vptr이 있을 것이다. 물론 이 vtable에는 PrintNum이라는 함수의 번지가 들어 있지 않다. 그런데 func 함수에서는 p를 Child *로 캐스팅했으므로 컴파일러는 이 번지에 PrintNum이 있을 것으로 판단하고 아무 에러를 내지 않는다.
그러나 실행해 보면 p가 가리키는 vtable의 두 번째 항목에는 함수의 번지가 아닌 쓰레기값이 들어 있고 이 번지로 점프해 버리면 다음부터 프로그램이 어찌될지는 아무도 장담하지 못하는 것이다. 변수는 잘못 읽어봐야 쓰레기가 출력되고 말지만 함수는 잘못 호출되면 어디로 튈지 예측할 수 없기 때문에 다운될 확률이 아주 높다.
이 예제의 func 함수가 원래 의도했던 바는 Parent 파생 객체를 받아 이 객체로 어떤 일을 하되 객체가 Child 타입인 경우만 PrintNum이라는 함수를 추가로 호출하고 싶었던 것이다. 이 의도대로라면 func 함수는 다음과 같이 작성하는 것이 옳다.

void func(Parent *p)
{
     p->PrintMe();
     if (p가 Child 객체를 가리키면) {
          ((Child *)p)->PrintNum();
     }
}

파생 객체를 모두 처리할 수 있어야 하므로 어쩔 수 없이 Parent * 타입의 인수를 받되 이 객체가 Child 타입인 조건이 만족될 때만 p를 강제 캐스팅해서 PrintNum을 부르도록 하고 싶다. 이렇게 조건문을 작성하여 p가 Child 타입의 객체를 가리킬 때만 PrintNum을 호출하면 확실히 안전하다. 그런데 포인터만 가지고 있는 상황에서 이 포인터가 Parent 객체를 가리키는지 Child객체를 가리키는지를 어떻게 알 수 있겠는가? 위 코드에서 말로 된 가상의 코드를 어떻게 구체적인 실제 코드를 바꿀 수 있을까?
이것은 일반적으로 불가능하다. 포인터는 객체의 번지를 가리키고 있을 뿐이며 이 번지에는 객체의 실제 데이터가 들어 있을 뿐 내가 누구라는 정보는 없다. 왜 이런 코드가 불가능한지 좀 더 단순한 타입인 정수형으로 생각해 보자. 다음 예제 코드를 통해 직관적으로 이해할 수 있을 것이다.

void func(int *pi)
{
     // pi가 누구를 가리키는가?
}

int i,j;
unsigned k;
func(&i);
func(&j);
func((int *)&k);

func는 인수로 전달받은 pi가 i의 번지인지 j의 번지인지 또는 정수형 배열의 한 요소인지, 구조체에 속한 정수형 멤버의 번지인지를 알 수가 없다. 심지어 unsigned형의 변수 번지를 int *로 캐스팅해서 전달하면 이 번지 안에 있는 값이 부호가 있다고 믿어버리기도 한다. func가 아는 것은 정수형의 포인터를 전달받았다는 것과 이 포인터가 가리키는 곳에 정수형의 값이 있다는 것밖에 없으며 func는 *연산자를 사용하여 이 번지에 들어있는 값을 읽거나 변경할 수 있을 뿐이다.
그래서 실행중에 타입을 판별할 수 있는 기능이 필요해진 것이다. 사실 이 기능은 아주 오래전부터 필요성이 제기되어 왔지만 C++의 초기 스팩에는 포함되지 않았다. 그래서 컴파일러 제작사나 라이브러리 제작사들은 나름대로 실행중에 객체의 타입을 판별할 수 있는 기능을 작성해서 이미 사용했다. MFC의 경우 루트 클래스인 CObject에 객체의 타입을 저장하는 멤버와 타입을 판별하는 IsKindOf, IsDerivedFrom 함수들이 포함되어 있다.
그러나 이렇게 각자가 만든 방법은 당연히 서로 호환되지 않으며 호환성이 결여된 기능은 아무리 좋아도 마음놓고 사용할 수가 없다. 그래서 최신 C++ 표준은 언어 차원에서 이 기능을 포함시켰으며 이것이 바로 RTTI이다. 언어가 제공하는 표준이므로 호환성, 이식성이 당연히 확보된다. RTTI는 가상 함수가 있는 클래스에 대해서만 동작하는데 그 이유는 클래스의 타입 관련 정보가 vtable에 같이 저장되기 때문이다. 사실 가상 함수가 없는 클래스는 단독 클래스이거나 정적으로만 호출되므로 실행중에 타입 정보를 알아야 할 필요가 전혀 없다고 할 수 있다.
RTTI가 제대로 동작하려면 모든 클래스에 타입과 관련된 정보를 작성해야 하며 그러자면 필시 프로그램이 느려지고 용량이 커지는 반대 급부가 있다. 그래서 대부분의 컴파일러들은 RTTI 기능을 사용할 것인지 아닌지를 옵션으로 조정할 수 있도록 되어 있다. 비주얼 C++의 경우도 마찬가지인데 프로젝트 설정 대화상자의 C/C++/C++ Language 페이지에서 이 옵션을 변경한다. 디폴트로 이 옵션은 꺼져 있으므로 RTTI 기능을 사용하려면 프로젝트 설정을 조정할 필요가 있다.
컴파일러가 디폴트로 이 옵션을 선택하지 않았다는 것은 잘 사용되지 않는 기능이라는 뜻이다. RTTI가 아니더라도 이 문제를 풀 수 있는 여러 가지 대체 방법들이 있는데 예를 들어 가상 함수로도 문제를 풀 수 있고 대개의 경우 가상 함수가 훨씬 더 합리적인 선택이다. 위 예제의 경우 아무 것도 하지 않는 PrintNum을 Parent에도 작성해 놓고 가상으로 선언해 두면 일단은 문제가 해결된다. 그러나 기반 클래스를 건드려야 한다는 면에서 일반적인 해결책이라고 보기는 어렵다. 왜냐하면 기반 클래스는 함부로 수정할 수 있는 대상이 아닌 경우도 많기 때문이다.




33-1-나.typeid 연산자

RTTI 기능은 typeid 연산자로 사용한다. 이 연산자는 클래스의 이름이나 객체 또는 객체를 가리키는 포인터를 피연산자로 취하며 피연산자의 타입을 조사한다. typeid 연산자의 리턴 타입은 const type_info & 이며 type_info는 클래스의 타입에 대한 정보를 가지는 또 다른 클래스이다. 이 클래스는 컴파일러 제작사마다 조금씩 다르게 정의하는데 비주얼 C++의 경우 typeinfo 헤더 파일에 다음과 같이 선언되어 있다.

class type_info {
public:
     virtual ~type_info();
    int operator==(const type_info& rhs) const;
     int operator!=(const type_info& rhs) const;
     int before(const type_info& rhs) const;
     const char* name() const;
     const char* raw_name() const;
private:
    void *_m_data;
    char _m_d_name[1];
    type_info(const type_info& rhs);
    type_info& operator=(const type_info& rhs);
};

name 멤버 함수는 문자열로 된 타입의 이름을 조사하는데 클래스 이름이라고 보면 된다. raw_name은 장식명을 조사하는데 사람이 읽을 수 없는 문자열이므로 비교에만 사용할 수 있다. 이 외에도 type_info 객체가 같은지, 다른지를 조사하는 ==, != 연산자가 오버로딩되어 있는데 통상 == 연산자만 사용해도 원하는 타입인지 아닌지를 알 수 있다.
만약 typeid의 피연산자가 NULL 포인터로부터 읽은 값일 경우 bad_typeid 예외를 발생시킨다. 예를 들어 p가 NULL일 때 typeid(*p) 연산식은 예외로 처리된다. 연산자의 리턴값에는 특이값이 없으므로 예외를 발생할 수밖에 없다. 다음 예제로 이 연산자의 동작을 잘 관찰해 보자. typeid 연산자를 사용하려면 typeinfo 헤더 파일을 포함해야 하며 또한 프로젝트 설정 대화상자에서 RTTI 옵션도 선택해야 한다.

  : typeid
#include <Turboc.h>
#include <typeinfo>

class Parent
{
public:
     virtual void PrintMe() { printf("I am Parent\n"); }
};

class Child : public Parent
{
private:
     int num;

public:
     Child(int anum=1234) : num(anum) { }
     virtual void PrintMe() { printf("I am Child\n"); }
     void PrintNum() { printf("Hello Child=%d\n",num); }
};

void main()
{
     Parent P,*pP;
     Child C,*pC;
     pP=&P;
     pC=&C;

     printf("P=%s, pP=%s, *pP=%s\n",
          typeid(P).name(),typeid(pP).name(),typeid(*pP).name());
     printf("C=%s, pC=%s, *pC=%s\n",
          typeid(C).name(),typeid(pC).name(),typeid(*pC).name());

     pP=&C;
     printf("pP=%s, *pP=%s\n",
          typeid(pP).name(),typeid(*pP).name());
}

main에서 객체 P와 C 그리고 각 타입의 포인터 pP와 pC를 선언하여 포인터가 객체를 가리키도록 했다. 포인터와 객체의 참조 관계를 그림으로 그려 보면 다음과 같다.
이 상태에서 typeid로 객체, 포인터, 그리고 포인터가 가리키는 대상체의 타입을 조사하여 이름을 출력했다. 다음은 비주얼 C++의 실행 결과이다.

P=class Parent, pP=class Parent *, *pP=class Parent
C=class Child, pC=class Child *, *pC=class Child
pP=class Parent *, *pP=class Child

클래스의 이름을 표현하는 방식도 컴파일러마다 다를 수 있는데 비주얼 C++은 클래스일 때 class라는 단어와 클래스 이름으로 표시하고 포인터는 뒤에 *를 더 붙인다. 다음은 gcc의 실행 결과인데 비주얼 C++과는 다른 이름을 사용한다.

P=6Parent, pP=P6Parent, *pP=6Parent
C=5Child, pC=P5Child, *pC=5Child
pP=P6Parent, *pP=5Child

앞 두 줄의 결과는 아주 당연하고 상식적이다. Parent 타입의 P나 포인터 타입의 pP나 pP가 가리키는 대상체는 모두 Parent 클래스 타입이며 Child의 경우도 마찬가지다. 그러나 마지막 줄의 결과는 조금 다르다. Parent 타입의 포인터 pP는 파생 객체인 C의 번지를 대입받을 수 있는데 이 상태에서 pP의 타입과 *pP의 타입이 각각 다르게 나타난다. pP는 포인터 자체의 타입을 물은 것이므로 Parent *라는 결과가 나오고 pP가 현재 Child 타입을 가리키고 있으므로 *pP는 Child라는 결과가 나온다. 부모 타입의 포인터가 자식 객제를 가리키고 있음을 인식한다는 얘기이다.
각 객체와 클래스에 타입에 대한 정보가 없다면 pP가 정확하게 누구를 가리키는지를 아는 것은 불가능하며 RTTI에 의해 이런 정보가 유지되고 조사되는 것이다. 실행중에 포인터가 누구를 가리키는지를 정확하게 알 수 있게 되었으므로 앞의 예제에서 func 함수를 RTTI를 사용해 수정해 보자. 이 예제도 제대로 컴파일하려면 typeinfo 헤더를 포함하고 RTTI 옵션을 켜야 한다.

#include <typeinfo>
....
void func(Parent *p)
{
     p->PrintMe();
     if (strcmp(typeid(*p).name(),"class Child")==0) {
          ((Child *)p)->PrintNum();
     } else {
          puts("이 객체는 num을 가지고 있지 않습니다.");
     }
}

typeid 연산자로 p가 가리키는 대상체, 그러니까 func 함수로 전달된 실인수의 타입을 조사했다. name 함수를 호출하면 클래스 이름이 리턴되는데 이 문자열이 "class Child"라면 이때는 p를 안전하게 Child *로 캐스팅해서 PrintNum을 호출할 수 있다. 만약 Child 객체가 아니라면 에러 메시지를 출력하거나 아니면 PrintNum 호출을 생략할 수 있다. 실행 결과는 다음과 같다.

I am Child
Hello Child=5
I am Parent
이 객체는 num을 가지고 있지 않습니다.

Parent형 객체를 넘기면 이를 판별해 낸다는 것을 확인할 수 있다. type_info 클래스의 name 멤버로 클래스 이름을 조사하여 문자열을 비교했는데 이 방법은 직관적이기는 하지만 이식성에 불리하다. 왜냐하면 RTTI는 C++ 언어의 표준 기능이지만 클래스 이름을 표시하는 방법은 컴파일러마다 다를 수 있기 때문이다. gcc만 해도 벌써 이름을 붙이는 방법이 다르므로 위 코드를 그대로 컴파일하면 비주얼 C++에서는 제대로 실행되지만 gcc에서는 틀린 비교를 할 것이다.
그래서 이름을 직접 비교하는 것보다는 type_info 클래스의 == 연산자로 원하는 클래스의 타입 정보와 같은지 비교하는 것이 훨씬 더 좋다. == 연산자는 이름을 붙이는 방법과는 무관하게 두 대상의 타입이 같은지 다른지를 비교하므로 우리는 이 연산자의 비교 결과만을 사용하면 된다. 다음과 같이 수정해 보자.

void func(Parent *p)
{
     p->PrintMe();
     if (typeid(*p)==typeid(Child)) {
          ((Child *)p)->PrintNum();
     } else {
          puts("이 객체는 num을 가지고 있지 않습니다.");
     }
}


*p의 타입 정보와 Child 클래스의 타입 정보를 비교하여 같으면 p가 Child형의 객체를 가리키는 것으로 판단했다. typeid 연산자는 객체나 객체의 포인터뿐만 아니라 클래스 타입도 인수로 받을 수 있으므로 원하는 클래스 이름을 바로 쓸 수 있다. 이 외에 RTTI와 관련된 기능으로 다음 절의 dynamic_cast 연산자가 있다.


33-1-다.RTTI의 내부

RTTI는 C++ 표준 중 비교적 최근에 채택된 것이라 아직까지도 이를 지원하지 않는 컴파일러들이 많이 있으며 컴파일러뿐만 아니라 라이브러리들도 효율이나 기타 여러 가지 이유로 본격적으로 RTTI를 활용하지 않는 경우가 허다하다. 컴파일러가 클래스의 타입 정보를 어떤 식으로 저장하는지를 안다면 이 방식을 유사하게 흉내냄으로써 실행중에 타입 정보를 조사할 수 있는 클래스를 만들 수도 있다. 다음 예제를 통해 타입 정보를 직접 만들어 보자.

  : CStyleRTTI
#include <Turboc.h>

class Parent
{
protected:
     const char *Name;

public:
     virtual void PrintMe() { Name="Parent";printf("I am Parent\n"); }
     virtual const char *GetName() { return Name; }
};

class Child : public Parent
{
private:
     int num;

public:
     Child(int anum=1234) : num(anum) { Name="Child"; }
     virtual void PrintMe() { printf("I am Child\n"); }
     void PrintNum() { printf("Hello Child=%d\n",num); }
};

void func(Parent *p)
{
     p->PrintMe();
     if (strcmp(p->GetName(),"Child")==0) {
          ((Child *)p)->PrintNum();
     } else {
          puts("이 객체는 num을 가지고 있지 않습니다.");
     }
}

void main()
{
     Parent p;
     Child c(5);

     func(&c);
     func(&p);
}

최상위 클래스인 Parent는 타입의 이름을 저장할 상수 포인터 Name을 멤버로 가지며 생성자에서 자신의 타입 이름으로 초기화한다. 한 번 정해진 타입 정보는 읽기만 해야 하므로 상수로 선언했으며 이 이름을 조사하는 GetName 퍼블릭 함수도 정의했다. Parent로부터 파생되는 모든 클래스는 Name과 GetName을 상속받으며 각자의 생성자에서 자신의 클래스 이름으로 초기화한다. 이렇게 되면 생성되는 모든 객체는 자신의 타입 이름을 멤버로 가지며 GetName 함수로 이 정보를 읽어 동적 타입을 조사할 수 있다. 실행중에 타입 정보를 조사해야 하므로 GetName은 당연히 동적 타입을 참조하는 가상 함수여야 한다.
main에서는 Parent, Child 타입의 객체를 각각 선언한 후 이 객체의 포인터를 func 함수의 인수로 전달했다. func함수는 Parent * 타입으로 실인수를 전달받아 GetName 가상 함수로 동적 타입을 판별하는데 타입 정보가 문자열이므로 문자열로 비교한다. Name이 단순한 포인터가 아니라 타입 정보를 표현하는 좀 더 큰 객체라면 == 등의 연산자를 오버로딩할 수도 있고 부모 클래스에 대한 정보나 특정 클래스로부터 파생되었는지 조사하는 기능도 넣을 수 있다. MFC 라이브러리가 이런 식으로 RTTI를 직접 구현해서 사용하는데 왜냐하면 MFC는 RTTI가 표준으로 채택되기 전에 만들어졌기 때문이다. 실행 결과는 다음과 같다.

I am Child
Hello Child=5
I am Parent
이 객체는 num을 가지고 있지 않습니다.

Child 객체만 num과 PrintNum을 가지는데 func 함수는 실인수의 타입을 잘 판별하여 Child가 아닌 객체에 대해서는 에러 처리를 정확하게 한다. 타입 정보가 없다면 형식 인수의 정적 타입만으로는 이런 에러 처리를 할 수 없을 것이다.
이 예제에서 보다시피 RTTI는 그리 어려운 개념이 아니다. 그러나 이 예제가 작성하는 타입 정보는 컴파일러가 직접 생성하는 type_info 클래스보다 효율적이지 못하며 빠르지도 않다. type_info는 vtable을 통해 각 클래스마다 하나씩 생성되는데 비해 이 예제의 타입 정보는 객체마다 하나씩 생성되기 때문에 용량상의 낭비가 심한 편이다. 정적 멤버를 사용하면 클래스마다 하나씩의 타입 정보를 생성할 수 있지만 정적 멤버는 상속되지 않기 때문에 각 파생 클래스마다 고유의 멤버를 따로따로 만들어야 하는 번거로움이 있다. 아무렴 직접 만든 코드가 컴파일러가 만든 것보다 효율이 좋을 수 있겠는가?

33-2.C++의 캐스트 연산자

33-2-가.C의 캐스트 연산자

C의 캐스트 연산자는 변수의 타입을 마음대로 바꿀 수 있다는 면에서 무척 편리하고 유연한 코드 작성을 도와 준다. 가급적이면 타입을 맞추어 쓰고 캐스트 연산자를 피하는 것이 좋지만 void *의 경우처럼 반드시 캐스트 연산자가 있어야만 하는 경우도 있다. 임의의 타입을 전달해야 하는 qsort같은 함수는 void *가 아니면 정렬을 할 수 없으며 그렇더라도 이 함수를 호출하는 쪽에서 타입을 정확하게 알고 있으므로 별 문제가 없다. 그러나 너무 관대해서 사용자의 요구대로 무조건 타입을 바꾼다는 점에 있어서 부작용이 많은데 다음 예제를 보자.

  : ccast1
#include <Turboc.h>

void main()
{
     char *str="korea";
     int *pi;

     pi=(int *)str;
     printf("%d %x\n",*pi,*pi);
}

문자형 포인터가 가리키는 번지를 int *로 캐스트한 후 이 번지의 내용을 읽어 보았다. 포인터의 타입은 *연산자로 대상체를 읽을 때 얼마만큼 읽어서 어떻게 해석할 것인가를 지정하는데 이 정보가 바뀌면 바뀐 타입대로 읽어 버린다. pi가 가리키는 번지에는 문자열이 들어 있지만 이 값을 강제로 정수 형태로 읽어내는 것이다. 실행 결과는 다음과 같다.

1701998443 65726f6b

10진수로 출력한 결과는 도대체 무슨 의미인지를 짐작하기 어렵다. 그나마 16진수로 출력한 결과는 앞쪽부터 차례대로 erok 4바이트의 문자 코드라는 것을 어렴풋이 짐작할 수 있다. pi가 가리키는 번지로부터 4바이트를 읽되 리틀 엔디안은 뒤쪽 번지에 높은 값이 있으므로 4문자가 거꾸로 읽혀져 정수형이 되는 것이다.
이렇게 읽은 정수값과 str이 가리키는 번지에 들어 있는 "korea"라는 문자열과는 별다른 논리적인 연관성을 찾기 힘들다. "korea"와 17억이라는 숫자는 도대체 아무런 연관이 없는 것이다. 문자열은 문자열로 읽을 때만 의미가 있으며 정수로 읽어서는 이 값의 실용성을 찾기 어렵다. C의 캐스트 연산자는 이런 의미없는 타입 변환까지도 허용하여 실수를 했을 때 엉뚱한 결과가 나오도록 방치한다. 때로는 캐스트 연산자로 인한 강제 타입 변환으로 프로그램의 안정성이 위협받기도 한다.

  : ccast2
#include <Turboc.h>

void main()
{
     char *str="korea";
     int *pi;
     char *pc;

     pi=(int *)str;
     pc=(char *)*pi;
     printf("%s\n",pc);
}

str 번지를 int *로 캐스팅하여 pi에 대입하고 *pi에서 정수값을 읽어 내고 그 정수값을 다시 char *로 캐스팅해서 출력했다. 이렇게 하면 0x65726f6b번지의 내용을 문자열로 해석해서 읽을 것이다. 다행히 이 번지가 읽을 수 있는 메모리라면 쓰레기 문자열이라도 나오겠지만 그렇지 않다면 프로그램은 당장 다운되어 버린다. 허가되지 않은 메모리 영역을 마음대로 읽었기 때문이다. 위 코드는 문자열을 정수형으로 해석하고 정수형을 번지로 강제로 바꿔 그 번지를 읽는 연산을 하는데 이는 논리적으로 어떤 의미도 없고 말도 안되는 코드다.
문제는 이런 터무니없는 코드도 냉큼 컴파일된다는 점인데 컴파일러는 개발자가 지시했으므로 아무 군말없이 연산자의 지시대로 타입을 바꿀 뿐이다. 설사 그것이 개발자의 황당한 실수이더라도 말이다. 이 실수가 지금처럼 당장 실행중 에러로 나타나면 그래도 다행이지만 어떤 경우는 별 이상없이 실행되는 경우도 있어 언제 터질지 모르는 시한 폭탄같은 프로그램이 만들어지기도 한다.
C언어의 캐스트 연산자는 확실히 너무 무책임하고 개발자에게 모든 것을 떠 넘긴다. 원하는대로 바꿔 줄테니 결과가 어찌 되든 개발자가 책임을 지라는 식이다. 그래서 C++에서는 좀 더 안전하고 변환 목적에 맞게 골라 쓸 수 있는 4개의 새로운 캐스트 연산자를 제공한다. 이 연산자들은 C의 캐스트 연산자에 비해 규칙이 다소 엄격해 실수를 줄일 뿐만 아니라 어떤 의도의 타입 변환인지를 좀 더 분명히 표시하는 장점이 있다.


33-2-나.static_cast

static_cast 연산자는 지정한 타입으로 변경하는데 무조건 변경하는 것이 아니라 논리적으로 변환 가능한 타입만 변환한다. 기본 문법은 다음과 같다.

static_cast<타입>(대상)

< > 괄호안에 원하는 타입을 적고 ( ) 괄호안에 캐스팅할 대상을 적는다. 즉 (대상) 변수를 <타입>형으로 강제로 바꾸는 동작을 한다. 나머지 C++ 캐스트 연산자도 기본 형식은 이와 동일하다. 간단한 예제를 만들어 보자.

  : static_cast
#include <Turboc.h>

void main()
{
     char *str="korea";
     int *pi;
     double d=123.456;
     int i;

     i=static_cast<int>(d);                  // 가능
     pi=static_cast<int *>(str);            // 에러
     pi=(int *)str;                          // 가능
}

실수형의 d를 정수형으로 캐스팅하거나 반대로 실수형 변수를 정수형으로 캐스팅하는 것은 허용된다. 또한 상호 호환되는 열거형과 정수형과의 변환, double과 float의 변환 등도 허용된다. 그러나 포인터의 타입을 다른 것으로 변환하는 것은 허용되지 않으며 컴파일 에러로 처리된다. 위험한 캐스트 연산을 컴파일 중에 알려 줌으로써 실수를 방지할 수 있다. 이에 비해 C의 캐스트 연산자는 너무 너무 친절해서 언제나 OK이고 그러다 보니 프로그램이 언제 KO당할지 모른다. 포인터끼리 타입을 변환할 때는 상속 관계에 있는 포인터끼리만 변환이 허용되며 상속 관계가 아닌 포인터끼리는 변환을 거부한다.

  : static_cast2
#include <Turboc.h>

class Parent { };
class Child : public Parent { };

void main()
{
     Parent P,*pP;
     Child C,*pC;
     int i=1;

     pP=static_cast<Parent *>(&C);         // 가능
     pC=static_cast<Child *>(&P);      // 가능하지만 위험
     pP=static_cast<Parent *>(&i);     // 에러
     pC=static_cast<Child *>(&i);       // 에러
}

Parent와 Child는 상속 관계에 있는 클래스이다. 먼저 제일 아래쪽의 변환을 보자. 정수형 포인터 상수 &i를 Parent * 타입으로 변환하거나 Child * 타입으로 변환하는 것은 금지된다. int는 Child, Parent와 상속 관계에 있지 않기 때문이다. 만약 이 변환을 허가하면 pP로 Parent의 멤버 함수를 호출할 수도 있을텐데 정수형 변수가 이런 멤버 함수를 가지지 않으므로 이상 동작할 것이다.
상속 관계에 있는 클래스 포인터끼리는 상호 타입 변환할 수 있다. 첫 번째 줄은 자식 객체의 번지를 부모형의 포인터로 업 캐스팅(UpCasting)한다. 상속 계층의 위쪽으로 이동하는 변환을 업 캐스팅이라고 한다. 사실 이 변환은 캐스트 연산자를 사용하지 않아도 항상 가능한 대입이며 언제나 안전하다. 왜냐하면 pP로 가리킬 수 있는 멤버 변수나 멤버 함수는 항상 C에 포함되어 있기 때문이다. 캐스트 연산자없이 pP=&C;라고 고쳐도 잘 컴파일된다.
두 번째 줄은 부모 객체의 번지를 자식 객체의 포인터로 다운 캐스팅(DownCasting)한다. 상속 계층의 아래쪽으로 이동하기 때문에 다운 캐스팅이라고 하는데 이는 캐스트 연산자의 도움 없이는 허가되지 않는다. 부모 객체가 자식 클래스의 모든 멤버를 가지고 있지 않으므로 이는 무척 위험한 변환이다. static_cast는 실행중에 타입 체크를 하지 않으므로 이 변환이 위험하다는 것까지는 모르므로 일단은 허용한다.
이 변환은 아주 위험해질 수 있는데 pC로 부모에게 없는 멤버 함수를 호출할 경우 어떻게 될지 예측할 수 없기 때문이다. 물론 PC로 상속받은 멤버만 참조한다면 안전하겠지만 포인터를 가진 이상 어떤 멤버를 참조할지 알 수 없다. 반면 다음에 알아볼 dynamic_cast 연산자는 RTTI 정보를 사용하여 위험한 변환을 막아 준다.

33-2-다.dynamic_cast

이 캐스트 연산자는 포인터끼리 또는 레퍼런스끼리 변환하는데 반드시 포인터는 포인터로 변환해야 하고 레퍼런스는 레퍼런스로 변환해야 한다. 포인터를 레퍼런스로 바꾸거나 레퍼런스를 포인터로 변환하는 것은 상식적으로 필요하지도 않고 가능하지도 않다. 포인터끼리 변환할 때도 반드시 상속 계층에 속한 클래스끼리만 변환할 수 있다. int *를 char *로 변환하거나 Parent *를 int *로 변환하는 것은 안된다.
부모 자식간을 변환할 때 업 캐스팅은 원래부터 허용되는 것이므로 이 캐스트 연산자가 있으나 없으나 당연히 가능하다. 문제는 부모 타입의 포인터를 자식 타입의 포인터로 다운 캐스팅할 때인데 이때는 무조건 변환을 허용하지 않고 안전하다고 판단될 때만 허용한다. 안전한 경우란 변환 대상 포인터가 부모 클래스형 포인터 타입이되 실제로 자식 객체를 가리키고 있을 때 자식 클래스형 포인터로 다운 캐스팅할 때이다. 말이 좀 복잡한데 실제로 가리키고 있는 객체의 타입대로 캐스팅했으므로 이 포인터로 임의의 멤버를 참조해도 항상 안전하다.
반대로 부모 클래스형 포인터가 부모 객체를 가리키고 있는 상황일 때 자식 클래스형으로의 다운 캐스팅은 안전하지 않은 변환이다. 왜냐하면 부모 객체를 다운 캐스팅해서 자식 객체를 가리키는 포인터에 대입한 후 이 포인터로 자식에게만 있는 멤버를 참조할 수도 있기 때문이다. dynamic_cast 연산자는 이럴 경우 캐스팅을 허용하지 않고 NULL을 리턴하여 위험한 변환을 허가하지 않는다. 구체적인 예를 들어 보자.

  : dynamic_cast
#include <Turboc.h>

class Parent
{
public:
     virtual void PrintMe() { printf("I am Parent\n"); }
};

class Child : public Parent
{
private:
     int num;

public:
     Child(int anum=1234) : num(anum) { }
     virtual void PrintMe() { printf("I am Child\n"); }
     void PrintNum() { printf("Hello Child=%d\n",num); }
};

void main()
{
     Parent P,*pP,*pP2;
     Child C,*pC,*pC2;
     pP=&P;
     pC=&C;

     pP2=dynamic_cast<Parent *>(pC);       // 업 캐스팅-항상 안전하다.
     pC2=dynamic_cast<Child *>(pP2);        // 다운 캐스팅-경우에 따라 다르다.
     printf("pC2 = %p\n",pC2);
     pC2=dynamic_cast<Child *>(pP);         // 캐스팅 불가능
     printf("pC2 = %p\n",pC2);
}

앞 절의 RTTI 예제에서 사용했던 클래스 계층을 그대로 사용하기로 한다. pP가 P객체를 가리키고 pC가 C객체를 가리키고 있는 상황이다. 이 상태에서 pC를 업 캐스팅하여 부모 포인터 타입으로 바꾸는 연산은 항상 안전한데 pP2로 부모에 속한 임의의 멤버 함수를 불러도 이 멤버는 pC가 가리키는 C 객체에 소속되어 있기 때문이다. 따라서 이 대입의 경우 캐스트 연산자를 쓸 필요도 없이 pP2=pC로 바로 대입해도 된다.
다운 캐스팅의 경우는 대상 변수가 실제로 어떤 객체를 가리키는가에 따라 가능할 수도 있고 그렇지 않을 수도 있다. pP2를 pC2로 다운 캐스팅하는 경우를 보자. 이때 메모리의 상황은 다음과 같을 것이다. P 객체를 pP가 가리키고 C 객체를 pC가 가리키는 상황에서 pP2가 PC를 업캐스팅했으므로 pP2도 C를 같이 가리키고 있다. 이 상태에서 pC2는 pP2가 가리키고 있는 객체의 번지를 대입받고 싶다고 하자.
pP2는 Parent * 타입이므로 바로 대입할 수는 없고 Child *로 다운 캐스팅해서 대입해야 한다. 이때 pP2가 가리키는 실제 대상은 C객체이므로 캐스팅하고자 하는 타입과 일치하며 캐스팅은 성공하여 pC2가 C객체의 번지를 가리킬 수 있을 것이다. Child 타입의 객체를 Child *타입의 포인터가 가리키고 있으니 당연히 안전하다.
그러나 두 번째 경우는 다르다. pP가 가리키고 있는 객체를 pC2에 대입하려고 한다. 이때도 타입이 일치하지 않으므로 다운 캐스팅이 필요하다. pP가 가리키는 실제 대상은 Child 객체가 아니라 Parent 객체이므로 이때는 다운 캐스팅을 허가할 수 없다. 만약 허가한다면 pC2 포인터로 PrintNum 함수를 부를 경우 제대로 된 값을 출력하지 못할 것이며 이는 앞의 예제에서도 확인해 본 바 있다. 이렇게 안전하지 않을 경우 dynamic_cast 연산자는 NULL을 리턴하여 잘못된 캐스팅임을 알린다. 실행 결과는 다음과 같다.

pC2 = 0012FF6C
pC2 = 00000000

안전한 객체의 번지에 대해서는 제대로 다운 캐스팅을 하고 그렇지 않을 경우에는 캐스팅을 거부한다. static_cast 연산자와 dynamic_cast 연산자는 상속 관계에 있는 클래스들을 캐스팅한다는 점에 있어서 기능상 동일하다. 그러나 다운 캐스팅을 할 때 static_cast는 무조건 변환을 허가하지만 dynamic_cast는 실행중에 타입을 점검하여 안전한 캐스팅만 허가한다는 점이 다르다.
이 연산자가 변환 가능성을 판단하기 위해서는 실행중에 객체의 실제 타입을 판별할 수 있어야 한다. 그래서 이 연산자를 사용하려면 RTTI 옵션이 켜져 있어야 하며 변환 대상 타입들끼리는 상속 관계에 있어야 하고 최소한 하나 이상의 가상 함수를 가져야 한다. 만약 가상 함수가 없는 클래스 계층이라면 부모 타입의 포인터에 자식 객체의 번지를 대입할 일이 없을 것이고 캐스팅도 불필요할 것이다.
dynamic_cast 연산자는 포인터가 가리키는 대상이 캐스팅하고자 하는 타입을 가리키고 있을 때만 변환을 허용하므로 이 연산자로 변환한 포인터는 안전하게 사용할 수 있다. 앞의 RTTI 예제에서 실행중 타입을 판별하기 위해 typeid 연산자를 사용했는데 이 연산자 대신 dynamic_cast 연산자를 사용할 수도 있다. 예제의 func 함수를 다음과 같이 수정해 보자.

void func(Parent *p)
{
     p->PrintMe();
     Child *c=dynamic_cast<Child *>(p);
     if (c) {
          c->PrintNum();
     } else {
          puts("이 객체는 num을 가지고 있지 않습니다.");
     }
}

인수로 전달된 p를 Child *로 캐스팅하되 p가 가리키는 객체가 Child 타입일 때만 제대로 변환되고 그렇지 않을 때는 NULL이 리턴된다. dynamic_cast가 이 변환을 무사히 했다면 p의 대상체가 Child 타입임을 확실히 알 수 있고 따라서 이 객체로부터 PrintNum을 불러도 안전하다. 이 연산자를 사용하면 실행중에 포인터의 타입 점검을 할 수 있을 뿐만 아니라 캐스팅까지 할 수 있으므로 typeid 연산자보다 훨씬 더 편리하다.
이 연산자는 주로 상속 관계에 있는 포인터를 캐스팅할 때 사용하는데 레퍼런스에 대해서도 캐스팅할 수 있다. 단 레퍼런스는 에러에 해당하는 NULL을 리턴할 수 없으므로 대신 bad_cast 예외를 던진다. 따라서 레퍼런스를 변환할 때는 반드시 캐스팅 코드를 try 블록에 작성하고 bad_cast 예외를 잡아서 처리해야 한다.
다중 상속 계층에서 업, 다운 캐스팅을 할 때는 모호한 상황이 종종 벌어지기 때문에 좀 더 복잡한 캐스팅 규칙이 적용되며 가상 기반 클래스가 있을 때도 특별한 규칙이 적용된다. 또한 다중 상속된 한 객체를 가리키는 부모 포인터를 또 다른 부모 포인터 타입으로 변환하는 교차 캐스팅(cross cast)도 가능하다. 이런 규칙에 대해 관심있으면 따로 연구해 보되 어차피 다중 상속이 권장되지 않는 문법이므로 애써 배울 가치는 없다고 하겠다.



33-2-라.const_cast

이 캐스트 연산자는 포인터의 상수성만 변경하고 싶을 때 사용한다. 상수 지시 포인터를 비상수 지시 포인터로 잠시 바꾸고 싶을 때 const_cast 연산자를 쓴다. 반대의 경우도 물론 이 연산자를 사용할 수 있겠지만 비상수 지시 포인터는 상수 지시 포인터로 항상 변환 가능하므로 캐스트 연산자를 쓸 필요가 없다. 그냥 대입만 하면 된다.
이 연산자는 포인터의 const 속성을 넣거나 빼거나 할 수 있으며 잘 사용되지는 않지만 비슷한 성격의 지정자인 volatile 속성과 __unaligned 속성에 대해서도 변경할 수 있다. 이 캐스트 연산자 외의 다른 캐스트 연산자는 포인터의 상수성을 변경할 수 없다. 물론 C의 캐스트 연산자로는 마음대로 할 수 있지만 말이다. 다음 예를 보자.

  : const_cast
#include <Turboc.h>

void main()
{
     char str[]="string";
     const char *c1=str;
     char *c2;

     c2=const_cast<char *>(c1);
     c2[0]='a';
     printf("%s\n",c2);
}

상수 지시 포인터 c1은 비상수 지시 포인터 str을 별다른 제약없이 대입받을 수 있다. 이렇게 대입받은 포인터를 다른 비상수 지시 포인터 c2에 대입하고자 할 때는 c2=c1으로 바로 대입할 수 없다. 두 포인터의 상수성이 다르며 c1이 가리키는 읽기 전용 값을 c2로 부주의하게 바꿔 버릴 위험이 있기 때문이다. 그러나 이 경우 c1이 가리키는 대상(최초 대입받은 str)이 변경 가능한 대상이라는 것을 확실히 알고 있으므로 c1의 상수성만 잠시 무시하면 대입 가능하다. 이때 const_cast 연산자로 c1을 char *로 캐스팅할 수 있다.
만약 str이 char *로 선언되어 있다면 이때 str은 실행 파일의 일부분을 가리키고 있으므로 변경할 수 없다. 이 경우 포인터의 상수성을 함부로 변경하면 위험해진다. 이 연산자는 변수의 상수성만 변경할 수 있을 뿐이며 그 외의 타입 변환은 허용하지 않는다. 포인터의 대상체 타입을 바꾼다거나 기본 타입을 다른 타입으로 바꾸는 것도 허용되지 않는다. 그래서 다음 코드는 모두 에러로 처리된다.

int *pi=const_cast<int *>(c1);
d=const_cast<double>(i);

정수를 실수형 타입으로 변환하는 것은 상승 변환이므로 당연히 가능하지만 const_cast는 이것조차도 허용하지 않는다. 그냥 d=i; 라고 대입하면 묵시적 상승 변환에 의해 대입 가능한데도 말이다. 이처럼 const_cast는 오로지 포인터의 상수성만을 변경할 수 있다. 그래서 상수성을 변경할 때 이 캐스트 연산자를 사용하면 다른 엉뚱한 변환을 피할 수 있어 더 안전하며 코드를 읽는 사람도 어떤 의도로 이 캐스트 연산자를 사용했는지 쉽게 파악할 수 있다.
캐스트 연산자의 기능을 특정한 변환으로만 제한해 두면 무분별한 사용으로 인한 사고를 예방할 수 있는데 전통적인 C 캐스트 연산자를 사용한 다음 코드를 보자.

const char *c1;
char *c2;
c2=(char *)c1;

c1의 상수성을 잠시 없애 c2에 대입하기 위해 (char *) 캐스트 연산자를 사용했다. 이 상태에서 어떤 이유로 c1을 const double *로 변경했다고 하자. 변수의 타입을 바꾸어야 하는 경우는 개발중에 종종 있는 일인데 c1이 가리키는 대상이 char에서 double로 바뀐 것이다. 그러면 애초에 상수성을 없애기 위해 (char *) 연산자를 사용했는데 의미가 완전히 바뀌어 버려 타입을 변경하라는 명령이 되어 버린다. 하지만 컴파일러는 여전히 아무런 지적없이 만사 OK이다.
c1이 const double *로 바뀌었다면 c2로 당연히 double *로 바뀌어야 하는데 컴파일러가 아무런 불평이 없으므로 개발자가 이를 알지 못하고 넘어갈 수 있는 것이다. 설사 개발자가 문제가 있을 것이라는 추측을 할 수 있다 하더라도 소스를 일일이 다 뒤져 타입 변경에 대한 뒷처리를 하는 것은 무척 귀찮고 일부를 수정하지 않는 누락의 위험도 있다. 그러나 다음과 같이 캐스팅을 했다고 해 보자.

c2=const_cast<char *>(c1);

이렇게 하면 상수성 변경만을 원한다는 것을 분명히 표시하는 것이며 c1의 타입이 완전히 바뀌어 버리면 당장 에러로 처리된다. 따라서 개발자는 타입 변경에 대해 추가로 더 어떤 작업을 해야 하는지를 즉시 알게 되고 사고를 미연에 방지할 수 있다. C의 캐스트 연산자는 변환의 범위가 너무 넓은데 비해 C++의 캐스트 연산자는 기능이 제한적이다.


33-2-마.reinterpret_cast

이 캐스트 연산자는 임의의 포인터 타입끼리 변환을 허용하는 상당히 위험한 캐스트 연산자이다. 심지어 정수형과 포인터간의 변환도 허용한다. 정수형값을 포인터 타입으로 바꾸어 절대 번지를 가리키도록 한다거나 할 때 이 연산자를 사용한다.

int *pi;
char *pc;
pi=reinterpret_cast<int *>(12345678);
pc=reinterpret_cast<char *>(pi);

12345678이라는 정수값을 정수형 포인터로 바꾸어 pi에 대입할 수 있고 이 값을 다시 문자형 포인터로 바꾸어 pc에 대입할 수 있다. 상속 관계에 있지 않은 포인터끼리도 변환 가능하다. 대입을 허가하기는 하지만 이렇게 대입한 후 pi, pc 포인터를 사용해서 발생하는 문제는 전적으로 개발자가 책임을 져야 한다. 일종의 강제 변환이므로 안전하지 않고 이식성도 없다.
이 연산자는 포인터 타입간의 변환이나 포인터와 수치형 데이터의 변환에만 사용하며 기본 타입들끼리의 변환에는 사용할 수 없다. 예를 들어 정수형을 실수형으로 바꾸거나 실수형을 정수형으로 바꾸는 것은 허락되지 않는다. 이럴 때는 static_cast 연산자를 사용해야 한다. 이상으로 C++의 캐스트 연산자 4가지를 연구해 봤는데 가능한 변환 타입에 대해 정리해 보면 다음과 같다.

캐스트 연산자
변환 형태
static_cast
상속 관계의 클래스 포인터  레퍼런스기본 타입타입 체크 안함
dynamic_cast
상속 관계의 클래스 포인터  레퍼런스타입 체크. RTTI 기능 필요
const_cast
const, volatile 등의 속성 변경
reinterpret_cast
포인터끼리포인터와 수치형간의 변환

연산자별로 가능한 연산이 있고 그렇지 않은 연산이 있으므로 목적에 맞게 골라서 사용해야 하며 부주의한 캐스팅을 조금이라도 방지하는 효과가 있다. 컴파일러는 캐스트 연산자의 목적에 맞게 제대로 캐스팅을 했는지 컴파일 중에 미리 에러를 발견할 수 있을 것이다. 그리고 모양이 아주 특이하기 때문에 캐스트 연산자인지를 금방 알아볼 수 있다는 점도 또 다른 이점이기도 하다.
변수의 타입을 변경하는 캐스트 연산은 어떤 경우라도 항상 주의해서 사용해야 한다. 아무 타입이나 마음대로 바꿀 수 있는 것도 아니고 바꾼 후의 효과에 대해서는 개발자가 책임을 져야 한다. 예를 들어 정수형과 구조체는 어떤 캐스트 연산자를 사용해도 상호 변환할 수 없다. 심지어 C의 캐스트 연산자도 이런 캐스팅은 허용하지 않는다. 어느모로 보나 정수와 구조체는 호환되지 않는 타입이며 변환할 필요성도 거의 없다. C++의 캐스트 연산자도 정도가 다르기는 하지만 위험하기는 역시 마찬가지이다.

33-3.멤버 포인터 연산자

33-3-가.멤버 포인터 변수

멤버 포인터 변수란 특정 클래스(구조체도 물론 포함된다.)에 속한 멤버만을 가리키는 포인터이다. 일반 포인터가 메모리상의 임의 지점을 가리킬 수 있는데 비해 객체 내의 한 지점만을 가리킨다는 점에서 독특하다. 선언 형식은 다음과 같다.

타입 클래스::*이름;

포인터 변수이므로 당연히 대상체의 타입이 필요하다. 그리고 특정 클래스 소속의 변수만을 가리킬 수 있으므로 어떤 클래스의 멤버들을 가리킬 것인지도 밝혀야 하며 클래스 소속 뒤에 포인터임을 나타내는 구두점 *와 변수의 이름을 적는다. 선언 형식이 다소 생소하므로 간단한 예제를 보고 사용예를 익혀 보자.

  : MemberPointer
#include <Turboc.h>

class MyClass
{
public:
     int i,j;
     double d;
};

void main()
{
     MyClass C;
     int MyClass::*pi;
     double MyClass::*pd;
     int num;

     pi=&MyClass::i;
     pi=&MyClass::j;
     pd=&MyClass::d;
     //pd=&MyClass::i;
     //pi=&MyClass::d;
     //pi=&num;
}

MyClass에는 정수형 멤버 변수 i, j와 실수형 멤버 변수 d가 포함되어 있다. main에서 멤버 포인터 변수 pi와 pd를 선언하는데 pi는 MyClass에 속한 정수형 변수를 가리키도록 선언했으며 pd는 MyClass에 속한 실수형 변수를 가리키도록 선언했다. pi가 가리킬 수 있는 변수는 반드시 MyClass에 속한 멤버여야 하며 또한 정수형이어야 한다. 따라서 pi는 MyClass::i또는 MyClass::j의 번지를 대입받을 수 있으며 타입이 다르거나 소속이 다른 변수는 가리킬 수 없다. MyClass::d는 MyClass 소속이기는 하지만 정수형이 아니므로 pi에 그 번지를 대입할 수 없으며 main의 지역변수 num은 정수형이기는 하지만 MyClass 소속이 아니므로 역시 pi에 대입할 수 없다.
같은 원리로 pd는 MyClass에 속한 실수형 변수의 번지만 가리킬 수 있으므로 오로지 d의 번지만 가리킬 수 있다. pd에 i나 j의 번지를 대입하거나 클래스 외부의 실수형 변수의 번지를 대입하면 컴파일 에러로 처리된다. 당연한 얘기겠지만 pi, pd는 MyClass 외부에 있는 변수이므로 이 변수가 클래스 내부의 변수를 가리키려면 대상체 멤버는 public으로 선언되어야 한다. 위 예제에서 i나 j를 private로 선언하면 컴파일 에러이다.
멤버 포인터 변수를 초기화할 때는 어떤 클래스에 속한 어떤 변수의 번지를 가리킬 것인지 &Class::Member 식으로 대입한다. 이 대입식은 특정 변수의 번지를 가리키도록 하는 것이 아니라 클래스의 어떤 멤버를 가리킬 것인가만 초기화하는 것이므로 이 상태에서 멤버 포인터 변수에 대입되는 번지가 결정되는 것은 아니다. 다만 가리키는 멤버가 클래스의 어디쯤에 있는지 위치에 대한 정보만을 가질 뿐이다. 클래스 전체를 하나의 작은 주소 공간으로 보고 클래스내의 멤버 위치를 기억하는 것이다. 멤버 포인터 변수로 객체의 실제 멤버를 액세스할 때는 멤버 포인터 연산자라는 특수한 연산자가 필요하다.

Obj.*mp
pObj->*mp

.* 연산자는 좌변의 객체에서 멤버 포인터 변수 mp가 가리키는 멤버를 읽는다. Obj가 상수 객체가 아니고 mp가 상수가 아닌 데이터 멤버를 가리킨다면 Obj.*mp 자체는 좌변값이므로 이 식을 좌변에 놓아 멤버 값을 변경하는 것도 물론 가능하다. ->*연산자는 좌변의 포인터가 가리키는 객체에서 mp가 가리키는 멤버를 읽는다. 두 연산자는 첫 번째 피 연산자가 객체인가 객체를 가리키는 포인터인가만 다를 뿐이며 기능적으로 거의 동일하다.
멤버 포인터 변수가 실제로 어떻게 초기화되고 .* 연산자가 객체의 멤버를 어떻게 읽을 것인가는 컴파일러에 따라 구현 방식이 다를 것이다. 주로 클래스내의 멤버 위치인 오프셋을 기억해 두었다가 .*연산자가 적용될 때 객체의 오프셋을 대상체 타입만큼 읽는 방법을 쓴다. 이 두 연산자를 사용하여 객체의 멤버를 액세스하는 간단한 예제를 만들어 보자.

  : MemberPointOp
#include <Turboc.h>

class Position
{
public:
     int x,y;
     char ch;

     Position() {x=0;y=0;ch='A';}
     void OutPosition() {
          gotoxy(x, y);putch(ch);
     }
};

void main()
{
     Position Here;
     Position *pPos=&Here;
     int Position::*pi;

     pi=&Position::x;
     Here.*pi=30;
     pi=&Position::y;
     pPos->*pi=5;
     Here.OutPosition();
}

Position 클래스는 화면상의 위치와 이 위치에 출력할 문자에 대한 정보를 가지는 간단한 클래스이다. main에서는 Position 객체 Here와 Here를 가리키는 포인터 변수 pPos를 선언해 두었다. 그리고 Position 클래스에 속한 정수형 멤버의 번지를 가리킬 수 있는 멤버 포인터 변수 pi를 선언하고 pi로부터 x와 y의 값을 간접적으로 액세스한다. pi가 Position::x를 가리키도록 초기화한 후 .* 연산자로 Here객체의 x를 30으로 변경했다. 그리고 pi가 Position::y를 가리키도록 한 후 ->*연산자로 pPos 포인터가 가리키는 객체의 y를 5로 변경해 보았다.
pi가 Position클래스의 정수형 멤버를 가리키도록 선언했으므로 Position의 정수형 멤버를 액세스할 수 있는 것은 당연하다. 일반 포인터는 메모리 내의 임의 위치에 있는 지정한 타입의 변수를 가리킬 수 있지만 멤버 포인터는 지정한 타입의 변수를 가리킬 수 있되 그 범위가 클래스 내로만 국한된다는 점이 다르다. 멤버 포인터 변수는 멤버에 대한 위치를 가리키므로 *pi식은 이 포인터가 가리키는 멤버의 역할을 대신하는 셈이다. 일반 포인터의 경우와 멤버 포인터의 경우 똑같은 원리가 적용된다.

int *pi=&i;
*pi=30;

pi가 i의 번지를 가리키고 있을 때 *pi는 곧 i와 같은 표현식이며 *pi에 30을 대입하면 i값이 변경된다. *pi=30 대입문은 i=30 대입문과 같다. 멤버 포인터의 경우도 마찬가지이다.

int Position::*pi=&Position::x;
Here.*pi=30;

pi가 Position의 x를 가리키고 있을 때 Here.*pi는 Here.x와 같은 표현식이며 Here.*pi에 값을 대입하면 Here의 정수형 멤버 x가 변경된다. 가리킬 수 있는 범위가 객체 내부의 멤버일 뿐이지 일반 포인터에 비해 대상체를 간접적으로 액세스한다는 면에서 동일하다.
일반 포인터와 마찬가지로 멤버 포인터도 타입이 맞는 대상체만 가리킬 수 있다. ch는 Position 소속이기는 하지만 정수형이 아니므로 pi로 값을 변경할 수 없다. 결국 이 예제의 .* 연산문과 ->* 연산문은 둘 다 Here의 x, y 멤버값을 간접적으로 변경하는 것이다. 멤버값을 변경한 후 OutPosition으로 그 결과를 출력해 보았는데 (30,5)에 'A'가 출력될 것이다.

33-3-나.멤버 포인터 연산자의 활용

그렇다면 멤버 포인터 변수로 간접적으로 멤버를 액세스하는 것은 무슨 의미가 있을까? 지금까지 경험해 봐서 알겠지만 무엇인가 한 단계를 더 거치면 중간 단계에서 많은 조작이 가능해진다. 가령 예를 들어 클래스 X가 수 많은 정수형 멤버 변수들을 가지고 있는데 이 중 어떤 멤버가 조작 대상인지를 기억하는 포인터 변수를 선언하고 조작 대상을 미리 선정해 놓을 수 있다.
pi가 액세스할 대상을 가리키고 있으므로 .*pi로 선정된 대상을 빠르게 액세스할 수 있을 것이다. 그러나 사실 이런 활용예는 다소 억지스러운데 왜냐하면 정수형 변수가 그토록 많이 필요하다면 당연히 배열을 구성할 것이고 배열상의 한 요소를 가리키는데는 첨자라는 더 편리한 방법이 있기 때문이다. 사실 멤버 변수를 이렇게 간접적으로 액세스하는 것은 큰 의미가 없으며 주로 멤버 함수를 간접적으로 호출할 수 있다는 면에서 실용성이 있다. 멤버 포인터 연산자의 실용적인 활용예를 든다면 조건에 따라 적절한 멤버 함수를 선택하여 호출하는 기법을 들 수 있을 것이다. 다음 예제를 보자.

  : MemPtr1
#include <Turboc.h>

class Test
{
public:
     void Op1(int a,int b) { printf("%d\n",a+b); }
     void Op2(int a,int b) { printf("%d\n",a-b); }
     void Op3(int a,int b) { printf("%d\n",a*b); }
};

void main()
{
     int ch;
     Test t;
     int a=3,b=4;

     printf("연산 방법을 선택하시오. 0=더하기, 1=빼기, 2=곱하기 : ");
     scanf("%d",&ch);

     switch (ch) {
     case 0:
          t.Op1(a,b);
          break;
     case 1:
          t.Op2(a,b);
          break;
     case 2:
          t.Op3(a,b);
          break;
     }
}

이 예제의 Test 클래스는 동일한 원형을 가지는 세 개의 멤버 함수를 가지고 있으며 각 멤버 함수는 덧셈, 뺄셈, 곱셈의 상이한 동작을 한다. main에서는 사용자에게 어떤 연산을 할 것인지를 입력받고 사용자가 원하는 동작을 하기 위해 switch 문으로 다중 분기를 하고 있다. 예제에서는 비슷한 동작을 하는 함수가 세 개밖에 없으므로 단순한 switch문으로 분기를 했는데 만약 이런 함수가 수십 개나 있다거나 한 번 결정된 함수를 여러 번 반복적으로 호출해야 한다면 무척 번거로울 것이다.
이런 경우, 그러니까 호출할 함수가 아주 많은데 그 중 하나를 미리 결정해 놓고 싶을 때 쓸 수 있는 문법적 장치는 함수 포인터이다. 함수 포인터에 미리 어떤 함수를 부를 것인가를 결정해 놓고 필요할 때 함수 포인터로부터 원하는 함수를 빠르게 호출할 수 있다. 예제의 경우 함수 포인터 배열을 만들어 놓고 입력된 첨자로부터 어떤 함수를 호출할 것인가를 결정하면 된다. 이 정도는 C언어를 공부한 사람이면 누구나 아는 문법이다. 그렇다면 다음과 같이 함수 포인터 변수를 선언하고 멤버 함수를 대입할 수 있을까?

void (*pf)(int,int);
pf=t.Op1;

Op1, Op2, Op3은 모두 두 개의 정수형 인수를 취하고 리턴값이 없는 void (*)(int,int) 함수 타입이므로 이런 함수를 가리키는 포인터 변수 pf를 선언하고 pf가 함수를 가리키도록 하면 될 것 같기도 하다. 그러나 이 코드를 컴파일해 보면 에러로 처리되는데 왜냐하면 클래스에 속한 멤버 함수는 일반 함수와는 달리 호출하는 방법이 다르며 따라서 이런 함수를 가리키는 포인터를 선언하는 문법도 달라야 하기 때문이다. 멤버 함수는 반드시 호출하는 객체에 대한 정보를 가지는 this라는 암시적인 인수를 전달받아야 한다. 그래서 클래스의 멤버를 가리키는 멤버 포인터 변수와 멤버 포인터 연산자가 필요한 것이다. 다음과 같이 예제를 수정해 보자.

  : MemPtr2
#include <Turboc.h>

class Test;
typedef void (Test::*fpop)(int,int);
class Test
{
public:
     void Op1(int a,int b) { printf("%d\n",a+b); }
     void Op2(int a,int b) { printf("%d\n",a-b); }
     void Op3(int a,int b) { printf("%d\n",a*b); }
};

void main()
{
     int ch;
     Test t;
     int a=3,b=4;
     static fpop arop[3]={&Test::Op1,&Test::Op2,&Test::Op3};

     printf("연산 방법을 선택하시오. 0=더하기, 1=빼기, 2=곱하기 : ");
     scanf("%d",&ch);

     if (ch >= 0 && ch <= 2) {
          (t.*arop[ch])(3,4);
     }
}

Test 클래스 선언문 앞에 fpop라는 타입을 정의하는데 fpop는 int형 둘을 인수로 취하고 리턴값이 없는 Test 클래스의 멤버 함수에 대한 포인터 타입이다. main에서는 이런 타입의 arop 배열을 선언하고 각 요소를 Op1, Op2, Op3로 초기화해 두었다. arop 배열에 멤버 함수에 대한 목록이 작성되어 있으므로 첨자만 선택하면 이 배열을 통해 원하는 멤버 함수를 바로 호출할 수 있다.
main에서 사용자에게 입력받은 연산 방법을 ch 변수에 대입하고 이 변수를 arop의 첨자로 사용하여 t객체의 멤버 함수를 호출한다. 단, 이 경우 배열 첨자를 넘지 않는지 반드시 점검해야 안전한 호출을 할 수 있다. ch에 입력되는 첨자로 호출할 함수를 결정하므로 이런 함수가 아무리 많더라도 arop 배열만 늘리면 되고 한 번 결정된 값으로 여러 번 호출할 수도 있다. switch같은 단순한 방법에 비해서는 훨씬 더 우월한 방법이라고 할 수 있다.
함수 포인터의 장점 중 하나는 함수를 다른 함수의 인수로 전달할 수 있다는 점이다. 예를 들어 qsort 함수는 정렬은 직접 하되 자료의 대소 비교는 사용자가 제공한 함수를 호출하여 결정한다. 멤버 포인터 변수와 연산자를 사용하면 마찬가지로 멤버 함수를 다른 멤버 함수의 인수로 전달할 수도 있다. 다음 예제는 이런 방법을 보여 준다.

  : MemFuncArgument
#include <Turboc.h>

class Test;
typedef void (Test::*fpop)(int,int);
class Test
{
public:
     void DoCalc(fpop fp,int a,int b) {
          puts("지금부터 연산 결과를 발표하겠습니다.");
          printf("%d와 %d의 연산 결과 : ",a,b);
          (this->*fp)(a,b);
          puts("이상입니다.");
     }
     void Op1(int a,int b) { printf("%d\n",a+b); }
     void Op2(int a,int b) { printf("%d\n",a-b); }
     void Op3(int a,int b) { printf("%d\n",a*b); }
};

void main()
{
     int ch;
     Test t;
     int a=3,b=4;
     static fpop arop[3]={&Test::Op1,&Test::Op2,&Test::Op3};

     printf("연산 방법을 선택하시오. 0=더하기, 1=빼기, 2=곱하기 : ");
     scanf("%d",&ch);

     t.DoCalc(arop[ch],3,4);
}

예제의 DoCalc 함수는 Op1, Op2, Op3를 대신 호출하는 역할을 하는데 연산뿐만 아니라 앞뒤로 친절한 몇 가지 메시지를 같이 출력한다. main에서는 연산 방법과 피연산자만 DoCalc로 전달하면 된다. Op* 함수는 꼭 필요한 연산만 하고 메시지를 출력하는 일은 DoCalc가 대신하는데 만약 Op*가 메시지를 일일이 출력한다면 중복되는 코드가 아주 많아질 것이다.
이해를 위해 간단한 예를 들다보니 실용적이지 못한 것 같은데 좀 더 실용적인 예를 들어 본다면 트리의 순회 문제를 들 수 있다. 트리를 순회하면서 어떤 동작을 하는 함수를 작성하고 싶은데 이 함수로 트리를 순회하는 방법을 전달하고 싶다고 하자. 이럴 경우 전위, 중위, 후위, 층별 순회 함수를 각각 만들어 두고 이 멤버 함수의 번지를 인수로 전달하는 방법을 사용할 수 있다. 만약 멤버 함수를 인수로 전달할 수 없다면 매 순회 방법별로 개별 함수를 일일이 만들어야 할 것이다.


33-3-다.멤버 포인터의 특징

멤버 포인터 변수는 클래스 내의 변수를 가리킨다는 면에서 일반 포인터와는 다른 면이 많은데 여기서는 멤버 포인터 변수의 몇 가지 특징에 대해 정리해 보자. 지극히 상식적인 규칙들인데 우선 상속 관계에 있는 클래스의 멤버를 가리킬 때의 특징을 설명하는 다음 예제를 보자.

  : MemPtrInherit
#include <Turboc.h>

class A
{
public:
     int a;
};

class B : public A
{
public:
     int b;
};

void main()
{
     int A::*pa;
     int B::*pb;

     pa=&A::a;
     pb=&B::b;
     pb=&A::a;
     pb=&B::a;
     //pa=&B::b;
}

A에 정수형 멤버 변수 a가 선언되어 있고 A로부터 파생된 B에 또 다른 정수형 멤버 변수 b가 선언되어 있다. 결국 B는 상속 받은 a와 자신이 선언한 b 두 개의 정수형 멤버를 가지는 것이다. main에서 A클래스의 정수형 멤버를 가리키는 pa와 B클래스의 정수형 멤버를 가리키는 pb를 선언하고 두 변수에 각 클래스의 멤버를 대입해 보았다.
pa가 &A::a를 대입받거나 pb가 &B::b를 대입받는 것은 타입이 완전히 일치하므로 너무 너무 당연한 일이다. pb가 &A::a를 대입받는 식은 타입이 다르기는 하지만 아주 정상적인 대입식이다. 왜냐하면 B는 A로부터 파생되었으므로 A에 속한 모든 멤버를 가지고 있으며 따라서 pb가 부모 클래스인 A의 멤버를 가리킬 수 있는 것이다. a는 A의 멤버이지만 상속에 의해 B에도 존재하므로 pb=&B::a라고 써도 동일하다. 그러나 pa=&B::b는 에러로 처리되는데 A클래스는 자식의 멤버를 가지지 않기 때문이다. 요약하자면 멤버 포인터 변수는 타입만 일치한다면 기반 클래스로부터 상속받은 멤버도 가리킬 수 있다. 단, 다중 상속에 의해 한 멤버가 두 번 상속되었을 경우는 실제 어떤 멤버를 가리켜야 할 지 모호하므로 에러로 처리될 것이다.
멤버 포인터 변수는 정적 멤버 변수를 가리킬 수 없으며 레퍼런스 멤버를 가리킬 수도 없다. 다음 예제를 보자.

  : MemPtrToStatic
#include <Turboc.h>

class A
{
public:
     int &ri;
     static int a;
     A(int &i) : ri(i) { }
};
int A::a=4;

void main()
{
     int A::*pa;
     int *pi;

     pa=&A::ri;
     pa=&A::a;
     pi=&A::a;
}

A에 정수형 정적변수 a가 선언되어 있는데 이 변수의 번지를 int A::*pa에 대입하면 에러로 처리된다. 왜냐하면 정적 멤버 변수는 클래스 소속일 뿐 객체와는 상관없는 별개의 변수이며 객체 내에서 위치를 가지지도 않으므로 멤버 포인터 변수에 위치를 대입할 수 없기 때문이다. A::a는 A 클래스에 소속되어 있을 뿐이지 단순한 정수형 변수이므로 이 변수의 위치를 가리키려면 일반 포인터 변수를 사용해야 한다. 레퍼런스 멤버의 번지도 대입할 수 없는데 C++은 멤버에 대한 레퍼런스라는 개념은 제공하지 않는다.
멤버 포인터의 연산 규칙도 일반 포인터에 비해 조금 다르다. 가장 쉬운 차이점을 예로 든다면 증감 연산자를 쓸 수 없다는 정도인데 이는 함수 포인터를 증감할 수 없는 것과 같다. 같은 타입의 멤버들이 클래스내에 무리지어 있을 리가 없으므로 멤버 포인터를 증가시킨다고 해서 다음 멤버로 이동할 리도 없고 그렇게 해야 할 실용적인 이유도 없는 셈이다. 멤버 포인터 변수는 대입에 의해 특정 멤버를 가리키기만 할 뿐이다.








댓글 없음:

댓글 쓰기

[Effective C++] 항목 30 : 인라인 함수는 미주알고주알 따져서 이해해 두자.

인라인 함수를 사용하면 컴파일러가 함수 본문에 대해 문맥별 최적화를 걸기가 용이해집니다. 인라인 함수의 아이디어는  함수 호출문을 그 함수의 본문으로 바꿔치기하자는 것  남발했다가는 코드의 크기가 커질 게 뻔하다. 인라인 함수로 부풀려진 ...