728x90
반응형

'어떤 일이 있어도 type error가 생기지 않도록 보장한다.'라는 것은 C++의 동작 규칙이다.

즉, 이론적으로는 컴파일만 잘 되면 그 이후엔 어떤 객체에서도 불완전한 연산이나 말도 안 되는 연산 등을 수행하지 않는다는 것이다.

하지만 C++에서는 cast 시스템 때문에 이런 보장이 깨질 수도 있다.

 

캐스트 방법

C 스타일의 캐스트 (구형 캐스트)

  • (T) 표현식
  • T(표현식) : 함수 방식의 캐스트

C++ 스타일의 캐스팅

  • const_cast<T>(표현식)
    • 객체의 상수성을 없애는 용도, const -> non-const로 바꾸는 용도
  • dynamic_cast<T>(표현식)
    • 안전한 다운 캐스팅을 할 때 사용한다. 상속 상황에서 쓰이며, 주어진 객체가 어떤 클래스 상속 계통에 속한 특정 타입인지 아닌지를 결정하는 작업에 쓰인다. 런타임 비용이 매우 높다.
  • reinterpret_cast<T>(표현식)
    • 하부 수준 캐스팅(포인터 > int 등)을 위한 것으로 구현 환경에 의존적이다. 즉, 이식성이 없기 때문에 하부 수준 코드 외에는 거의 없어야 한다.
  • static_cast<T>(표현식)
    • 암시적 변환(비상수 객체 > 상수 객체, int > double 등)을 강제로 진행할 때 사용한다. 흔히들 이루어지는 타입 변환을 거꾸로 수행하는 용도(void* > 일반 타입의 포인터, 기본 클래스의 포인터 > 파생 클래스의 포인터 등)로도 쓰인다.
    • 상수 객체를 비상수 객체로 캐스팅하는 데는 사용할 수 없다.

 

C++스타일 캐스트를 쓰는 것이 바람직한 이유

  • 코드를 읽을 때 알아보기 쉽다.(사람 눈, grep 검색 도구 등)
  • 소스코드 어디에서 C++의 타입 시스템이 망가졌는지를 찾아보는 작업이 편해진다.
  • 캐스트를 사용한 목적을 더 좁혀서 지정하기 때문에 컴파일러 쪽에서 사용 에러를 진단할 수 있다.

 

캐스팅으로 인해 런타임에 만들어지는 코드

캐스팅은 그냥 어떤 타입을 다른 타입으로 처리하라고 컴파일러에게 알려주는 것으로만으로 알고 있는 경우가 많지만, 실상은 그렇지 않다.

타입 변환 시 런타임에 실행되는 코드가 만들어지는 경우가 꽤 있어 런타임 비용이 필요하기 때문이다.

int x, y;
...
// 부동 소수점 나눗셈을 사용하여 x를 y로 나눈다
double d = static_cast<double>(x)/y;

int타입의 x를 double타입으로 캐스팅한 부분에서 코드가 만들어진다. 왜냐하면 대부분의 컴퓨터 아키텍처에서 int의 표현 구조와 double의 표현 구조가 아예 다르기 때문이다.

class Base {...};
class Derived: public Base {...};
Derived d;
// Derived* -> Base*의 암시적 변환이 이루어진다.
Base *pb = &d;

파생 클래스 객체에 대한 기본 클래스 포인터를 만드는 코드이다. 그런데 두 포인터 값이 같지 않을 때는 포인터의 변위(offset)를 Derived* 포인터에 적용하여 실제의 Base* 포인터 값을 구하는 동작이 런타임에 이루어진다.

이 현상은 객체 하나가 가질 수 있는 주소가 한 개가 아니라 그 이상이 될 수 있음을(Base* 포인터로 가리킬 때의 주소, Derived* 포인터로 가리킬 때의 주소) 보여주는 사례다.

이런 현상은 C, Java, C#에서는 생길 수 없으나 C++에서는 생긴다. 다중 상속이 사용되면 이런 일이 항상 생기지만, 단일 상속인데도 이렇게 되는 경우가 있다.

 

결과적으로 객체의 메모리 배치 구조를 결정하는 방법과 객체의 주소를 계산하는 방법이 컴파일러마다 천차만별이기 때문에 캐스팅은 아주 조심히 써야 한다는 것이다.

 

캐스팅으로 인해 맞게 보이지만 틀린 코드

// 기본 클래스
class Window
{
    public:
    // 기본 클래스의 onResize 구현 결과
    virtual void onResize() {...}
    ...
};

// 파생 클래스
class SpecialWindow: public Window 
{
    public:
    // 파생 클래스의 onResize 구현 결과 *this를 Window로 캐스팅하고
    // 그것에 대해 onResize를 호출한다. 동작이 안되어서 문제
    virtual void onResize()
    {
        static_cast<Window>(*this).onResize();
        // SpecialWindow에서만 필요한 작업을 여기서 수행한다.
        ...
    }
}

Window에서 가상 함수를 정의하고 있고 그 가상 함수를 Window를 상속하는 SpecialWindow에서 재정의하는데, 재정의할 때 가상 함수에서 Window의 onResize()를 호출하는 부분이다. 이때 자기 자신 즉, SpecialWindow 객체를 Window로 캐스팅해서 Window의 onResize()를 부르고 있다.

