C++에 dynamic_cast에 의존하지 않고, 엔진에서 직접 타입 확인과 형변환을 처리하는 커스텀(RTTI) 시스템 구현
핵심은 모든 엔진 객체의 공통 부모 클래스인 CraftObject에 타입 ID 체계를 만들고, 상속 관계를 따라 타입을 확인할 수 있도록 하는 것이다.
1. 커스텀 RTTI가 필요한 이유
일반 C++에서는 dynamic_cast를 사용해 객체의 실제 타입을 확인할 수 있다.
TestActor* testActor = dynamic_cast<TestActor*>(actor);
하지만 엔진에서는 다음과 같은 이유로 자체 타입 시스템을 사용한다.
- C++ RTTI 사용 여부를 직접 제어할 수 있음
- 엔진 객체의 타입 확인 방식을 통일할 수 있음
- 상속 구조에 맞는 타입 검사 로직을 직접 설계할 수 있음
- 엔진의 캐스팅 규칙을 명확하게 관리할 수 있음
2. CraftObject 설계
모든 타입 시스템 객체의 최상위 부모로 CraftObject를 추가했다.
class CRAFT_API CraftObject
{
public:
virtual size_t GetType() const = 0;
virtual bool Is(size_t id) const
{
return false;
}
template<typename T>
bool IsTypeOf() const
{
return Is(T::TypeId());
}
};
각 함수의 역할
- GetTYpe() : 현재 객체의 정확한 타입 ID 반환
- Is(size_t id) : 현재 타입 또는 부모 타입인지 확인
- IsTypeOf<T>() : 템플릿을 이용한 타입 확인
// 예시
if (actor->IsTypeOf<TestActor>())
{
// TestActor 타입인 경우
}
3. shared_ptr 기반 Cast 구현
타입을 확인한 뒤 안전하게 static_pointer_cast 를 수행하는 캐스팅 함수 추가
template<typename T, typename U>
std::shared_ptr<T> Cast(const std::shared_ptr<U>& object)
{
if (!object)
{
return nullptr;
}
if (object->Is(T::TypeId()))
{
return std::static_pointer_cast<T>(object);
}
return nullptr;
}
std::static_pointer_cast : C++에서 std::shared_ptr 객체의 타입을 컴파일 타임에 안전하게 변환(캐스팅)해 주는 함수
// 예시
std::shared_ptr<TestActor> testActor = actor->Cast<TestActor>(actor);
if (testActor != nullptr)
{
// 캐스팅 성공
}
먼저 Is() 로 타입을 확인하기 때문에, 타입이 다르면 nullptr 반환
4. TYPE_DECLARATIONS 매크로
각 클래스에 필요한 타입 관련 함수를 반복해서 작성하지 않도록 매크로 추가
#define TYPE_DECLARATIONS(Type, ParentType) \
using super = ParentType; \
protected: \
static size_t TypeIdClass() \
{ \
static int runtimeTypeId = 0; \
return reinterpret_cast<size_t>(&runtimeTypeId); \
} \
public: \
static size_t TypeId() \
{ \
return Type::TypeIdClass(); \
} \
virtual size_t GetType() const override \
{ \
return Type::TypeIdClass(); \
} \
virtual bool Is(size_t id) const override \
{ \
return (id == TypeIdClass()) ? true : ParentType::Is(id); \
}
매크로가 생성하는 주요 기능
- 부모 타입 별칭 생성
- 클래스별 타입 ID 생성
- 현재 객체의 타입 반환
- 현재 타입 또는 부모 타입 여부 확인
5. 타입 ID 생성 방식
각 클래스의 TypeIdClass() 에는 정적 지역 변수가 있다.
static int runtimeTypeId = 0;
return reinterpret_cast<size_t>(&runtimeTypeId);
정적 지역 변수는 프로그램 실행 중 하나만 생성되며, 클래스별 함수에 각각 존재한다.
따라서 각 클래스의 runtimeTypeId 주소가 서로 달라지고, 그 주소를 타입ID로 사용할 수 있다.
이 타입 ID는 파일이나 네트워크에 저장하는 값이 아니라, 프로그램 실행 중 타입을 구분하기 위한 임시 식별자다.
6. 상속 체인에서 Is 동작 방식
Actor는 다음과 같이 시스템을 적용한다.
class Actor : public CraftObject
{
TYPE_DECLARATIONS(Actor, CraftObject)
};
TestActor는 Actor 를 부모로 지정
class TestActor : public Actor
{
TYPE_DECLARATIONS(TestActor, Actor)
};
TestActor의 Is()는 먼저 자신의 타입 ID를 확인한다.
if (id == TestActor::TypeId())
{
return true;
}
자신의 타입이 아니라면 부모인 Actor::Is(id) 에 확인을 위임한다.
이 구조로 TestActor 객체는 다음 타입 질문에 모두 True를 반환하게 된다.
testActor->IsTypeOf<TestActor>();
testActor->IsTypeOf<Actor>();
7. super 별칭
매크로에는 부모 클래스 super라는 이름으로 사용할 수 있도록 별칭을 추가
using super = ParentType;
실제 사용 예시
void TestActor::Tick(float deltaTime)
{
super::Tick(deltaTime);
}
Unreal Engine의 GENERATED_BODY()가 생성하는 Super 별칭과 비슷한 사용 방식이지만,
이번 강의의 super는 직접 만든 일반 C++ 매크로 기능이다.
8. TYPE_DECLARATIONS를 사용하지 않으면?
파생 클래스에 매크로를 추가하지 않으면 해당 클래스의 타입 정보가 등록되지 않는다.
class TestActor : public Actor
{
// TYPE_DECLARATIONS가 없음
};
그러면 Actor 정보만 사용하게 된다.