KoreanFoodie's Study

[C++ 게임 서버] 2-10. TypeCast 본문

Game Dev/Game Server

[C++ 게임 서버] 2-10. TypeCast

GoldGiver 2023. 9. 19. 12:36

Rookiss 님의 '[C++과 언리얼로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버' 를 들으며 배운 내용을 정리하고 있습니다. 관심이 있으신 분은 꼭 한 번 들어보시기를 추천합니다!

[C++ 게임 서버] 2-10. TypeCast

핵심 :

1. 템플릿 메타 프로그래밍(TMP)로 TypeCast 를 구현해 보자. static_cast 를 이용해 dynamic_cast 의 효과를 낼 수 있다!

2. TMP 를 사용하면, 컴파일 타임에 타입과 관련된 값을 미리 알 수 있다. 

3. TypeCast 가 가능한지 여부를 행렬에 저장하여 사용할 것이므로 속도가 매우 빠르며, shared_ptr 에도 사용할 수 있다!

제목은 TypeCast 지만, 오늘은... 즐거운 흑마법 시간이다!

해당 이미지와 본문은 어떠한 연관도 없습니다

흑마법... 아니, 템플릿 메타 프로그래밍(Template Meta Programming; 줄여서 TMP 라고 할 것)으로, 다양한 타입 간의 Casting 을 직접 만들어 볼 것이다.

왜.. 그런 것을 하냐고? 왜냐니... 그야...

라는 이유도 있지만, 사실 좀 더 그럴듯한 이유도 있다.

예를 들어, 우리가 RPG 게임을 만든다고 해 보자. 그럼 타입으로 Player 도 있고, Mage 도 있고, Knight 도 있고... Monster 도 있을 것이다.

이때, Player 는 Mage 와 Knight 의 부모 클래스이므로, Mage 와 Knight 에서 Player 로의 Casting 은 유효해야 한다. 하지만 그 반대는 성립하면 안되며, Player 에서 Monster 로의 변환도 성립하면 안 될 것이다.

그냥 dynamic_cast 를 쓰면 해결되는 문제가 아니냐? 라고 할 수 있지만... 그러려면 RTTI 를 써야 하는데, 일단 dynamic_cast 는 느리다는 단점이 있다. RTTI 를 키면 좀 무거워지기도 하고.

그래서 실제로는 static_cast 를 쓰는데... 그럴때 TypeCast 템플릿을 만들어 놓으면, static_cast 로 마치 dynamic_cast 를 하는 것 같은 효과를 낼 수 있다.

그래서 흑마법이라는 것이다 😂

 

이제 잡소리가 길었으니, 코드를 보자. 일단 흑마법 기초를 다시 되새기면서 시작하겠다 😁

#pragma region TypeList
template<typename... T>
struct TypeList;

template<typename T, typename U>
struct TypeList<T, U>
{
	using Head = T;
	using Tail = U;
};

template<typename T, typename... U>
struct TypeList<T, U...>
{
	using Head = T;
	using Tail = TypeList<U...>;
};
#pragma endregion

일단, 타입리스트를 위와 같이 정의할 것이다. Variadic Template 을 이용해, 리스트를 컴파일 타임에 만들어 낼 것이다.

대충 아래와 같이 사용할 수 있다 :

TypeList<Mage, Knight>::Head whoAMI;
TypeList<Mage, Knight>::Tail whoAMI2;

TypeList<Mage, TypeList<Knight, Archer>>::Head whoAMI3;
TypeList<Mage, TypeList<Knight, Archer>>::Tail::Head whoAMI4;
TypeList<Mage, TypeList<Knight, Archer>>::Tail::Tail whoAMI5;

TMP 가 좋은 점은, 컴파일 타임에 연산을 하므로 에디터 상에서도 값을 확인할 수 있다는 것이다.

 

리스트에서 보통 사용하는 게 길이, Index 조회, Find 정도이니, 우리도 마찬가지로 아래와 같이 구현할 것이다 :

#pragma region Length
template<typename T>
struct Length;

template<>
struct Length<TypeList<>>
{
	enum { value = 0 };
};

template<typename T, typename... U>
struct Length<TypeList<T, U...>>
{
	enum { value = 1 + Length<TypeList<U...>>::value };
};
#pragma endregion

#pragma region TypeAt
template<typename TL, int32 index>
struct TypeAt;

template<typename Head, typename... Tail>
struct TypeAt<TypeList<Head, Tail...>, 0>
{
	using Result = Head;
};

template<typename Head, typename... Tail, int32 index>
struct TypeAt<TypeList<Head, Tail...>, index>
{
	using Result = typename TypeAt<TypeList<Tail...>, index - 1>::Result;
};
#pragma endregion

