본문 바로가기

프로그램언어/C++

[C++] 캐스트 연산자

1.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 정보를 사용하여 위험한 변환을 막아 준다.

 

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)도 가능하다. 이런 규칙에 대해 관심있으면 따로 연구해 보되 어차피 다중 상속이 권장되지 않는 문법이므로 애써 배울 가치는 없다고 하겠다.

 

3.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++의 캐스트 연산자는 기능이 제한적이다.

 

4.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++의 캐스트 연산자도 정도가 다르기는 하지만 위험하기는 역시 마찬가지이다.

 

'프로그램언어 > C++' 카테고리의 다른 글

make 강좌  (0) 2008.07.22
AutoTools  (0) 2008.07.22
리눅스용 뮤텍스(mutex) 사용 예제  (0) 2008.07.22
C++ 튜토리얼 소스  (0) 2008.07.22
STL Container  (0) 2008.07.22