하지만 이는 잘못된 코드이다. 왜냐하면 캐스팅을 할 때 *this의 사본이 임시 객체로 형성되어 Window::onResize를 호출하기 때문이다. 현재 객체가 호출하는 것이 아닌 임시 객체가 호출하는 방식이기 때문에 혹시라도 Window::onResize에서 객체를 수정하도록 되어있다면 반영되지 않을 것이다.

따라서 캐스팅을 사용하지 말고 다음과 같이 그냥 기본 클래스 버전을 호출하도록 만들면 된다.

class SpecialWindow: public Window
{
    public:
    virtual void onResize()
    {
        Window::onResize();
        ...
    }
    ...
};

 

dynamic_cast 연산자의 비용과 대안

상당수의 구현 환경에서 이 캐스팅 연산자는 느리다. 특히 어떤 구현 환경에서는 클래스 이름을 비교함으로써 이 연산자가 구현되었기 때문에 깊이가 4인 단일 상속 계통에 속한 어떤 객체에 적용할 때 strcmp가 최대 4번 불리며, 다중 상속일 경우는 그 정도가 더욱 심해진다. 따라서 수행 성능에 사활이 걸린 코드라면 특히 dynamic_cast에 주의를 놓지 말아야 한다.

 

하지만 dynamic_cast 연산자가 쓰고 싶을 때가 있다. 파생 클래스 객체임이 분명한 녀석이 있어서 이에 대해 파생 클래스의 함수를 호출하고 싶은데, 그 객체를 조작할 수 있는 수단으로 기초 클래스의 포인터 밖에 없을 경우가 그렇다. 이런 문제를 피해 가는 일반적인 방법으로는 두 가지를 들 수 있다.

 

첫 번째 방법

파생 클래스 객체에 대한 포인터(혹은 스마트 포인터. 항목 13 참조)를 컨테이너에 담아둠으로써 각 객체를 기본 클래스 인터페이스를 통해 조작할 필요를 아예 없애버리는 것이다.

Window  SpecialWindow 상속 계통에서 깜빡거리기(blink) 기능을 SpecialWindow객체만 지원하게 되어 있다면, 아래처럼 하지 말고

// 하지 말것!!
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();
}

이렇게 해보란 것이다.

typedef std::vector<std::tr1::shared_ptr<SpecialWindow>> VPSW;
VPSW winPtrs;
...
// dynamic_cast가 없다
for (VPSW::itertor iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
    (*iter)->blink();

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

 

두 번째 방법

원하는 조작을 가상 함수 집합으로 정리해서 기본 클래스에 넣어두면 Window에서 뻗어 나온 자손들을 전부 기본 클래스 인터페이스를 통해 조작할 수 있다.

지금은 blink 함수가 SpecialWindow에서만 가능하지만, 그렇다고 기본 클래스에 못 넣어둘 만한 것도 아니다. 그러니까, 아무것도 안 하는 기본 blink를 구현해서 가상 함수로 제공한다.

class Window
{
    public:
    // 기본 구현은 '아무 동작 안하기'
    // item 34에 가상함수의 기본 구현이 왜 안좋은 아이디어인지 확인할 수 있다
    virtual void blink() {}
    ...
};

class SpecialWindow: public Window
{
    public:
    // 이 클래스에서는 blink 함수가 특정한 동작 수행
    virtual void blink() {...}
    ...
};

// 이 컨테이너는 Windows에서 파생된 모든 타입의 객체(에 대한 포인터) 들을 담는다.
typedef std::vector<std::tr1::shared_ptr<Windows>> VPW;
VPW winPtrs;
...

for (VPW::iterator iter = winPtrs.begin(); iter != winptrs.end(); ++iter)
    // dynamic_cast 가 없다.
    (*iter)->blink();

 

폭포식(cascading) dynamic_cast는 피하자

class Window {...};
...     // 파생클래스가 정의됨
typedef std::vector<std::tr1::shared_ptr<Windows>> VPW;
VPW winPtrs;
...
for (VPW::iteraotr iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
{
    if (SpecialWindow1 *psw1 = dynamic_cast<SpecialWindow1*>(iter->get()))
    {...}
    else if (SpecialWindow2 *psw2 = dynamic_cast<SpecialWindow2*>(iter->get())) 
    {...}
    else if (SpecialWindow3 *psw3 = dynamic_cast<SpecialWindow3*>(iter->get())) 
    {...}
}

파생 클래스가 하나 추가되면 폭포식 코드에 계속해서 조건 분기문을 추가해야 하기 때문에 이런 형태의 코드를 보면 가상 함수 호출에 기반을 둔 어떤 방법이든 써서 바꿔 놓아야 한다.

 

정말 잘 작성된 C++ 코드는 캐스팅을 거의 쓰지 않는다.

캐스팅 역시, 그냥 막 쓰기에는 꺼림칙한 문법 기능을 써야 할 때 흔히 쓰이는 수단을 활용해서 처리하는 것이 좋다. 쉽게 말해 최대한 격리시키자는 것이다. 캐스팅을 해야 하는 코드는 내부 함수 속에 몰아 놓고, 그 안에서 일어나는 ‘천한’ 일들은 이 함수를 호출하는 외부에서 알 수 없도록 인터페이스로 막아두는 식으로 해결하면 된다.

 

요약

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