[C++] 16. 형 변환 연산자

kkado·2023년 10월 21일
0

열혈 C++

목록 보기
16/16
post-thumbnail

💬 윤성우 님의 <열혈 C++ 프로그래밍> 책을 혼자 공부하며 배운 내용을 정리합니다. 글의 모든 내용은 책에서 발췌하였습니다.


드디어 마지막 챕터이다! \^^/

C++에서의 형 변환 연산

C언어에서 제공하는 형 변환 연산자는 강력해서 변환하지 못하는 대상이 없다. 따라서 문제가 발생할 수 있는 다음과 같은 코드를 작성해도 컴파일러는 이를 잡아내지 못한다.

class Car
{
private:
    int fuelGauge;
public:
    Car(int fuel) : fuelGauge(fuel) {}
    void showCarState()
    {
        cout << "현재 연료 : " << fuelGauge << "\n";
    }
};

class Truck : public Car
{
private:
    int freightWeight;
public:
    Truck(int fuel, int weight) : Car(fuel), freightWeight(weight) {}

    void showTruckState()
    {
        showCarState();
        cout << "화물 무게 : " << freightWeight << "\n";
    }
};

int main()
{
    Car* pcar1 = new Truck(80, 200);
    Truck* ptruck1 = (Truck*)pcar1;
    ptruck1->showTruckState();

    cout << "\n";

    Car* pcar2 = new Car(120);
    Truck* ptruck2 = (Truck*)pcar2;
    ptruck2->showTruckState();
}
현재 연료 : 80
화물 무게 : 200

현재 연료 : 120
화물 무게 : 0

pcar1과 ptruck1의 형변환은 문제가 되지 않는다. Truck 객체를 Car 포인터로 참조하는 것도 괜찮고 이를 다시 Truck 포인터로 참조해도 문제가 없기 때문이다.

반면 Car 객체를 Truck 포인터로 참조하는 것은 적절하지 못하다. 그러나 C 스타일의 형 변환 연산자는 컴파일 에러를 발생시키지 않는다.

컴파일러는, '이것이 맞는 지는 잘 모르겠는데... 일단 형변환 하라니까 하긴 한다.' 라는 생각을 한다.

하지만 더 큰 문제는 사실 Truck* ptruck1 = (Truck*)pcar1; 에 있다. 이런 형식의 형변환은 프로그래머의 실수인지 아닌지의 여부를 쉽게 판단하기가 어렵기 때문이다.

Truck형 포인터가 Truck 객체를 가리키는 것은 정상적인 상황이라고 볼 수 있지만, 그렇다면 굳이 Truck 객체를 Car 포인터로 가리키게 했을까? 기초 클래스의 포인터형을 유도 클래스의 포인터형으로 변환하는 것은 일반적인 연산은 아니다.

이러한 혼동을 막기 위하여 C++에서는 4가지의 연산자를 추가로 제공하면서 너무 강력한 C 형변환 연산자 말고 용도에 딱 맞는 형 변환 연산자를 사용하게끔 유도하고 있다.


dynamic_cast : 상속 관계에서의 안전한 형변환

dynamic_cast 형변환 연산자는 다음의 형태를 갖는다.

dynamic_cast<T>(expr)

<> 괄호에는 변한하고자 하는 자료형의 이름을 두되, 객체의 포인터 또는 참조형이 와야 하며 () 괄호에는 변환의 대상이 온다.

그리고 요구한 형 반환이 적절하지 않은 경우 컴파일 시 에러가 발생한다.
여기서 말하는 '적절한 형변환' 이라고 함은 다음의 경우이다.

상속 관계에 놓여 있는 두 클래스들 사이에서 유도 클래스의 포인터 및 참조형 데이터를 기초 클래스의 포인터 및 참조형 데이터로 형 변환하는 경우

dynamic cast 연산자를 사용했다는 것은 다음과 같은 의미를 지닌다.

