C++/이것이 C++이다

[C++] 형 변환 연산자

ruru14 2021. 9. 19. 21:21

지난 글에서는 가상 함수에 관해 살펴보았습니다.

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

위 예제와 달라진 점은 세 가지 입니다.

  1. MyClassDes에서 print_data()메소드 오버라이드
  2. MyClassDes의 멤버 변수 추가
  3. 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++의 연산자를 활용하는 습관을 들이고

그 이전에, 변환을 하는 충분한 이유가 뒷받침 될 수 있도록 해야 할 것입니다.

 

읽어주셔서 감사합니다.