프로그래밍 공부
작성일
2024. 3. 8. 13:04
작성자
WDmil
728x90

10.3 타입과 캐스팅

우리는 C++의 기본 타입에 대해 알아보았고, 커스텀 타입을 정의하는 방법을 배웠다. 이번에는 typedef, 함수 포인터에 대한 typedef, 타입에일리어스, 그리고 캐스팅 같은 타입과 관련된 까다로운 부분을 살펴본다.


10.3.1 typedef

typedef를 이용하면 새롭게 타입을 만들지 않고, 이미 정의된 타입에 또 다른 이름을 부여할 수 있다. typedef로 어떤 타입에 대한 별명을 만든다고 생각해도 된다. 다음 코드는 int* 타입에 대해 IntPtr이라는 새로운 이름을 부여한다.

typedef int* IntPtr;

 

이렇게 새롭게 부여된 타입 이름으로 변수를 선언할 수 있다. 다음의 두 변수 선언은 모두 허용된다.

int* p1;
IntPtr p2;

 

typedef로 만들어진 새로운 타입 이름은 원래 타입과 서로 호환된다. 그래서 위에 선언된 두 변수는 다음과 같이 상호 대입하는 데 아무런 문제가 없다. 사실 두 타입은 완전히 동일하게 취급된다.

p1 = p2;
p2 = p1;

 

typedef가 이용되는 가장 흔한 상황은 원래 타입의 이름이 너무 거추장스러울 때다.

이런 상황은 템플릿을 이용할 때 많이 발생한다. 예를 들어 1장에서 소개한 std::vector 표준 라이브러리 클래스를 이용할 때, string 타입에 대한 vector를 선언하면 std::vector<std::string>과 같이 된다. vector는 템플릿 클래스이기 때문에 항상 템플릿 파라미터를 타입 이름에 지정해주어야 한다. 변수 선언 떄 뿐만 아니라 함수 파라미터나 리턴 타입으로 이용할 떄도 항상 std::vector<std::string>와 같이 써주어야 한다.

void processVector<const std::vector<std::string>& vec) { /* 생략 */ }
int main()
{
	std::vector<std::string> myVector;
    rreturn 0;
}

 

typedef를 이용하면 타입 이름을 더 간결하고 이해하기 쉽게 만들 수 있다.

typedef std::vector<Std::string> StringVector;
void processVector<const StringVector& vec) { /* 생략 */ }
int main()
{
	StringVector myVector;
    return 0;
}

 

typedef는 스코프 지정자를 포함할 수도 있다. 위 예에서 StringVector는 네임스페이스 std를 포함하고 있다.

 

STL에서는 짧은 타입 이름을 제공하기 위해 typedef를 많이 이용하고 있다. 예를 들어 string은 사실 다음과 같이 typedef로 정의되어 있다.

typedef basic_string<char, char_traits<char>, allocator<char>> string;

10.3.2 함수 포인터에 대한 typedef

typedef의 가장 난해한 활용처는 함수 포인터다. 사실 C++에는 virtual 메서드가 있기 때문에 함수 포인터를 이용할 일이 별로 없다. 하지만 어떤 경우에는 함수 포인터를 이용해야 한다.

 

가장 흔한 경우가 동적 연결 라이브러리(Dynamic Linked Libary(DLL))에 정의된 함수 포인터를 얻는 경우다. 다음 코드는 마이크로소프트 윈도우 DLL에서 함수 포인터를 얻어내는 예다.

 

예를 들어 동적 연결 라이브러리 함수 MyFunc()을 가지고 있다고 하자. 이 라이브러리를 로딩하여 함수 MyFunc()를 호출해야 한다. 윈도우에서는 LoadLibary()함수로 커널 API를 호출하여 런타임에 라이브러리를 로딩할 수 있다.

HMODULE lib = ::LoadLibrary(_T("library name"0));

 

이 함수의 호출 결과를 '라이브러리 핸들' 이라고 부른다. 만약 로딩에 실패했다면 NULL값이 리턴된다. 라이브러리에서 함수를 추출해내려면 그 함수의 프로토타입을 알아야 한다. MyFunc()함수의 프로토타입이 다음과 같이 세 파라미터 bool, int, char*를 받고 int값을 리턴한다고 하자.

int __stdcall MyFunc(bool b, int n, const char* p);

 

여기서

__stdcall은 마이크로소프트에 종속적인 지시자로 파라미터가 함수에 어떠헥 전달되고 메모리에서 정리되는지 규정한다.

 