상속 관계에 있는 유도 클래스의 포인터 및 참조형 데이터를 기초 클래스의 포인터 및 참조형 데이터로 형 변환 하겠다.

즉 앞서 보았던 Car-Truck 과 같은 관계에서 사용할 수 있다. 그렇다면 위 코드에서 dynamic cast를 사용하는 사례를 살펴보자

// Car, Truck 클래스는 위와 동일
int main()
{
    Car* pcar1 = new Truck(80, 200);
    Truck* ptruck1 = dynamic_cast<Truck*>(pcar1); // 컴파일 에러!

    Car* pcar2 = new Car(120);
    Truck* ptruck2 = dynamic_cast<Truck*>(pcar2); // 컴파일 에러!


    Truck* ptruck3 = new Truck(70, 150);
    Car* pcar3 = dynamic_cast<Car*>(ptruck3);
}

여기서 ptruck1, ptruck2의 형변환 과정에서 컴파일 에러가 발생한다.
그런데, pcar2, ptruck2의 예시는 딱 봐도 이상함을 느낄 수 있는데 pcar1, ptruck1을 보면 경우에 따라 필요할 수도 있을 것 같은데, 이럴 때는 어떤 연산자를 쓰면 좋을까.

static_cast : A 타입에서 B 타입으로

static_cast 형변환 연산자는 다음의 형태를 갖는다.

static_cast<T>(expr)

dynamic_cast 연산자와 동일하다.

static_cast를 사용하고자 하는 우리에게 컴파일러는 이렇게 말한다.

"좋다. 유도 클래스의 포인터 및 참조형 데이터를 기초 클래스의 포인터 및 참조형 데이터로뿐만 아니라, 그 반대인 기초 클래스의 포인터 및 참조형 데이터에서 유도 클래스의 포인터 및 참조형 데이터로의 형 변환도 시켜줄게. 하지만 그 책임은 니가 져라.

뭔가 강력한 것 같지만 잘 의미가 와닿지 않으니, static_cast 연산자를 Car-Truck 예제에 써먹어보자.

int main()
{
    Car* pcar1 = new Truck(80, 200);
    Truck* ptruck1 = static_cast<Truck*>(pcar1);
    ptruck1->showTruckState();

    cout << "\n";

    Car* pcar2 = new Car(120);
    Truck* ptruck2 = static_cast<Truck*>(pcar2);
    ptruck2->showTruckState();
}

이 코드는 문제없이 실행된다.

Truck* ptruck1 = static_cast<Truck*>(pcar1); 이 문장이 의미하는 것은 다음과 같다.

포인터 pcar1을 Truck* 형으로 형변환 할 건데, 이것은 내가 의도한 것이고 그에 대한 책임도 내가 진다.

뭐, 문제 없는 상황이긴 하다. 원래 pcar1 객체는 Truck형 객체였으니...

그런데 Truck* ptruck2 = static_cast<Truck*>(pcar2); 이 문장은 조금 논란의 여지가 있다. pcar2가 가리키는 대상은 Car 객체이다. 이를 Truck 포인터로 가리키는 것은 정당화될 수 없다.. 따라서 이러한 형태는 옳지 않다.

그리고 이 책의 필자는 독자들에게 다음과 같이 권한다.

"static_cast 연산자는 dynamic_cast 연산자보다 더 많은 형변환을 허용한다. 하지만 그에 대한 책임도 프로그래머가 져야 하는 것이기에 신중하게 선택해야 한다. dynamic_cast 연산자를 사용할 수 있는 경우에는 가급적 dynamic_cast 연산자를 사용해 안정성을 높여야 하며, 그 이외 정말 책임질 수 있는 상황에서만 제한적으로 static_cast 연산자를 사용하길 권한다."

나중에 다룰 내용이긴 하지만 static_cast 연산자가 dynamic_cast 연산자보다 연산의 속도가 더 빠르다고 한다. 이러한 이유로 dynamic_cast를 사용하는 것이 좋은 상황에서조차 static_cast 연산자를 사용하는 경우도 많다고 한다.