#pragma  region IndexOf
template<typename TL, typename T>
struct IndexOf;

template<typename... Tail, typename T>
struct IndexOf<TypeList<T, Tail...>, T>
{
	enum { value = 0 };
};

template<typename T>
struct IndexOf<TypeList<>, T>
{
	enum { value = -1 };
};

template<typename Head, typename... Tail, typename T>
struct IndexOf<TypeList<Head, Tail...>, T>
{
private:
	enum { temp = IndexOf<TypeList<Tail...>, T>::value };

public:
	enum { value = (temp == -1) ? -1 : temp + 1 };
};
#pragma endregion

각각 Lenght, TypeAt, IndexOf 라는 이름으로 구현했다.

 

실제 예시는 아래와 같다 :

int32 len1 = Length<TypeList<Mage, Knight>>::value;
int32 len2 = Length<TypeList<Mage, Knight, Archer>>::value;

using TL = TypeList<class Player, class Mage, class Knight, class Archer>;

// 3*3 
TypeAt<TL, 0>::Result whoAMI6;
TypeAt<TL, 1>::Result whoAMI7;
TypeAt<TL, 2>::Result whoAMI8;

int32 index1 = IndexOf<TL, Mage>::value;
int32 index2 = IndexOf<TL, Archer>::value;
int32 index3 = IndexOf<TL, Dog>::value;

위에서, TL 의 경우 Player, Mage, Knight, Archer 로 이루어진 4 개의 타입 리스트를 의미한다.

 

그럼 이제 본격적으로 타입 캐스팅을 위한 준비를 해 보자.

#pragma region Conversion
template<typename From, typename To>
class Conversion
{
private:
	using Small = __int8;
	using Big = __int32;

	static Small Test(const To&) { return 0; }
	static Big Test(...) { return 0; }
	static From MakeFrom() { return 0; }

public:
	enum
	{
		exists = sizeof(Test(MakeFrom())) == sizeof(Small)
	};
};

먼저 Conversion 이다. 음.. 결국 중요한 건 exists 쪽인데, MakeFrom() 을 잘 보자. 이 녀석은 반환형이 From 인데, Test 를 보면 To 를 인자형으로 받는다.

이게 뭐지? 싶지만, From 에서 To 로의 변환이 가능하면 (static_cast 방식으로), Test 의 결과가 Small 타입을 반환할 것이고, 그게 아니라면 Big 타입을 반환할 것이다. "(...)" 이 붙어 있는 것은, To 이외의 모든 경우를 체크하겠다는 것과 같다. 대신 우선순위는 To 가 먼저 가져가지만.

어쨋든, Small 과 Big 의 타입은 각각 __int8, __int32 이니, sizeof 를 통해 비교하면 무조건 다를 것이다. 즉, Test 의 반환형이 Small 이면 Conversion 이 가능하고, Big 이면 Conversion 이 불가능하다는 결과를 유추할 수 있다.

예시를 보면...

bool canConvert1 = Conversion<Player, Knight>::exists;
bool canConvert2 = Conversion<Knight, Player>::exists;
bool canConvert3 = Conversion<Knight, Dog>::exists;

처럼 사용을 하는데, 마우스 오버를 해 보면 이미 값이 계산된 것을 알 수 있다.

Player 에서 Knight, 혹은 Knight 에서 Dog 로의 변환은 exists 값이 0 이 될 것이다.

 

그럼.. 이제 대망의 타입 캐스팅 쪽을 보자.

template<int32 v>
struct Int2Type
{
	enum { value = v };
};

template<typename TL>
class TypeConversion
{
public:
	enum
	{
		length = Length<TL>::value
	};

	TypeConversion()
	{
		MakeTable(Int2Type<0>(), Int2Type<0>());
	}

	template<int32 i, int32 j>
	static void MakeTable(Int2Type<i>, Int2Type<j>)
	{
		using FromType = typename TypeAt<TL, i>::Result;
		using ToType = typename TypeAt<TL, j>::Result;

		if (Conversion<const FromType*, const ToType*>::exists)
			s_convert[i][j] = true;
		else
			s_convert[i][j] = false;

		MakeTable(Int2Type<i>(), Int2Type<j + 1>());
	}

	template<int32 i>
	static void MakeTable(Int2Type<i>, Int2Type<length>)
	{
		MakeTable(Int2Type<i + 1>(), Int2Type<0>());
	}

	template<int j>
	static void MakeTable(Int2Type<length>, Int2Type<j>)
	{
	}