이제 typedef를 이용해서 위 함수의 프로토타입에 대한 포인터를 짧은 이름으로 정의해보자.

typoedef int (__stdcall *MyFuncProc)(bool b, int n, const cahr* p);

 

다른 경우와 달리 함수 포인터에 대한 typedef는 상당히 난해하다. 새로 정의된 이름 (myFuncProc)은 위 구문 가운데에 숨어있다. 불행하게도 이러한 문법은 표준에 의해 정해져 있다.

 

라이브러리를 로딩하고 함수 포인터에 대한 typedef를 완료했다면 다음과 같이 함수 포인터 주소를 라이브러리로부터 얻어낼 수 있다.

MyFuncProc MyProc = ::GetProcAddress(lib, "MyFunc");

 

만약 이 작업이 실패하면 MyProc은 NULL값을 가지게 된다. 성공하면 다음과 같이 로딩한 함수를 호출할 수 있다.

MyProc(true, 3, "Hello world");

 

C프로그래머 라면 아래처럼 MyProc에 *을 붙여서 역참조 해야 하지 않나? 라고 생각할 수도 있을 것이다.

(*MyProc)(true, 3, "Hello world");

 

수십 년 전에는 맞는 말이지만 이제는 컴파일러가 똑똑해져서 자동으로 역참조가 수행된다.


10.3.3 타입 에일리어스

타입 에일리어스(type allases)가 typedef보다 이해하기가 더 쉬운 경우가 있다. 예를 들어 다음과 같은 typedef가 있다고 하자.

typedef int MyInt;

 

이것을 타입 에일리어스로 고쳐쓰면 다음과 같다.

using MyInt = int;

 

타입 에일리어스 기능은 함수 포인터 처럼 typedef가 복잡해질 떄 특히 유용하다. 에를 들어 char와 double 타입 파라미터를 받고 int 타입 리턴값을 가지는 함수 프로토타입에 대해 함수 포인터 타입을 정의하고 싶다고 하자. typedef를 이용하면 다음과 같다.

typedef int (*FuncType)(char, double);

 

 이러한 정의는 별명 FuncType이 typedef 구문 가운데에 오는 관계로 난해하다. 타입 에일리어스를 이용하면 다음과 같이 작성할 수 있다.

using FuncType = int (*)(char, double);

 

여기까지의 설명으로부터 타입 에일리어스가 단지 읽기 쉬운 typedef에 지나지 않는다고 생각할 수 있지만 그 이상의 역할이 있다. 기존의 typedef는 템플릿 클래스를 이용할 때 문제가 된다. 이 부분은 후 11장에서 서술한다.


10.3.4 캐스팅

기존에 괄호를 이용한 C 스타일 캐스팅을 C++에서도 그대로 사용할 수 있다. 하지만 C++에서는 네 가지 새로운 캐스팅 방법을 지원한다. static_cast, dynamic_cast, const_cast, reinterpret_cast 가 그것으로, 가능하면 기존의 캐스팅 방법 대신 이러한 새로운 캐스팅 방법을 이용하는 것이 좋다.

 

이러한 새로운 캐스팅 구문은 좀 더 많은 타입 검사를 해주기 때문에 코드를 문법적으로 더 안전하게 만들어준다.


10.3.4.1 const-cast

const_cast는 가장 이해하기 쉽다. cosnt 변수의 상수 속성을 없애고자 할 때 이용한다. 네 종류의 캐스팅 방법 중 상수 속성을 없애는 유일한 캐스팅 방법이다.

 

이상적으로는 cosnt_cast 를 이용할 일이 없는 것이 바람직하다. 아떤 변수가 const로 선언되었으면 const로 남아있어야 한다. 하지만 현실적으로는 const가 아닌 인자로 함수에 넘겨줘야 할 경우가 있다. 당연하지만 가장 올바른 방법은 전체 프로그램에서 const가 유지되게 하는 것이다.

 

하지만 서드 파티에서 제공한 라이브러리를 사용하는 등의 상황에서는 어쩔 수 없이 융통성을 발휘해야 할 수도 있다. 그렇게 하더라도 그 함수가 객체를 수정하지 않는다는 것을 확인한 다음에 해야한다.

 

만약 객체를 수정한다면, 융통성을 발휘할 것이 아니라 프로그램의 전체 구조를 다시 생각해보아야 한다.

 

다음은 const_cast를 이용하는 예다.

