C++ 에서 캐스팅이 왜 중요한지에 대해 알아보자

안녕하세요! 두두코딩 입니다 ✋
오늘은 항목27 캐스팅에 관한 내용에 대해 알아보겠습니다.

🖇 소스코드에 마우스를 올리고 copy 버튼을 누를 경우 더 쉽게 복사할 수 있습니다!

궁금한 점, 보안점 남겨주시면 성실히 답변하겠습니다. 😁
+ 감상평 댓글로 남겨주시면 힘이됩니다. 🙇

C++에서 캐스팅

C++은 “어떤 일이 있어도 타입 에러가 생기지 않도록 보장한다”라는 철학을 바탕으로 설계되어져있다.

이론적으로 C++ 프로그램은 일단 컴파일만 깔끔하게 끝나면 이후엔 어떤 객체에 대해서도 불안전한 연산이나 말도 안 되는 연산을 수행하지 않는 것을 보장한다.

C++에서는 이런 이론을 가볍게 뒤집는 동작이 있는데 그게 바로 캐스팅 동작이다.

C++에서는 캐스팅 동작을 정말 조심해서 사용해야되는데, 우선 캐스팅 하는 방법에 대해 알아보자.

C++ 캐스팅 방법

C++은 C 연산들을 품고 생성된 언어이다. 따라서, C에서도 가능한 캐스팅 동작을 수행할 수 있다. 즉, C++에서는 C 스타일 캐스팅 방법, C++ 스타일 캐스팅 방법 2가지로 나눠서 생각해야된다.

🌱 C 스타일 캐스팅

C 스타일 캐스팅 방법은 2가지이다.

// (T) 표현식
(double)1

// T(표현식)
double(1)

위 두가지 표현식으로 캐스팅을 한 예시는 아래와 같다.

#include <iostream>
#include <typeinfo>

using namespace std;

int main() {
	int i = 10;

	cout << typeid(i).name() << endl;

	// (T) 표현식 //
	cout << typeid((double)i).name() << endl;

	// T(표현식) //
	cout << typeid(double(i)).name() << endl;
}

🌱 C++ 스타일 캐스팅

C++ 스타일 캐스팅은 세부적인 기능단위인 총 4가지로 나뉜다.

const_cast<T>(표현식)

dynamic_cast<T>(표현식)

reinterpret_cast<T>(표현식)

static_cast<T>(표현식)

각 연산자 나름 이유가 있는데, 간단하게 언급하고 좀 더 자세히 알고 싶다면 여기를 클릭해 확인해보자.

const_cast - 객체의 상수성을 없애는 용도로 사용됨

dynamic_cast - “안전한 다운캐스팅” 을 할때 사용되는 연산자이다. 주어진 객체가 어떤 클래스 상속 계통에 속한 특정 타입인지를 결정할때 사용됨.

reinterpret_cast - 포인터를 기본타입으로 바꾸는 등의 하부 수준 캐스팅을 위해 만들어진 연산자이다. (구현 환경에 의존적이다.)

static_cast - 암시적 변환, 혹은 기본 타입의 강제 변환할때 사용된다. 흔히 타입변환 할때 많이 사용한다.

C++ 캐스팅을 써야하는 이유

C 스타일 같은 경우 “구형스타일” 이라고 불리며, 사용할 수 있으나 C++ 스타일 즉, “신형스타일” 캐스팅을 사용하는 것이 더 좋다. 구체적으로, 아래와 같이 2 가지 정도를 장점으로 생각할 수 있다.

첫째, 코드의 가독성이 좋아진다. 즉, 소스코드가 어디서 망가졌는지를 찾아보는 작업이 편리해진다.

둘째, 캐스트를 사용하는 목적을 더 좁혀서 지정하기 때문에 컴파일러가 에러진단을 할 수 있다. 만약 캐스팅을 잘못사용할 경우 컴파일 에러를 발생시킬 수 있다.

보통 사람들이 구형 스타일의 캐스팅을 많이 사용하는 경우는 객체를 인자로 넘기기 위해 명시호출 생성자를 호출하고 싶은 경우이다. 아래의 예시를 통해 알아보도록 하자.

class Widget {
public:
	explicit Widget(int size);
	...
};

void doSomeWork(const Widget& w);

// 구형스타일 캐스팅
// T(표현식)
doSomeWork(Widget(15));

// 신형스타일 캐스팅
doSomeWork(static_cast<Widget>(15));

본인도 해당 경우 구형스타일 캐스팅을 많이 사용한다. 왜냐하면 객체를 생성한다고 하면 캐스팅이라고 생각하지 않기 때문이다. 하지만 coredump가 잘 날 것 같은 코드에는 꼭 신형 스타일 캐스팅을 사용하도록 하자. 에러를 명확하게 볼 수 있을 것이다.

