[C++] 형 변환 연산자
지난 글에서는 가상 함수에 관해 살펴보았습니다.
C++과 객체 지향이라는 두 키워드를 같이 놓고 본다면, 가상 함수는 가장 중요한 내용 중 하나일 것 입니다.
그런 가상 함수의 무게를 줄이지 않기 위해 이번 글과 분리 해 놓았습니다.
이번 글에서는 C++에서 제공하는 형 변환 연산자에 대해 살펴 볼 것입니다.
1. 개요
형 변환 연산자는 이전 글(깊은 복사와 얕은 복사)에서 잠시 언급되었습니다.
다시 짚어보자면, C++의 형 변환 연산자는 4 종류가 존재합니다.
const_cast<>
static_cast<>
dynamic_cast<>
reinterpret_cast<>
C언어의 강제 형 변환 연산자의 위험성과 불안정성을 해결하기 위해 C++에서 새로 정의된 4가지 형 변환 연산자입니다.
다음 문단에서 위 연산자들을 살펴보도록 하겠습니다.
2. const_cast<>
const_cast<>는 상수형 포인터(참조)의 상수성 (const)을 제거하는 연산자입니다.
이전 글(클래스 기본 문법 2)에서 언급했듯이 설계상의 안정성을 깨뜨리는 방법이 될 수 있으며 사용에 있어 충분히 고려하고, 납득할만한 이유가 있을 때 사용해야 할 것입니다.
예제를 살펴보도록 하겠습니다.
#include <iostream>
using namespace std;
int main() {
const int &a = 10;
int& b = const_cast<int&>(a);
// Complie error
//a = 8;
cout << a << "\n****\n";
b = 8;
cout << a << "\n";
cout << b << "\n";
return 0;
}
10
****
8
8
a는 상수로 선언되었기 때문에 변경이 불가능하지만 const_cast 연산자를 이용할 경우 상수성을 제거하여 수정이 가능해집니다.
여기서 유의할 점은, const_cast연산자는 상수형 포인터의 상수성을 제거하는 것이입니다.
잘못된 예제를 살펴보도록 하겠습니다.
#include <iostream>
using namespace std;
int main() {
const int a = 10;
int& b = const_cast<int&>(a);
// Complie error
//a = 8;
cout << a << "\n****\n";
b = 8;
cout << a << "\n";
cout << b << "\n";
return 0;
}
10
****
10
8
포인터가 아닌 상수에 대해서는 의도대로 값이 변하지 않는 것을 볼 수 있습니다.
3. static_cast<>
상속 관계에서, 파생 형식과 추상 형식은 서로 형변환이 가능합니다.
이 때, 파생 형식이 추상 형식으로 변환되는 것을 상향 형변환 (업캐스팅) 이라고 하고
추상 형식이 파생 형식으로 변환되는 것을 하향 형변환 (다운캐스팅) 이라고 합니다.
업캐스팅은 묵시적으로 이루어질 수 있지만, 다운캐스팅은 명시적으로 지정되어야 합니다.
우선 다운캐스팅의 예제입니다.
#include <iostream>
using namespace std;
class MyClassAnc {
public:
void set_data(int data) { my_data = data; }
void print_data() {
cout << "My data : " << my_data << "\n";
}
protected:
int my_data;
};
class MyClassDes : public MyClassAnc {
public:
void set_data(int data) {
if (data > 10)
data = 10;
MyClassAnc::set_data(data);
}
};
int main() {
MyClassAnc* a = new MyClassDes;
MyClassDes* b = nullptr;
a->set_data(15);
b = static_cast<MyClassDes*>(a); // Line 28
b->print_data();
delete a;
return 0;
}
15
Line 28에서, MyClassAnc가 MyClassDes로 다운캐스팅 되었습니다.
이 부분에서 static_cast를 제거하면 컴파일 에러가 발생할 것 입니다.
이와 같이, 상속 관계에서 다운캐스팅은 명시적인 형 변환 연산자를 사용해야 합니다.
차후 객체 지향에 관한 글을 따로 쓰면서 다루겠지만, 상속 관계에서 클래스는 하위 클래스로 갈 수록 확장되는 경향이 있습니다.
다운캐스팅은 이러한 상황에 오류를 발생시킬 수 있는데, 예제를 살펴보도록 하겠습니다.
#include <iostream>
using namespace std;
class MyClassAnc {
public:
void set_data(int data) { my_data = data; }
void print_data() {
cout << "My data : " << my_data << "\n";
}
protected:
int my_data;
};
class MyClassDes : public MyClassAnc {
public:
void set_data(int data) {
if (data > 10)
data = 10;
MyClassAnc::set_data(data);
}
void print_data() {
cout << "My data : " << my_data << "\n";
cout << "New my data : " << new_my_data << "\n";
}
protected:
int new_my_data = 5;
};
int main() {
MyClassAnc* a = new MyClassAnc;
MyClassDes* b = nullptr;
a->set_data(15);
b = static_cast<MyClassDes*>(a);
b->print_data();
delete a;
return 0;
}
My data : 15
New my data : -33686019
위 예제와 달라진 점은 세 가지 입니다.
- MyClassDes에서 print_data()메소드 오버라이드
- MyClassDes의 멤버 변수 추가
- main함수의 변수 a의 실 형식이 MyClassDes에서 MyClassAnc로 변경
이 예제를 통해 다운캐스팅이 완벽하지 않다는 것을 알 수 있습니다.
자료형은 약속입니다.
이것을 의도적으로 변경할 때에는 충분한 이유가 동반되어야 할 것입니다.
4. dynamic_cast<>
dynamic_cast는 조금 더 안전한 형 변환에 이용됩니다.
하지만, 안전함을 대가로 성능을 희생시킨 형 변환이고, 대체할 수 있는 방법도 있기 때문에 사용을 권장하지는 않는다고 합니다.
우선 예제를 살펴보도록 하겠습니다.
#include <iostream>
using namespace std;
class MyClassAnc {
public:
void set_data(int data) { my_data = data; }
virtual void print_data() {
cout << "My data : " << my_data << "\n";
}
protected:
int my_data;
};
class MyClassDes : public MyClassAnc {
public:
void set_data(int data) {
if (data > 10)
data = 10;
MyClassAnc::set_data(data);
}
void print_data() {
cout << "My data : " << my_data << "\n";
cout << "New my data : " << new_my_data << "\n";
}
protected:
int new_my_data = 5;
};
int main() {
MyClassAnc* a = new MyClassAnc;
MyClassDes* b = nullptr;
a->set_data(15);
b = dynamic_cast<MyClassDes*>(a);
cout << "?\n";
if (b == nullptr)
cout << "!\n";
delete a;
return 0;
}
?
!
윗 문단의 오류가 발생하는 다운캐스팅 예제에서, static_cast를 dynamic_cast로 바꾼 예제입니다.
dynamic_cast는 변환에 실패하면 nullptr을 반환합니다.
이제 dynamic_cast의 작동 방식에 대해 알아보도록 하겠습니다.
dynamic_cast는 형 변환에 있어 클래스의 type_info를 판단 지표로 사용하게 됩니다.
이것은 이전 글(가상 함수)에서 보았던 vtable에 있는 정보입니다.
따라서 dynamic_cast는 가상 함수가 존재하는 클래스에 한해서만 사용할 수 있습니다.
이러한 자료형 확인 방법을 RTTI(Run-Time Type Information or Identification)라고 합니다.
이번에는 다운캐스팅에 있어 dynamic_cast보다 효율적인 방법에 대해 극단적인 예제를 통해 살펴보도록 하겠습니다.
#include <iostream>
using namespace std;
class MyShape {
public:
virtual void draw() { cout << "MyShape::draw()\n"; }
};
class MyCircle : public MyShape {
public:
virtual void draw() { cout << "MyCircle::draw()\n"; }
};
class MyTriangle : public MyShape {
public:
virtual void draw() { cout << "MyTriangle::draw()\n"; }
};
int main() {
cout << "1 : Circle, 2 : Triangle\n";
int flag;
cin >> flag;
MyShape* shape = nullptr;
if (flag == 1)
shape = new MyCircle;
else
shape = new MyTriangle;
shape->draw(); // Line 30
cout << "*********************\n";
// Line 34
MyCircle* circle = dynamic_cast<MyCircle*>(shape);
if (circle != nullptr)
circle->draw();
else {
MyTriangle* triangle = dynamic_cast<MyTriangle*>(shape);
triangle->draw();
}
return 0;
}
1 : Circle, 2 : Triangle
1
MyCircle::draw()
*********************
MyCircle::draw()
Line 30의 코드가 가상 함수를 이용했을 때의 방법이고
Line 34이후의 코드가 dynamic_cast를 이용했을 때의 방법입니다.
위의 소스코드는 이전 글(가상 함수)에서 보았던 추상 자료형에서의 가상 함수 활용법과 유사합니다.
가상 함수로 선언하면 간단해지고, 성능도 더 좋은 코드를 가독성도 떨어뜨리고, 성능까지도 안 좋아지는 코드로 선언할 필요는 없을 것 입니다.
물론 위의 예제는 극단적인 경우입니다.
dynamic_cast가 반드시 필요한 상황에는 사용하는 것이 맞습니다.
하지만 RTTI가 성능 측면에서 좋지 않음을 기억하고, 다른 방법이 있는지 생각해보도록 하는것이 좋겠습니다.
5. reinterpret_cast<>
윗 문단의 static_cast를 다룰 때에는 상속 관계와 업캐스팅, 다운캐스팅 위주로 설명을 했습니다.
여기에서 첨언하자면, static_cast는 피연산자에 대한 적절한 형 변환 연산자를 호출합니다.
예제를 살펴보도록 하겠습니다.
#include <iostream>
using namespace std;
class A {
public:
int data = 10;
};
class B {
public:
operator A() {
cout << "Cast\n";
A a;
return a;
}
};
int main() {
B b;
A a = static_cast<A>(b);
return 0;
}
Cast
간단하게 만들어진 클래스 A, B가 있고, B에는 A로의 형 변환 연산자가 존재합니다.
main함수에서 A 인스턴스를 생성하기 위해 B를 static_cast하여 생성했을 때, 형 변환 연산자가 호출되는 예제입니다.
reinterpret_cast는 이것보다 조금 더, 어쩌면 조금 많이 강력한 형 변환 연산자 입니다.
C의 형 변환 연산자와 비슷하게 강제성이 있다고 볼 수 있습니다.
예제를 살펴보도록 하겠습니다.
#include <iostream>
using namespace std;
int main() {
int* a = new int(100);
float* b = reinterpret_cast<float*>(a);
char* c = reinterpret_cast<char*>(b);
cout << *a << "\n";
cout << bitset<32>(*a) << "\n";
cout << *b << "\n";
cout << bitset<32>(*b) << "\n";
cout << "\n";
for (size_t n = 0; n < sizeof b; ++n)
cout << bitset<8>(c[n]);
return 0;
}
100
00000000000000000000000001100100
1.4013e-43
00000000000000000000000000000000
01100100000000000000000000000000
예제의 출력에 의하면, reinterpret_cast는 비트 단위의 형 변환 연산자 입니다.
출력을 보면, int*형 100을 float*형으로 reinterpret_cast했을 때, 100과는 전혀 관련 없는 값이 출력됨을 볼 수 있습니다.
하지만 이것을 이진수로 변환해서 보면 동일하다는 것을 알 수 있습니다.
float*의 이진수 출력이 뒤집혀 있는 것은 출력을 0번 비트부터 했기 때문입니다.
reinterpret_cast의 사용과 관련된 Stackoverflow의 글 하나를 첨부하도록 하겠습니다.
https://stackoverflow.com/questions/573294/when-to-use-reinterpret-cast
When to use reinterpret_cast?
I am little confused with the applicability of reinterpret_cast vs static_cast. From what I have read the general rules are to use static cast when the types can be interpreted at compile time henc...
stackoverflow.com
static_cast와 reinterpret_cast의 비교를 메모리 주소 관점에서 비교한 답변과 필요할 때 알게 될 것이니 사용하지 말라는 답변을 볼 수 있습니다.
전체적으로 Low한 (메모리, 비트 관점에서 바라보아야 하는) 연산자이기 때문에
사용 예가 많지 않음과, 사용에 신중을 기해야 하는 연산자임을 알 수 있습니다.
C++에서 이렇게 다양한 종류의 형 변환 연산자를 지원하는 이유는 안정성 때문일것입니다.
형 변환에 있어서는 C언어의 강제 형 변환 연산자가 가장 강력하고 편하겠지만
그만큼 버그도 많이 발생할 수 있기 때문에, 상황별로, 기능별로 분리해둔 것이라 생각합니다.
변환에 있어서는 조금 불편하더라도 C++의 연산자를 활용하는 습관을 들이고
그 이전에, 변환을 하는 충분한 이유가 뒷받침 될 수 있도록 해야 할 것입니다.
읽어주셔서 감사합니다.