extern void ThirdPatryLibraryMethod(char* str);
void f(const char* str)
{
	ThirdPartyLibraryMethod(const_cast<cahr*>(str));
}

10.3.4.2 static_cast

static_cast는 언어에서 지원되는 가장 일반적인 타입 변환을 수행한다. 예를 들어 다음과 같이 int 변수를 이용해서 double 타입의 결괏값을 계산할 때 정수 나눗셈이 발생하지 않게 하려면 static_cast를 이용해서 int변수를 double로 변환한다.

 

C++에서 자동으로 전체 연산이 부동소수점 연산이 되도록 만들기 떄문에 static_cast만으로 충분하다.

int  i = 3;
int j = 4;

double result = static_cast<double>(i) / j;

 

커스텀 생성자가 변환 함수를 통해

정상적으로 허용되는 타입 간 변환을 명시적으로 하고 싶을 때도 static_cast를 이용할 수 있다.

 

예를 들어 클래스 A가 클래스 B의 객체를 인자로 받는 생성자를 가지고 있을 떄 static_cast를 이용해서 B 객체를 A 객체로 변환할 수 있다.

 

하지만 이런 상황은 대부분 컴파일러가 변환을 자동으로 수행해주기 때문에 꼭 static_cast를 이용하지 않아도 된다.

 

static_cast의 또 다른 사용처는 객체를 클래스 계층에서 아래쪽으로 다운캐스팅 할 떄다. 다음 예시를 보자.

class Base
{
	public:
	Base() {};
    virtual ~Base() {}
};
class Derived : public Base
{
	puiblic:
	Derived() {}
    virtual ~Derived() {}
};
int main()
{
	Base* b;
    Derived* d = new Derived();
    b = d;	// 클래스 계층 위쪽으로 업캐스팅될 필요가 없음
    d = static_cast<Derived*>(b);	// 클래스 계층 아래로 다운 캐스팅 되어야 함
    Base base;
    Derived derived;
    Base& br = derived;
    Derived& dr = static_cast<Derived&>(br);
    return 0;
}

 

이들 캐스팅은 포인터는 물론 참조형에도 적용할 수 있다. 단, 객체 자체는 이용할 수 없다.

 

static_cast는 런타임 검사를 하지 않는다는 것에 유의해야 한다. static_cast는 업캐스팅, 다운 케스팅 모두 할 수 있다.

즉, Base포인터를 Derived포인터로 캐스팅하거나 Base참조를 Derived참조로 캐스팅할 수 있지만, 계층 구조가 실제 런타임에도 유효한지는 검사하지 않는다. 예를 들어 다음 코드는 컴파일도 되고 실행도 되지만, 포인터 d의 이용은 객체의 범위를 벗어난 메모리 접근과 같은 예상할 수 없는 심각한 문제를 일으킨다.

 

Base* b = new Base();
Derived* d = static_cst<Derived*>(b);

 

런타임 타임 검사가 수행되는 안전한 캐스팅을 하려면 dynamic_cast를 사용해야 한다.

 

항상 static_cast가 허용되는 것은 아니다. 포인터나 객체를 전혀 상관없는 타입으로 캐스팅할 수는 없다. const타입을 non-const타입으로 캐스팅할 수도 없고, 포인터를 int로 캐스팅할 수도 없다. 기본적으로 타입 원칙에 비추어 비상식적인 캐스팅은 허용되지 않는다.


10.3.4.3 reinterpret_cast

reinterpret_cast는 static_cast보다 강력하지만 위험한 캐스팅 방법이다. reinterpret_cast를 이용하면

C++에서 통상적으로 허용되지 않지만 프로그래머가 의도한 특별한 상황에서 타입관 변환을 강제적으로 할 수 있다.

 

예를 들어 클래스 계층 등과 전혀 관계없는 다른 타입으로 변환할 수 있다. void*와 상호 변환이 가장 흔한 활용 예다. 비슷하게 전혀 관계없는 참조형 간에도 변환할 수 있다. void* 타입은 아무 타입과도 관련이 없고 단지 임의의 메모리 위치를 가리킬 뿐이다. 다음은 이러한 예 이다.

