[Effective C++]캐스팅은 절약, 또 절약! 잊지 말자

구현, 두 번째 이야기

Posted by SungBeom on December 15, 2019 · 15 mins read

C++의 캐스트

"어떤 일이 있어도 타입 에러가 생기지 않도록 보장한다." C++의 동작 규칙은 바로 이 철학을 바탕으로 설계되어 있습니다. 즉, 이론적으로 C++ 프로그램은 일단 컴파일만 깔끔하게 끝나면 그 이후엔 어떤 객체에 대해서도 불안전한 연산이나 말도 안 되는 연산을 수행하려 들지 않는다는 것입니다.

그런데 공교롭게도 C++에는 이 타입 시스템을 위협하는 것이 있는데, 바로 캐스트(cast)입니다. C++에서 캐스팅은 정말로 조심해서 써야 하는 기능입니다. 캐스팅 문법부터 정리하자면 똑같은 캐스트인데 쓰는 방법은 세 가지입니다.

우선, C 스타일의 캐스트입니다.
(T)표현식  // 표현식 부분을 T 타입으로 캐스팅합니다.

다음은 함수 방식 캐스트입니다. 문법이 함수호출문 같지요.
T(표현식)  // 표현식 부분을 T 타입으로 캐스팅합니다.

위 두 형태는 어떻게 쓰든 괄호를 어디에 썼느냐만 다를 뿐, 가진 의미는 똑같습니다. 이 두 형태를 통틀어 '구현 스타일의 캐스트'라고 부르겠습니다.

C++은 네 가지로 이루어진 새로운 형태의 캐스트 연산자를 독자적으로 제공합니다. 이는 신형 스타일 캐스트 혹은 C++ 스타일의 캐스트라고 부르겠습니다.

const_cast<T>(표현식)
객체의 상수성(constness)을 없애는 용도로 사용됩니다.

dynamic_cast<T>(표현식)
이른바 '안전한 다운캐스팅(safe downcasting)'을 할 때 사용하는 연산자입니다. 즉, 주어진 객체가 어떤 클래스 상속 계통에 속한 특정 타입인지 아닌지를 결정하는 작업에 쓰입니다. 런타임 비용이 높은 캐스트 연산자입니다.

reinterpret_cast<T>(표현식)
포인터를 int로 바꾸는 등의 하부 수준 캐스팅을 위해 만들어진 연산자로서, 이것의 적용 결과는 구현환경에 의존적입니다(이식성이 없습니다). 이런 캐스트는 하부 수준 코드 외에는 거의 없어야 합니다.

static_cast<T>(표현식)
암시적 변환(비상수 객체를 상수 객체로 바꾸거나, int를 double로 바꾸는 등의 변환)을 강제로 진행할 때 사용합니다. 흔히들 이루어지는 타입 변환을 거꾸로 수행하는 용도(void*를 일반 타입의 포인터로 바꾸거나, 기본 클래스의 포인터를 파생 클래스의 포인터로 바꾸는 등)로도 쓰입니다. 물론 상수 객체를 비상수 객체로 캐스팅하는 데 이것을 쓸 수는 없습니다.

구형 스타일의 캐스트는 요즘도 여전히 적법하게 쓰일 수 있지만, 그보다는 C++ 스타일의 캐스트를 쓰는 것이 바람직합ㄴ디ㅏ. 우선, 코드를 읽을 때 알아보기 쉽기 때문에(사람 눈에도 그렇고 grep 등의 검색도구에도 그렇습니다), 소스 코드의 어디에서 C++의 타입 시스템이 망가졌는지를 찾아보는 작업이 편해집니다. 둘째, 캐스트를 사용한 목적을 더 좁혀서 지정하기 때문에 컴파일러 쪽에서 사용 에러를 진단할 수 있습니다. 무슨 말인고 하니, 상수성을 없애려고 한 부분에다가 const_cast 대신에 다른 신형 스타일의 캐스트를 실수로 썼다면 코드 자체가 컴파일되지 않으므로 좋다는 것입니다.

파생 클래스의 캐스팅 문제

캐스팅은 그냥 어떤 타입을 다른 타입으로 처리하라고 컴파일러에게 알려 주는 것에 지나지 않는다고 생각한다면, 크나큰 오해입니다. 어떻게 쓰더라도(캐스팅으로 명시적으로 바꾸거나 컴파일러가 암시적으로 바꾸거나) 일단 타입 변환이 있으면 이로 말미암아 런타임에 실행되는 코드가 만들어지는 경우가 적지 않습니다. 다음의 코드를 봐주세요.

1
2
3
4
int x, y;
...
double d = static_cast<double>(x)/y;  // x를 y로 나눕니다. 그런데 이때
                                      // 부동소수점 나눗셈을 사용합니다.
cs

거의 항상 int 타입의 x를 double 타입으로 캐스팅한 부분에서 코드가 만들어집니다. 대부분의 컴퓨터 아키텍처에서 int의 표현구조와 double의 표현구조가 아예 다르기 때문입니다. 문제는 다음 코드입니다.

