[C++] 형 변환 type casting
자료형과 관련된 용어
Type expression = ActualType inst;
위 expression이라는 변수를 선언했을 때, expresssion을 표현식이라하고 expression을 선언하기 위해 사용한 타입을 표현식의 타입이라하고 실제 인수가 가리키는 타입을 실제 타입이라고 한다.
Implicit Conversion 암묵적 형변환
컴파일러가 자료를 재해석하여 자동적으로 자료형을 변환해주는 것을 의미한다. 변환될 타입의 범위가 실제 타입보다 작은경우 (double (8)-> float(4), unsigned long long -> long/int) narrow conversion 좁은 범위의 변환이 일어나고 컴파일러 경고가 나타난다.
int a = 1.0f; // float -> int : narrow conversion warning
int b = 1.0; // double -> int : narrow conversion warning
float y = 1; // int -> float ok
double z = 1.0f; // float -> double ok
int s = 'a'; // char -> int ok
Explicit Conversion 명시적 형변환
캐스팅 연산자를 이용하여 명시적으로 형변환을 나타낸다.
C 스타일의 캐스팅은 괄호(자료명)을 통해 형변환을 하고 C++에서는 static_cast/dynamic_cast/reinterpret_cast와 같은 casting operator를 사용한다.
int dot = (int) 322.1f; // C-style casting
class A{};
class B : public A
{};
A * pa = new B;
B* pb = static_cast<B*>(pa); // static_cast<type-id> 컴파일 타임에 형변환
B* pb2 = dynamic_cast<B*>(pa); // dynamic_cast<type-id> 실행 타임에 형변환
다형성 클래스의 캐스팅
지역(자동) 변수간 캐스팅
class A
{
public:
virtual void f()
{
printf("A class\n");
}
A()
{
printf("Constructor of A\n");
}
A(const A& rhs)
{
printf("Copy Constructor of A\n");
}
};
class B : public A
{
public:
virtual void f()
{
printf("B class");
}
};
class C : public B
{
public:
virtual void f()
{
printf("C class");
}
};
int main()
{
B b;
A a = b; // A(const A& rhs) 오버로딩
a.f();
b.f();
return 0;
}
B타입의 인스턴스 b를 선언했고 A 타입의 인스턴스 생성자에 b를 대입하였다. 지역 변수인 a는 A 타입의 클래스이지 B타입의 클래스가 아니다. 따라서 a.f()는 클래스 A의 f()를 호출한다.
두번째 줄의 실행은 A a = b; 는 B타입의 인스턴스 b를 A 타입으로 암묵적 변환 후 A의 복사생성자를 오버로딩한다.
다형성 클래스의 캐스팅에서 자신의 기반 클래스 (base class)로 형변환하는 것을 up-casting이라고 한다. 명시적 형변환도 가능하다.
A a1 = b; // 암묵적 형변환
A a2 = (A) b; // C 스타일의 명시적 형변환 ok
반대로 자신의 타입에서 파생 클래스 (derived class)로 형변환하는 것을 down-casting이라고 한다. C 프로그램에서 파생 클래스로 형변환 하는 것은 안전하지 않기 때문에 "일반적으로" 금지하고 있다. C++ 캐스팅 연산자에 따라 가능한 경우도 있지만 C++ 캐스팅 연산자를 쓰지 않고 일반적인 암묵적/명시적 형변환은 컴파일 에러를 일으킨다.
C c1 = (C)b; // 컴파일 에러 B클래스에서 C클래스로 형변환하려고 함
C c2 = b; // 컴파일 에러
포인터/레퍼런스 타입간 캐스팅
포인터와 레퍼런스 타입은 똑같은 형변환 기준을 적용하지만 일반 지역 변수보다 좀 더 복잡하다.
class A
{
public:
int m_a = 0;
public:
virtual void f()
{
printf("A class\n");
}
A()
{
printf("Constructor of A\n");
}
A(const A& rhs)
{
printf("Copy Constructor of A\n");
}
void f2()
{
printf("A func\n");
}
};
class B : public A
{
public:
int m_b = 0;
public:
virtual void f()
{
printf("B class\n");
}
void f2()
{
printf("B func\n");
}
};
class C : public B
{
public:
int m_c = 0;
public:
virtual void f()
{
printf("C class\n");
}
void f2()
{
printf("C func\n");
}
};
위 클래스를 기준으로 여러 예시를 들어보자.
void foo()
{
B b;
B* pb = new B;
B& ref_b = b;
// up-casting
A* pa = pb;
A* pa2 = (A*)pa;
A& ref_a = b;
A& ref_a2 = (A&)b;
}
일반적인 up-casting은 implicit conversion이기 때문에 컴파일 타임에 자동 형변환이 된다. 명시적으로 형변환 해도 문제없다. up-casting은 대체로 실제 할당된 메모리보다 "더 좁은 영역의 메모리만 접근 가능"하기 때문에 멤버 접근이 제한되는 것이외에는 큰 문제는 없다.
문제는 down-casting시 지역변수와 다르게 암묵적 형변환은 불가능하지만 명시적 형변환을 허용한다.
void foo2()
{
B b;
B* pb = new B;
B& ref_b = b;
C* pa = pb; // compile error
C* pc2 = (C*)pa; // unsafe, but ok
C& ref_c = b; // compile error
C& ref_c2 = (C&)b; // unsafe, but ok
}
또한 이러한 캐스팅 결과로 생성된 파생 클래스의 인스턴스는 파생 클래스만 가지고 있는 멤버 변수와 함수를 호출할 수 있게 된다. 이 의미는 캐스팅 결과로 할당된 포인터와 참조자를 사용해서 더 넓은 메모리 영역을 접근 할 수 있다는 뜻이고 마치 동적 할당을 한 결과처럼 실제 인스턴스의 메모리 크기가 커진다.
// new member to C class
pc2->m_c = 10;
ref_c2.m_c = 10;
int szCasted = sizeof(*pc2); // 32 bytes
int szOriginal = sizeof(*pb); // 24 bytes
포인터의 경우 힙 메모리에 할당된 객체가 더 넓은 메모리 영역을 사용하는 것으로 기준 주소보다 낮은 방향으로 할당되기 때문에 다른 객체가 할당되어 있다면 메모리 침범이 일어날 수 도 있고 운좋게도 안 일어날 수도 있다. 다음 예시를 보자.
- 힙 메모리 확장
B* pb_arr[2];
pb_arr[0] = new B;
pb_arr[1] = new B;
printf("%p\n", pb_arr[0]);
printf("%p\n", pb_arr[1]);
printf("%zd\n", sizeof(*pb_arr[0]));
// 캐스팅 이후
C* pc3 = (C*)pb_arr[0];
printf("%p\n", pb_arr[0]);
printf("%p\n", &pb_arr[0]->m_b);
printf("%p\n", &pc3->m_c);
printf("%p\n", pb_arr[1]);
실제 pb_arr[0]의 인스턴스를 C타입 포인터로 캐스팅한 pc3에 대입한다면 pc3는 더 넓은 메모리를 쓸 수 있다. 확인 결과에서도 보듯이 pc3->m_c는 pb_arr[0]->m_b에 8바이트 offset을 가지고 있다. 운영체제 마다 힙 메모리를 할당하는 기준은 다르겠지만 확장된 메모리에 다른 데이터가 있었거나 그 영역에 다른 객체를 새로 할당한다면 원치 않은 행동을 유발할 수 있다.
- 스택 메모리 확장
B pb_arr[2];
printf("%p\n", &pb_arr[0]);
printf("%p\n", &pb_arr[1]);
printf("%zd\n", sizeof(pb_arr[0]));
// 캐스팅 이후
C& pc3 = (C&)pb_arr[0];
printf("%p\n", &pb_arr[0]);
printf("%p\n", &pb_arr[0].m_a);
printf("%p\n", &pb_arr[0].m_b);
printf("%p\n", &pc3.m_c);
printf("%p\n", &pb_arr[1]);
일반 배열로 선언된 B 인스턴스는 pb_arr[0]와 pb_arr[1]의 메모리 offset이 정확히 B 클래스 메모리 크기와 일치한다. 따라서 pb_arr[0]와 pb_arr[1]은 정확히 24 바이트 오프셋을 가진다. 여기서 만약 pb_arr[0]를 C& 레퍼런스로 캐스팅 한다면 무슨 일이 일어날까
당연한 이야기이지만 0xB0 - 0x98 = 0x18 = 24 bytes인 것은 확인된다.
그 후 pb_arr[0]의 주소와 m_a, m_b 그리고 확장된 메모리 m_c의 주소를 비교해보자.
pc3에 C 타입 레퍼런스 캐스팅 이후 pc3는 이제 m_c 멤버를 접근할 수 있다. 그런데 m_c의 주소를 출력해보면 pb_arr[1]의 시작 주소와 같은 것을 볼 수 있다. 이는 캐스팅 이후 메모리 침범이 발생했지만 런타임 에러를 발생시킬 수도 있고 소리없이 작동할 수 도 있다. foo2()의 B 클래스의 "b 지역 변수"의 경우 C&로 캐스팅 되면서 함수 종료 시 다음 예외를 던진다.
Polymorphic 하지 않은 클래스간 캐스팅
서로 다른 클래스 계통을 가진 인스턴스간 캐스팅을 하면 무슨 일이 일어날까. 다음 D 클래스를 새로 정의해보자.
class D
{
public:
int m_d = 0;
public:
virtual void f()
{
printf("D class\n");
}
void f2()
{
printf("D func\n");
}
};
foo3()는 A 인스턴스를 생성해서 pd에 D*로 캐스팅하여 할당한다. 그리고 m_d에 10을 대입해보자.
void foo3()
{
A* pa = new A;
D* pd = (D*)pa;
pd->m_d = 10;
}
pd는 새로 메모리를 할당하지도 않으며 여전히 pa와 같은 메모리 공간을 사용한다. pd의 m_d에 값을 할당했지만 pd는 A의 인스턴스로 해석되고 m_a 값이 바뀌었다. 전혀 예측 불가능한 결과가 나타난다.
가상 함수가 있는 클래스에서 없는 클래스로 upcasting
up-casting은 컴파일러가 허용하는 암묵적 변환으로 대체적으로 문제를 일으키지 않는다. 하지만 다음의 경우를 보자.
class First {};
class Second : public First
{
public:
virtual ~Second() {};
};
class Third : public Second
{
public:
virtual ~Third() {};
};
void foo4()
{
Third* ps = new Third;
First* pf = ps;
delete pf;
}
먼저 파생 클래스 인스턴스를 동적 생성하고 이를 가리키는 ps 포인터를 기반 클래스의 포인터 pf에 할당한다. 위 예시는 up-casting 이므로 다음 암묵적 변환이 일어난다.
First* pf = (First*) ps;
문제는 First 클래스에는 가상 소멸자가 선언되어 있지 않다. 다형성이 있는 클래스의 소멸자는 반드시 가상함수로 정의해야하는데 클래스 설계부터 문제가 있는 코드인 것이다. 어쨋든 ps와 pf의 주소를 확인해보면 이상한 결과를 볼 수 있다.
ps의 주소가 8바이트 만큼 감소한 것이다. 또한 pf를 지우려고하면 유효하지 않은 주소에 대한 delete로 예외가 발생한다.
pf 는 가상 함수가 없기 때문에 가상함수 테이블 (void**)를 가지고 있지 않는다. 결국 pf가 가지고 있던 가상함수 테이블의 크기만큼 (8바이트) 떨어져서 주소가 할당되는 것으로 보인다. 이는 pf의 예측 불가능한 행동을 유발할 여지가 매우 크다. 실제 사용자 코드에서는 다음과 같이 많이 사용하므로 First와 같은 클래스 설계를 하지 않도록 주의하는 것이 필요하다. 또한 new 연산자 이후 대입되는 형이 up-casting이라면 다음과 같이 암묵적 변환이 숨겨져 있다는 사실을 알도록한다.
First *pf = new Third; // First *pf = (First*) new Third;
C 스타일의 캐스팅 문제
C 스타일 캐스팅은 위와 같은 많은 예제에서 안전하지 않은 경우라도 컴파일 에러를 일으키지도 않고 강제 형변환을 하기 때문에 좋지 않다. (char, int, enum, float, double 등) 리터럴 자료형간 변환을 할 때는 C 스타일 캐스팅을 사용해도 대체적으로 괜찮지만 클래스간 캐스팅에서는 사용하지 않는 것이 옳다.
C++ 스타일의 캐스팅 static_cast / dynamic_cst / reinterpret_cast / const_cast 등을 권장해서 사용하는 데, 이와 관련해서는 다른 포스트에서 정리한다.