그리고 static cast 연산자는 기본 자료형 데이터 간의 형 변환에도 사용된다. 가령 정수형 변수끼리의 나눗셈의 결과는 정수형이 되는데 다음과 같이 나눗셈을 구성하면 된다.

double result = static_cast<double>(20) / 3;

물론 C 연산자를 통해

double result = (double)20 / 3;
double result = double(20) / 3;

이런 식으로 해도 된다.

그런데 static_cast와 기본 C 형변환 연산자의 차이는 무엇일까? 프로그래머에게 책임을 지라고 하는거면 C 형변환 연산자도 비슷하지 않나?

C 형변환 연산자는 다음과 같은 말도 안 되는 형 변환도 허용하는 반면 static_cast는 허용하지 않는다.

int main()
{
	const int num = 20;
    int* ptr = (int*)&num;
    *ptr = 30; // 실제로 const형인 num의 값이 바뀜
    cout << *ptr << "\n";
    
    float* adr = (float*)ptr; // int형 포인터를 float형으로 해석
    cout << *adr << "\n";
    

이런 말도 안되는 코드를 누가 짤까 싶긴 하다만 static_cast 연산자는 C 형변환 연산자보다는 좁은 범위를 갖는 것은 맞다.

const_cast : const 성향을 삭제

C++에서는 포인터와 참조자의 const 성향을 제거하는 형 변환을 목적으로 const_cast 라는 것을 제공한다.

const_cast<T>(expr)

포인터의 const 성향을 제거하는 것은 const의 가치를 떨어뜨리는 것이 아닐까? 그러나 그 이면을 살펴보면 나름의 의미를 발견할 수 있다. 다음 예제를 통해 확인 해보자

void showString(char* str)
{
    cout << str << "\n";
}

void showAddResult(int& n1, int& n2)
{
    cout << n1 + n2 << "\n";
}

int main()
{
    const char* name = "Lee seung gi";
    showString(const_cast<char*>(name));

    const int& num1 = 100;
    const int& num2 = 200;
    showAddResult(const_cast<int&>(num1), const_cast<int&>(num2));
}

간단한 예시이다. nameconst char*형이고 showString 함수의 매개변수는 char* 형인데, 따라서 nameshowString 함수의 인자로 전달할 수 없다.

이렇듯 const_cast 형변환은 함수의 인자 전달 시 const의 선언으로 인한 형의 불일치에 의해 인자의 전달이 불가능한 경우에 유용하다.

그러나 이것은 '값의 변경을 허용하지 않는다' 는 const의 의미를 퇴색시키는 것으로, 이렇게 유용하게 사용할 수 있을 때만 제한적으로 사용하는 것이 좋다.

reinterpret_cast : 상관없는 자료형으로의 형 변환

reinterpret_cast 형변환 연산자는 전혀 상관이 없는 자료형으로의 형변환에 사용이 된다.

reinterpret_cast<T>(expr)

예를 들어 상속 관계에 있지 않은, 전혀 별개의 두 클래스 간의 형변환도 가능하다.

class AAA {};
class BBB {};

int main()
{
	AAA* a = new AAA;
    BBB* b = reinterpret_cast<BBB*>(a);
}

포인터를 대상으로 하는, 또는 포인터와 관련있는 모든 유형의 형변환을 허용한다. 그러나 이렇게 작성한 것이 무슨 의미가 있을까...?

특정 상황에서는 분명 이러한 연산이 유용하게 사용되기도 한다.


dynamic_cast : polymorphic 클래스 기반의 형변환

지금까지의 내용을 잘 정리해보면, 상속과 관련된 형 변환에 대해서 다음과 같이 정리할 수 있다.

  • 상속 관계에 놓여 있는 두 클래스 사이에서 유도 클래스의 포인터 및 참조형 데이터를 기초 클래스의 포인터 및 참조형 데이터로 형변환 할 경우 dynamic_cast 연산자를 사용함
  • 반대로 상속 관계에 놓여 있는 두 클래스 사이에서 기초 클래스의 포인터 및 참조형 데이터를 유도 클래스의 포인터 및 참조형 데이터로 형변환할 경우에는 static_cast를 사용함

그러나 dynamic_cast 연산자도 기초 클래스의 포인터 및 참조형 데이터를 유도 클래스의 포인터 및 참조형 데이터로 형변환이 가능하다. '기초 클래스가 Polymorphic 클래스일 때' 가능하다.

Polymorphic 클래스란 하나 이상의 가상함수를 가지는 클래스를 뜻한다.

상속 관계에 놓여 있는 두 클래스 사이에서 기초 클래스에 가상 함수가 하나 이상 존재하면, dynamic_cast 연산자를 이용해 기초 클래스의 포인터 및 참조형 데이터를 유도 클래스의 포인터 및 참조형 데이터로 변환이 가능하다.

대체 무슨 소리인가 싶지만.. 예제를 살펴보자.

class Simple
{
public:
    virtual void showSimple()
    {
        cout << "Simple base class\n";
    }
};

class Complex : public Simple
{
public:
    void showSimple()
    {
        cout << "Complex derived class\n";
    }
};

int main()
{
    Simple* simPtr = new Complex;
    Complex* comPtr = dynamic_cast<Complex*>(simPtr);
    comPtr->showSimple();
}
Complex derived class

분명 Simple 포인터형을 Complex 포인터형으로 dynamic_cast를 사용해 형변환하고 있는데 문제 없이 실행된다.

Simple 클래스에서 함수가 void로 선언이 되어 있어서 가상함수가 됐기 때문에 가능했고 만약 virtual로 선언되지 않을 경우 에러가 발생한다.


int main()
{
    Simple* simPtr = new Complex;
    Complex* comPtr = dynamic_cast<Complex*>(simPtr);
    ...
}

바로 위의 예제를 가져와 봤다. 이러한 dynamic cast가 성공한 이유는 애초에 선언된 것이 Complex형 객체였기 때문이다.

즉 Complex형 객체를 Complex 포인터로 가리키는 셈이 되어 괜찮은 것이다.

그러나 이렇게 바뀐다면 어떨까.

int main()
{
    Simple* simPtr = new Simple;
    Complex* comPtr = dynamic_cast<Complex*>(simPtr);
    comPtr->showSimple();
}

이번에는 Simple 데이터를 Complex 포인터로 참조하려 하고 있다. 이러한 경우 형변환 결과 NULL 포인터가 반환된다.

int main()
{
    Simple* simPtr = new Simple;
    Complex* comPtr = dynamic_cast<Complex*>(simPtr);
    if (comPtr == NULL)
        cout << "형변환 실패\n";
    else
        comPtr->showSimple();
}
형변환 실패

이렇듯 dynamic_cast는 안정적인 형변환을 보장한다.

dynamic_cast는 컴파일 시간이 아닌 런타임에 안전성을 검사하도록 컴파일러가 바이너리 코드를 생성하기 때문에, 실행 속도는 다소 늦어지지만 안정적인 형변환이 가능하다.

반면 static_cast는 안전성을 보장하지 않고 무조건 형변환이 되도록 바이너리 코드를 생성하기 때문에 실행 속도가 빠른 것이다.


맺는 글

C에 대한 개념이 약한 채로 C++ 공부를 시작했는데 생각보다 재밌게 공부했던 것 같다. 객체지향 관련된 개념들도 확실히 잘 알게 되었고.

물론 C++의 문법적/구조적 기초일 뿐 STL이나 설계 기술들도 공부하기엔 아직 갈 길이 멀다..

다음 책은 아마 C++의 바이블이라고 불리는 스콧 마이어스의 'Effective C++' 을 공부할 것 같다.

profile
울면안돼 쫄면안돼 냉면됩니다

0개의 댓글