1
2
3
4
class Base { ... };
class Derived: public Base { ... };
Derived d;
Base *pb = &d;  // Derived* => Base*의 암시적 변환이 이루어집니다.
cs

보다시피 파생 클래스 객체에 대한 기본 클래스 포인터를 만드는(초기화하는), 지극히 흔하디 흔한 코드입니다. 그런데 두 포인터의 값이 같지 않을 때도 가끔 있습니다. 이런 경우가 되면, 포인터의 변위(offset)를 Dervied* 포인터에 적용하여 실제의 Base* 포인터 값을 구하는 동작이 바로 런타임(runtime)에 이루어집니다. 이는 객체 하나(이를테면 Derived 타입의 객체)가 가질 수 있는 주소가 오직 한 개가 아니라 그 이상이 될 수 있음을(Base* 포인터로 가리킬 때의 주소, Dervied* 포인터로 가리킬 때의 주소) 보여주는 사례입니다. 이런 일은 C나 자바는 물론이고 C#에서도 결코 생길 수 없고, C++에서만 생깁니다. 따라서 C++을 쓸 때는 데이터가 어떤 식으로 메모리에 박혀 있을 거라는 섣부른 가정을 피해야 하며, 더욱이 이런 가정에 기반한 캐스팅은 통하지 않습니다. 이를테면, 어떤 객체의 주소를 char* 포인터로 바꿔서 포인터 산술 연산을 적용하는 등의 코드는 거의 항상 미정의 동작을 낳을 수 있다는 이야기입니다.

캐스팅이 들어가면, 보기엔 맞는 것 같지만 실제로는 틀린 코드를 쓰고도 모르는 경우가 많아집니다. 이를테면, 주변에서 많이들 쓰이는 응용프로그램 프레임워크(application framework)를 하나 살펴보면, 가상 함수를 파생 클래스에서 재정의해서 구현할 때 기본 클래스의 버전을 호출하는 문장을 가장 먼저 넣어달라는 요구사항을 보게 됩니다. 어떤 프레임워크에 Window 기본 클래스가 있고 SpecialWindow 파생 클래스가 있다고 가정해 보죠. 이들 클래스는 onResize를 구현하려면 Window의 onResize를 호출해야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Window {                                  // 기본 클래스
public:
    virtual void onResize() { ... }             // 기본 클래스의 onResize 구현 결과
    ...
};
 
class SpecialWindow: punlic Window {            // 파생 클래스
public:
    virtual void onResize() {
        static_cast<Window>(*this).onResize();  // 파생 클래스의 onResize 구현 결과 *this를 Window로 캐스팅하고
                                                // 그것에 대해 onResize를 호출합니다. 동작이 안 되어서 문제죠.
        ...                                     // Special Window에서만 필요한 작업을 여기서 수행합니다.
    }
    ...
};
cs

위의 코드는 *this를 Window로 캐스팅하는 코드입니다. 이에 따라 호출되는 onResize 함수는 Window::onResize가 됩니다. 그런데 함수 호출이 이루어지는 객체는 현재의 객체가 아닙니다! 이 코드에서는 캐스팅이 일어나면서 *this의 기본 클래스 부분에 대한 사본이 임시적으로 만들어지게 되어 있는데, 지금의 onResize는 바로 이 임시 객체에서 호출된 것입니다. 결국, 위의 코드는 현재의 객체에 대해 Window::onResize를 호출하지 않고 지나갑니다. 그러고 나서 SpecialWindow 전용의 동작은 또 현재의 객체에 대해서 수행합니다. 다시 말해, SpecialWindow만의 동작을 현재 객체에 대해 수행하기도 전에 기본 클래스 부분의 사본에 대고 Window::onResize를 호출하는 것입니다. 이때 Window::onResize가 객체를 수정하도록 만들어졌기라도 하면, 현재 객체는 실제로 그 수정이 반영되지 않을 것입니다. 하지만 SpecialWindow::onResize에서 객체를 수정하면 진짜 현재 객체가 수정될 게 분명합니다. 즉, 시본 클래스에서 들어가는 수정은 반영되지 않고, 파생 클래스에서 들어가는 수정만 반영됩니다.

이 문제를 풀려면 일단 캐스팅을 빼버려야 합니다. 그냥 현재 객체에 대고 onResize의 기본 클래스 버전을 호출하도록 만들면 되는 것입니다.

1
2
3
4
5
6
7
8
class SpecialWindow: punlic Window {
public:
    virtual void onResize() {
        Window::onResize();  // *this에서 Window::onResize를 호출합니다.
        ...
    }
    ...
};
cs

dynamic_cast의 대체 방안