캐스팅 특징과 발생할 수 있는 문제

캐스팅 특징

우리가 보통 캐스팅을 한다고 하면 어떤 타입을 다른 타입으로 처리해라 라고 컴파일러에게 알려주는 것이라고 생각한다. 즉, 컴파일 처리하기 전 “이 타입으로 바꿔주세요.. “ 라고 생각한다. 하지만 캐스팅은 컴파일 타임 결정이 아닌 런타임에 실행되는 코드를 만든 경우가 많다.

아래의 코드를 보자.

void test() {
	int x, y;

	...

	// 실행시간에 double 형을 생성함.
	double d = static_cast<double>(x)/y;
}

int형을 double형으로 바꾸는 코드를 보자. test 함수가 컴파일을 수행하면서 double 형의 x를 만들지 않는다. 즉, 프로그램을 수행하면서 static_cast라는 키워드를 만날 경우 double 타입을 생성한다.

그 이유는 int형과 double형 아키텍트 구조가 완전 다르기 때문이다. 따라서, 사용 유무를 판단하지 못하는 경우에는 굳이 미리 만들어 두지 말자 라고 컴파일러는 생각한다.

캐스팅이 런타임에 동작하는 또다른 케이스를 확인해보자.

class Base { ... };

class Derived : public Base { ... };

Derived d;

Base *pb = &d;

위의 코드는 파생 클래스 객체에 대한 기본 클래스 포인터를 만드는 코드이다. 이 경우 Derived 객체에서 offset 계산해 실제 Base 객체의 포인터 값을 구하는 캐스팅 동작이 발생하는데, 이 동작이 런타임에 일어난다.

위 케이스를 통해 하나의 객체가 offset을 활용한 다양한 주소값을 가질 수 있다는 것을 알 수 있다. 이런 경우는 C++ 언어에서 발생하는데, 특히 다중 상속 케이스에서 많이 발생한다.

가끔 offset을 활용해 주소값을 구하는 경우가 있는데 이 경우는 굉장히 신중해야된다. 아키텍처 마다 메모리 배치 구조가 다르기 때문에 플랫폼마다 해당 코드가 동작할수도 안할수도 있다는 이야기가 된다. (이 부분은 특히 중요하게 생각하자)

캐스팅으로 인해 발생할 수 있는 문제

위에서 배운 특징을 활용한 캐스팅 동작에서 파생될 수 있는 문제를 생각해보자.

실제로는 맞는 것 같지만 틀린 코드를 사용할 수 있는 경우가 있다. 아래의 코드를 보자.

#include <iostream>

class Window {
public:
	void onSize() {
		x = 5;
	}

protected:
	int x = 0;
	int y = 0;
};

class SpecialWindow : public Window {
public:
	void onSize() {
		// 해당 동작이 잘 동작할까? That's nono..
		// 캐스팅은 동적으로 발생함. 따라서, 사본이 만들어짐.
		static_cast<Window>(*this).onSize();
		y = 5;
	}

	void print() {
		std::cout << " x  = " << x << " y = " << y << std::endl;
	}
};

int main() {
	SpecialWindow sw;
	sw.onSize();
	sw.print();
}

위의 코드는 파생클래스를 통해 기본클래스의 onSize를 접근해 x 값을 변경하고, 자체 onSize 함수에서 y 값을 변경해 x 값과 y 값이 각각 5 라는 값을 넣고 출력하는 코드이다.

여기서 우리가 주목할 코드는 static_cast<Window>(*this).onSize(); 코드이다. 이론상 보면 현재 객체 this 값을 기본 클래스인 Window로 캐스팅하고, 함수를 부는 것이라는 지극히 정상적인 동작이라 생각할 수 있다. 해당 동작은 Window의 onSize를 호출하는 아주 정상적인 동작이다.

하지만, 여기서 어처구니 없는 문제가 발생한다. 함수 호출이 이루어지는 객체가 현재의 객체가 아니기 때문에 문제가 발생한다. 즉, 캐스팅동작이 발생하게 되면 사본을 생성한다. 즉, *this*를 활용해 Window 객체의 사본 객체를 만들고, 해당 객체의 onSize를 부르게 된다.

결국 사본 객체의 Window::OnSize를 부르게 되고, 기존 객체의 x 값은 변동 없이 0이 출력되게 된다.

여기서 중요한 점은 캐스팅시 발생하는 동작은 임시 객체 즉, 사본을 생성한다는 점이다. 따라서, 해당 문제를 해결하기 위해서는 캐스팅을 제거해야한다. 그리고, 현재 객체에 대해 직접 기본 클래스의 onSize()를 호출하도록 해야한다.

...

void onSize() {
	// 기본 클래스를 직접 부르도록 하자.
	// 이 경우에는 캐스팅 사용 노노
	// 임시객체가 생성된다는 점을 잊지 말자!
	Window::OnSize();
	y = 5;
}