	static inline bool CanConvert(int32 from, int32 to)
	{
		static TypeConversion conversion;
		return s_convert[from][to];
	}

public:
	static bool s_convert[length][length];
};

template<typename TL>
bool TypeConversion<TL>::s_convert[length][length];

일단, 여러모로.. 타입 리스트를 이용해 타입 테이블(?)을 만들어 주기 위해 TypeConversion 클래스를 정의했다.

잘 보면, MakeTable 을 통해 타입을 변환가능한지 여부를 s_convert 행렬에 저장을 한다. MakeTable 이 여러 버전이 있는데, 이는 for-loop 을 TMP 방식으로 만들기 위해 오버로딩 가능한 여러 버전을 만든 것이다.

또한 for-loop 을 돌기 위해 템플릿 타입을 마치 숫자인 것처럼 변환을 해 주어야 하는데, 이를 위해 Int2Type 템플릿 클래스를 만들었다. 이 녀석은 그냥 값을 타입처럼 사용할 수 있게 도와준다. 이제, TypeConversion 클래스의 CanConvert 함수를 활용해 캐스팅 가능 여부를 체크할 것이다 😉

 

이제 진짜 마지막이다. TypeCast 를 보자.

template<typename To, typename From>
To TypeCast(From* ptr)
{
	if (ptr == nullptr)
		return nullptr;

	using TL = typename From::TL;

	if (TypeConversion<TL>::CanConvert(ptr->_typeId, IndexOf<TL, remove_pointer_t<To>>::value))
		return static_cast<To>(ptr);

	return nullptr;
}

template<typename To, typename From>
bool CanCast(From* ptr)
{
	if (ptr == nullptr)
		return false;

	using TL = typename From::TL;
	return TypeConversion<TL>::CanConvert(ptr->_typeId, IndexOf<TL, remove_pointer_t<To>>::value);
}

TypeCast 는, 우리가 쌓아올린 CanConvert 를 통해 static_cast 를 수행한다. 물론, 이 녀석은 실질적으로는 dynamic_cast 와 거의 흡사하게 동작한다.

CanCast 는, 캐스팅이 가능한지 여부만을 체크해서 bool 값을 리턴한다.

그런데 코드를 잘 보면... ptr->_typeId 가 있다. 즉, 우리가 구현하고자 하는 녀석들의 클래스 내부에는 _typeId 가 선언되어 있어야 한다.

매번 선언하기 귀찮으니, #define 을 활용하자 :

#define DECLARE_TL		using TL = TL; int32 _typeId;
#define INIT_TL(Type)	_typeId = IndexOf<TL, Type>::value;

 

그럼 이제 실제 클래스에는 다음과 같이 쓰면 된다! 😄

using TL = TypeList<class Player, class Mage, class Knight, class Archer>;

class Player
{
public:
	Player()
	{
		INIT_TL(Player);
	}
	virtual ~Player() { }

	DECLARE_TL
};

class Knight : public Player
{
public:
	Knight() { INIT_TL(Knight); }
};

class Mage : public Player
{

public:
	Mage() { INIT_TL(Mage); }
};

class Archer : public Player
{

public:
	Archer() { INIT_TL(Archer) }
};

 

실제 예제 코드는 다음과 같다 :

Player* player = new Knight();

bool canCast = CanCast<Knight*>(player);
Knight* knight = TypeCast<Knight*>(player);


delete player;

 

아참, shared_ptr 도 TypeCast 를 할 수 있다. 아래와 같이 shared_ptr 를 위한 템플릿 함수를 추가하면 된다! 😮

template<typename To, typename From>
shared_ptr<To> TypeCast(shared_ptr<From> ptr)
{
	if (ptr == nullptr)
		return nullptr;

	using TL = typename From::TL;

	if (TypeConversion<TL>::CanConvert(ptr->_typeId, IndexOf<TL, remove_pointer_t<To>>::value))
		return static_pointer_cast<To>(ptr);

	return nullptr;
}

template<typename To, typename From>
bool CanCast(shared_ptr<From> ptr)
{
	if (ptr == nullptr)
		return false;

	using TL = typename From::TL;
	return TypeConversion<TL>::CanConvert(ptr->_typeId, IndexOf<TL, remove_pointer_t<To>>::value);
}

shared_pointer 변환에는 static_cast 가 아닌, static_pointer_cast 를 사용함에 유의하자(C++ Native 에서는). 😀

 

예제는 비슷하게 나온다 🤣

shared_ptr<Player> player = MakeShared<Knight>();

shared_ptr<Archer> archer = TypeCast<Archer>(player);
bool canCast = CanCast<Mage>(player);
Comments