dynamic_cast는 그 설계부터 말도 많고 탈도 많은 연산자입니다. 이 부분을 잘 알아두면 꽤 유익하긴 하지만, 지금은 상당수의 구현환경에서 이 연산자가 정말 느리게 구현되어 있습니다. dynamic_cast 연산자가 쓰고 싶어지는 때가 있는데, 파생 클래스 객체가 있어서 이에 대해 파생 클래스의 함수를 호출하고 싶은데, 그 객체를 조작할 수 있는 수단으로 기본 클래스의 포인터(혹은 참조자)밖에 없을 경우입니다. dynamic_cast를 사용한 예로, 지금까지 봐 왔던 Window 및 SpecialWindow 상속 계통에서 깜박거리기(blink) 기능을 SpecialWindow 객체만 지원하게 되어 있는 코드를 보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Window { ... };
 
class SpecialWindow: public Window {
public:
    void blink();
    ...
};
 
typedef std::vector<std::tr1::shared_ptr<Window>> VPW;
VPW winPtrs;
 
...
for(VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter) {
    if (SpecialWindow *psw = dynamic_cast<SpecialWindow*>(iter->get()))
        psw->blink();
}
cs

이런 문제를 피해 가는 일반적인 방법으로는 두 가지를 들 수 있습니다. 첫 번째 방법은, 파생 클래스 객체에 대한 포인터(혹은 스마트 포인터)를 컨테이너에 담아둠으로써 각 객체를 기본 클래스 인터페이스를 통해 조작할 필요를 아예 없애 버리는 것입니다.

1
2
3
4
5
6
typedef std::vector<std::tr1::shared_ptr<SpecialWindow>> VPSW;
VPSW winPtrs;
 
...
for(VPSW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
    (*iter)->blink();
cs

이 방법으로는 Window에서 파생될 수 있는 모든 것들에 대한 포인터를 똑같은 컨테이너에 저장할 수는 없습니다. 다른 타입의 포인터를 담으려면 타입 안전성을 갖춘 컨테이너 여러 개가 필요할 것입니다.

한편, Window에서 뻗어 나온 자손들을 전부 기본 클래스 인터페이스를 통해 조작할 수 있는 다른 방법이 없는 것은 아닙니다. 여러분이 원하는 조작을 가상 함수 집합으로 정리해서 기본 클래스에 넣어두면 됩니다. 예를 들어, 지금은 blink 함수가 SpecialWindow에서만 가능하지만, 그렇다고 기본 클래스에 못 넣어둘 만한 것도 아니죠. 그러니까, 아무것도 안 하는 기본 blink를 구현해서 가상 함수로 제공하는 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Window {
public:
    virtual void blink() {}       // 기본 구현은 '아무 동작 안하기'입니다.
    ...
};
 
class SpecialWindow: public Window {
public:
    virtual void blink() { ... }  // 이 클래스에서는 blink 함수가 특정한 동작을 수행합니다.
    ...
};
 
typedef std::vector<std::tr1::shared_ptr<Window>> VPW;
VPW winPtrs;                      // 이 컨테이너는 Window에서 파생된 모든 타입의
                                  // 객체(에 대한 포인터)들을 담습니다.
...
for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
    (*iter)->blink();
cs

말씀드린 두 가지 방법 중 어떤 것도(타입 안전성을 갖춘 컨테이너를 쓰든지 가상 함수를 기본 클래스 쪽으로 올려두든지) 모든 상황에 다 적용하기란 불가능하지만, 상당히 많은 상황에서 dynamic_cast를 쓰는 방법 대신에 꽤 잘 쓸 수 있습니다.

정말 잘 작성된 C++ 코드는 캐스팅을 거의 쓰지 않습니다. 하지만 캐스팅을 발본색원한다는 것도 어찌 보면 현장 사정을 무시한 생각일 수 있습니다. 앞에서 보신 int를 double로 바꾸는 경우는 꼭 필요한가에 대한 의문이 남긴 하지만, 터무니없는 캐스팅은 아닙니다. 캐스팅은 그냥 막 쓰기에는 꺼림칙한 문법 기능을 써야 할 때 흔히 쓰이는 수단을 활용해서 처리하는 것이 좋습니다. 쉽게 말해 최대한 격리시켜, 캐스팅을 해야 하는 코드를 내부 함수 속에 몰아 놓고, 그 안에서 일어나는 일들은 이 함수를 호출하는 외부에서 알 수 없도록 인터페이스로 막아두는 식으로 해결하면 됩니다.


정리

다른 방법이 가능하다면 캐스팅은 피하십시오. 특히 수행 성능에 민감한 코드에서 dynamic_cast는 몇 번이고 다시 생각하십시오. 설계 중에 캐스팅이 필요해졌다면, 캐스팅을 쓰지 않는 다른 방법을 시도해 보십시오.
캐스팅이 어쩔 수 없이 필요하다면, 함수 안에 숨길 수 있도록 해 보십시오. 이렇게 하면 최소한 사용자는 자신의 코드에 캐스팅을 넣지 않고 이 함수를 호출할 수 있게 됩니다.
구형 스타일의 캐스트를 쓰려거든 C++ 스타일의 캐스트를 선호하십시오. 발견하기도 쉽고, 설계자가 어떤 역할을 의도했는지가 더 자세하게 드러납니다.