...

dynamic_cast로 발생할 수 있는 문제

dynamic_cast는 설계부터 말이 많았던 연산이다. 특히, dynamic_cast는 내부적으로 strcmp라는 함수를 활용해 문자열을 비교하도록 컴파일러에서 구현되어져 있기 때문에 조금만 depth가 생겨도 성능이 확 떨어진다.

따라서, dynamic_cast 연산자는 성능을 위해 최대한 사용하지 않는 것이 좋다.

하지만, dynamic_cast를 사용할 수 밖에 없는 경우가 존재하는데 예를 들면 파생 클래스의 함수를 호출하고 싶은 경우가 있는데 기본클래스 밖에 없는 경우를 예로 들 수 있다.

아래의 예시코드를 보도록 하자. SpcialWindow 객체의 blink()를 사용해야되는데 우리가 아는 정보는 Window 즉, 기본클래스에 대한 정보밖에 없다고하자. 이럴 경우 dynamic_cast 연산자를 활용하게 되는데, 성능 저하 이슈가 발생한다.

class Window { ... };

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

// 기반 클래스를 저장하기 위한 컨테이너
typeof std::vector<std::shared_ptr<Window> > VPW;

VPW windPtrs;

...

for (VPW::iterator iter = winPts.begin();
		 iter != winPtrs.end();
		 ++iter) {
	if (SpecialWindow* psw = dynamic_cast<SpecialWindow*>(iter->get()))
		psw->blink();
}

성능저하 문제를 해결하기 위해서는 2가지 방법이 있다.

파생 클래스 객체에 대한 포인터 저장

파생클래스에 대한 객체 포인터를 컨테이너에 저장해 기본 클래스에서 파생클래스로 캐스팅하는 동작을 아예 없애버리는 방법이다.

...

typeof std::vector<std::shared_ptr<SpecialWindow> > VPSW;

VPSW windPtrs;

...

for (VPSW::iterator iter = winPts.begin();
		 iter != winPtrs.end();
		 ++iter) {
	  // dynamic_cast 키워드 제거가능
		(*iter)->blink();
}

즉, 위의 코드와 같이 Window라는 기본 객체를 저장하는 컨테이너가 아닌 SpecialWindow를 저장하는 컨테이너를 생성한 후 해당 데이터를 담아두고, 필요할 때 사용하는 방법이다. 이렇게 할경우 dynamic_cast를 제거할 수 있다.

하지만 위와 같은 방법을 사용할 경우 Window파생클래스가 생성될 때 마다 모든 객체를 관리할 수 있는 컨테이너를 생성해야되는 문제가 있다.

이를 막기 위해서는 아래의 방법을 이용해보자.

가상함수를 추가한다.

우리가 필요한 함수를 기본 클래스의 가상함수로 만들어 파생클래스에 구축하도록 하는 것이다. 기본 클래스에서 동작하는 가상함수 같은 경우 아무 동작을 하지 않도록 하는 것이 핵심이다.

class Window {
public:
	virtual void blink();
};

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

// 기반 클래스를 저장하기 위한 컨테이너
typeof std::vector<std::shared_ptr<Window> > VPW;

VPW windPtrs;

...

for (VPW::iterator iter = winPts.begin();
		 iter != winPtrs.end();
		 ++iter) {
		(*iter)->blink();
}

위와 같이 virtual 키워드를 활용해 필요한 함수를 기본 클래스에서 사용할수록 한다면 dynamic_cast를 사용하지 않을 수 있다.

위 2가지 방법으로 모든 경우를 막을 수는 없지만 꽤 많이 막을 수 있으니, dynamic_cast 보다 2가지 방법을 활용해 성능저하를 막도록 하자.

이것만은 피해줘!!

dynamic_cast를 어쩔수 없이 사용해야된다면 아래와 같은 구현 방식 즉, 폭포수 모델과 같은 방식은 정말 피해야한다.

class Windw { ... };

...

typeof std::vector<std::shared_ptr<Window> > VPW;

VPW windPtrs;

...

for (VPW::iterator iter = winPts.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())) { ... }
		...
}

위와 같은 폭포수 dynamic_cast 사용은 가히 성능에 재앙이 될 수 있으니, 무조건 지양하기 바란다.

캐스팅 동작은 최대한 자제하는 것이 좋고, 사용해야 된다면 최대한 격리해서 사용하도록 하자.

기억할 점

👉 dynamic_cast 사용은 지양하고, 해야한다면 피할 수 있는 대책을 먼저 찾자

👉 캐스팅이 어쩔 수 없이 필요하다면 함수 내로 숨기자.

👉 구형스타일 보다는 신형 스타일 즉, C++에서 새롭게 만든 캐스팅 기법을 사용하자.

Reference

Effective C++