class X {};
class Y {};
int main()
{
	X x;
    Y y;
    X* xp = &x;
    Y* yp = &y;
    // 관계없는 클래스 포인터로 변환하기 위해 reinterpret_cast 필요
    // static_cast는 작동하지 않음.
    xp = reinterpret_cast<X*>(yp);
    // 포인터를 void포인터로 변환할 때는 캐스팅이 필요 없음.
    void* p = xp;
    // 관계없는 포인터 타입으로 변환하기 위해 reinterpret_cast 필요
    xp = reinterpret_cast<X*>(p);
    // 관계없는 클ㄹ재스 참조 타입으로 변환하기 위해 reinterpret_cast 필요
    // static_cast는 작동하지 않음
    X& xr = x;
    Y& yr = reinterpret_cast<Y&>(x);
    return 0;
}

 

reinterpret_cast는 타입 검사를 전혀 하지 않기 때문에 매우 주의해서 사용해야 한다.

 

reinterpret_cast를 이용하면 포인터를 int로 변환하는 일도 가능해진다. 하지만 이러한 변환은 버그를 유발한다. 많은 플랫폼에서, 특히 64bit 플랫폼에서는 포인터의 크기와 int의 크기가 다르다. 64bit 플랫폼은 포인터의 크기가 64bit지만, int는 32bit다. 만약 64bit포인터를 int로 변환하면 절반에 달하는 데이터가 없어져버린다.


10.3.4.4 dynamic_cast

dynamic_cast는 클래스 계층에 대한 런타임 타입 정보(RunTime TYpe informatiopn(RTTI))검사를 수행하여 해당 변환이 적합한 클래스 계층 간 이동인지 확인한다. dynamic_cast도 포인터와 참조 모두에 적용할 수 있다.

 

캐스팅이 부적합한 경우에는 null 포인터를 리턴하거나( 포인터 변수의 변환인 경우 ) bad_cast 익셉션을 발생시킨다.

 

런타임 타입 정보는 객체의 vtable에 저장된다. 이 때문에 dynamic_cast가 타입 검사를 하기 위해서는 클래스에 하나 이상의 virtual메서드가 있어야 한다. 만약 클래스에 vtable이 없으면 dynamic_cast를 이용할 때 컴파일 에러가 발생한다. 이 경우 에러 메시지가 친절하지 않기 떄문에 원인을 파악하기 어렵다. 

 

예를 들어 마이크로소프트 비주얼 C++에서는 다음과 같은 에러 메시지가 출력된다.

error C2683: 'dynamic_cast' : 'MyClass' is not a polymorphic type.

 

다음은 dynamic_cast의 이용 예를 들기 위한 클래스 정의다.

class Base
{
	public:
    	Base() {};
        virtual ~Base() {}
};
class Derived : public Base
{
	public:
    	Derived() {}
        virtual ~Derived() {}
};

 

다음 코드는 위 클래스 들에 대해 dynamic_cast를 올바르게 사용한 예다.

Base* b;
Derived* d = new Derived();
b = d;
d = dynamic_cast<Derived*>(b);

 

다음 코드는 dynamic_cast를 참조에 적용하고 있는데, 잘못된 다운 캐스팅이기 때문에 익셉션이 발생한다.

 

Base base;
Derived derived;
Base& br = base;
try {
	Derived& dr = dynamic_cast<Derived&>(br);
} catch (cosnt bad_cast&) {
	cout << "Bad cast!\n";
}

 

그런데 이러한 다운 캐스팅은 static_cast나 reinterpret_cast를 이용해도 똑같이 수행할 수 있다. dynamic_Cast를 이용할 때의 차이점은 런타임 검사를 한다는 것이다. static_cast나 reinterpret_cast는 오류가 있을 수 있는 상황에서도 강제적으로 캐스팅한다.


10.3.4.5 캐스팅 방법 정리

다음 표는 여러 가지 상황에서 사용되는 캐스팅 방법을 정리한 것이다.

상황 캐스팅 방법
cosnt 속성 제거 cosnt_cast
int, double 간 변환처럼 언어 자체에서 허용되는 변환을 명시적으로 수행 static_cast
커스텀 생성자나 변환 연산자가 구현되어 있을 떄의 명시적인 변환 static_cast
전혀 관계없는 두 객체 간의 변환 불가능
같은 클래스 계층에 속하는 서로 다른 클래스 객체의 포인터 간 변환 synamic_cast(권장) 또는 static_cast
같은 클래스 계층에 속하는 서로 다른 클래스 객체의 참조 간 변환 synamic_cast(권장) 또는 static_cast
전혀 관계없는 두 포인터 간의 변환 reinterpret_cast
전혀 관계없는 두 참조 간의 변호나 reinterpret_cast
함수 포인터 간의 변환 reinterpret_cast

 

728x90