UE5 Cast<ClassType>(...) 은 빠를까?

에크까망·2024년 9월 20일

들어가기 전에

  • 단순화 하기 위해서 문체를 단정적으로 사용하지만 지극히 개인 의견일 뿐 입니다.
  • 본문과 관련해서 오류 지적이나 의견 있으시면 꼭 댓글 부탁드립니다.
  • 글 작성 시점은 2024/09 입니다.
  • 글 작성 기준은 UE5.4.4 입니다.

들어가며

  • 약간의 C++ 언어 사전 지식이 있는 것으로 가정 합니다.
  • UObject-UClass 구조는 다음에 다시 다룹니다.
  • UnrealEngine Configuration (in Editor or Not) 은 다음에 다시 다룹니다.

Class Type Casting

보통 Class Type Casting 은 Downcasting 이 안전한지 확인하고 타입을 변환하는 작업이다.

아래 코드에서 GetController()AController* 를 Return 하며 해당 class pointer(instance) 가 APlayerController 를 구현 하고 있는지(동일하거나 자식이거나) 확인하고 맞다면 적절한 pointer 를 반환한다.

APlayerController* PlayerController = Cast<APlayerController>(GetController())

Class Upcasting 은 Compile-Time 에서 바로 확인이 되지만, Downcasting 은 그렇지 못하다. 따라서 해당 Object(Class Instance)에 대한 타입 정보를 제공 받든지 또는 특정 구현이 필요하다.

C++ 이외의 언어 에서의 RTTI

C++ 이외에 요즘 널리 사용되는 언어는 거의 모두 Memory Managed 언어 이며 Framework 에서 Garbage Collection 과 Reflection 을 지원 하고 있다. 또한 이런 언어들은 기본적으로 Run-Time Type Information(이하 RTTI)을 제공한다. 따라서 다른 언어들은 대부분 RTTI 사용 여부가 선택사항이 아니며 기본 제공 된다. 고민할 필요도, 선택권도, 구현도 필요 없다.

C++ 에서의 RTTI

반면 C++ 에서의 RTTI 는 선택사항이다. 선택적으로 사용여부를 컴파일 타임에 지정할 수 있다. 있으면 무조건 좋은데 왜 선택적으로 할까? 이유는 RTTI 가 공짜가 아니기 때문이다.

Visual Studio 2022 C++ 에서 RTTI 옵션은 아래에 있다.

옵션을 키면 다음과 같이 사용할 수 있다

dynamic_cast

class Base {
    virtual void foo() {}  // 반드시 가상 함수를 포함해야 RTTI 사용 가능
};

class Derived : public Base {
};

Base* basePtr = new Derived();
Derived* derivedPtr = dynamic_cast<Derived*>(basePtr);

if (derivedPtr) {
    // 성공적인 캐스팅
} else {
    // 캐스팅 실패
}

typeid

Base* basePtr = new Derived();
std::cout << typeid(*basePtr).name() << std::endl; // Derived 클래스 이름 출력

비용 문제

C++ 에서 RTTI 를 사용하면 virtual-function-table 기반으로 추가 정보가 필요하며 이는 오버헤드 로 작용한다. dynamic_cast 또한 static_cast 에 비해 느리다. 골치아픈 C++ 을 사용한다는건 이 마저도 아깝기 때문일 것이다.

UE5 에서의 RTTI

언리얼엔진 에서는 C++ RTTI 가 꺼져 있으며, 특정 Module 에서 키고 싶다면 module.build.cs 에서 bUseRTTI=true; 로 켜야 한다.

그러면 위에서 본 Cast<APlayerController>(GetController()) 가 어떻게 구현되어 있는지 살펴보자.

Cast.h

UE_5.4/Engine/Source/Runtime/CoreUObject/Public/Templates/Casts.h

template <typename Type>
struct TCastFlags
{
	static const EClassCastFlags Value = CASTCLASS_None;
};

// Dynamically cast an object type-safely.
template <typename To, typename From>
FORCEINLINE To* Cast(From* Src)
{
	static_assert(sizeof(From) > 0 && sizeof(To) > 0, "Attempting to cast between incomplete types");

	if (Src) // 'Mark A'
	{
		if constexpr (TIsIInterface<From>::Value) // Mark B
		{
			if (UObject* Obj = Src->_getUObject())
			{
				if constexpr (TIsIInterface<To>::Value)
				{
					return (To*)Obj->GetInterfaceAddress(To::UClassType::StaticClass());
				}
				else
				{
					if constexpr (std::is_same_v<To, UObject>)
					{
						return Obj;
					}
					else
					{
						if (Obj->IsA<To>())
						{
							return (To*)Obj;
						}
					}
				}
			}
		}
		else if constexpr (UE_USE_CAST_FLAGS && TCastFlags<To>::Value != CASTCLASS_None) // Mark C
		{
			if constexpr (std::is_base_of_v<To, From>)
			{
				return (To*)Src;
			}
			else
			{
#if UE_ENABLE_UNRELATED_CAST_WARNINGS
				UE_STATIC_ASSERT_WARN((std::is_base_of_v<From, To>), "Attempting to use Cast<> on types that are not related");
#endif
				if (((const UObject*)Src)->GetClass()->HasAnyCastFlag(TCastFlags<To>::Value))
				{
					return (To*)Src;
				}
			}
		}
		else
		{
			static_assert(std::is_base_of_v<UObject, From>, "Attempting to use Cast<> on a type that is not a UObject or an Interface");

			if constexpr (TIsIInterface<To>::Value)
			{
				return (To*)((UObject*)Src)->GetInterfaceAddress(To::UClassType::StaticClass());
			}
			else if constexpr (std::is_base_of_v<To, From>)
			{
				return Src;
			}
			else
			{
#if UE_ENABLE_UNRELATED_CAST_WARNINGS
				UE_STATIC_ASSERT_WARN((std::is_base_of_v<From, To>), "Attempting to use Cast<> on types that are not related");
#endif
				if (((const UObject*)Src)->IsA<To>()) // Mark D
				{
					return (To*)Src;
				}
			}
		}
	}

	return nullptr;
}

위 코드는 Cast(Src) 가 실제로 작동되는 Code 이다. 위 코드를 이해하기 위해서는 약간의 C++ template 지식과 UObject-UClass 의 관계, 상속 구조 상에서의 UClass Instance 끼리의 관계를 알고 있어야 한다. 또, UnrealEngine 에서 Interface 를 다루는 방법도 어느 정도 알고 있어야 한다.

UObject-UClass 의 관계는 다른 포스팅에서 추가로 살펴보기로 하고, 여기서는 Interface 와의 형변환은 살짝 미뤄둔채 다음 내용을 숙지 하자.

  • class UMyObject : public UObject 를 선언하면 Module이 로딩될떄 UClass-Instance 가 생성된다.
  • UMyObjectAUMyObjectB 모두 UClass TypeUClass 로 동일하고 Instance 가 각각 만들어 진다.
  • std::is_base_of_v<To, From> 는 To 가 From 의 부모 Class 인지 확인하는 C++17 이후의 표준 기능이다.
  • constexpr 은 상수 취급할 수 있는 곳에서 compile-time 에서 확인후 Code 자체를 생성/제거 할때 주로 사용된다. C++ 일반 const 와는 다르다. 일반 const 는 읽기 전용이라는 의미에 가깝다.
  • UnrealEngine 은 Interface 를 제외한 다중상속을 허용하지 않는다

그럼 주요 부분을 살펴보자

Mark A

if (Src)

Casting 대상이 되는 object 의 pointer 가 nullptr 인지 확인한다. nullptr 이면 추가 동작 없이 nullptr 을 반환하면 된다.

Mark B

if constexpr (TIsIInterface<From>::Value)

Casting 대상이 되는 object 가 TInterface 인지 확인한다음, Interface 이면 내부 동작을 수행한다. Interface 라도 내부적으로는 해당 Interface가 가르키는 Object 의 Class 를 타고 올라가면서 대상 Interface 를 구현하고 있는지 확인하는 것 외에는 크게 다르지 않다. 다음 기회에 자세히 다뤄 보기로 한다.

Mark C

else if constexpr (UE_USE_CAST_FLAGS && TCastFlags<To>::Value != CASTCLASS_None)

아주 재미있는 부분이다. 먼저 UE_USE_CAST_FLAGS 를 살펴보자

#define UE_USE_CAST_FLAGS (USTRUCT_FAST_ISCHILDOF_IMPL != USTRUCT_ISCHILDOF_STRUCTARRAY)
#if UE_EDITOR
	// On editor, we use the outerwalk implementation because BP reinstancing and hot reload
	// mess up the struct array
	#define USTRUCT_FAST_ISCHILDOF_IMPL USTRUCT_ISCHILDOF_OUTERWALK
#else
	#define USTRUCT_FAST_ISCHILDOF_IMPL USTRUCT_ISCHILDOF_STRUCTARRAY
#endif

즉, Editor 에서 UE_USE_CAST_FLAGS 가 True 이고 TCastFlags::Value 를 확인한다.
TCastFlags::Value

UE_5.4/Engine/Source/Runtime/CoreUObject/Public/UObject/ObjectMacros.h

enum EClassCastFlags : uint64
{
	CASTCLASS_None = 0x0000000000000000,

	CASTCLASS_UField						= 0x0000000000000001,
	CASTCLASS_FInt8Property					= 0x0000000000000002,
	CASTCLASS_UEnum							= 0x0000000000000004,
	CASTCLASS_UStruct						= 0x0000000000000008,
	CASTCLASS_UScriptStruct					= 0x0000000000000010,
	CASTCLASS_UClass						= 0x0000000000000020,
...

저렇게 생겼고, 해당 Class Type 에 대해 TCastFlags::Value 를 지정 하고 있다. 예를들어 TCastFlags<UClass>::Value 는 CASTCLASS_None 이 아니며 내부 코드가 작동한다.

우선 Casting 대상이 명시적으로 부모 ClassType 으로 형변환 하는지 확인하고, 아니라면 UClass 에서 ((const UObject*)Src)->GetClass()->HasAnyCastFlag(TCastFlags<To>::Value)) 을 확인한다.

UE_5.4/Engine/Source/Runtime/CoreUObject/Private/UObject/Class.cpp

FORCEINLINE bool HasAnyCastFlag(EClassCastFlags FlagToCheck) const
{
	return (ClassCastFlags&FlagToCheck) != 0;
}

ClassCastFlags 는 형변환 가능 주요 타입의 비트 조합이며 uint64 이므로 최대 64개까지 지원됨을 알 수 있다.

/** Cast flags used to accelerate dynamic_cast<T*> on objects of this type for common T */
EClassCastFlags ClassCastFlags;

Mark D

if (((const UObject*)Src)->IsA<To>())

이제 핵심적인 부분을 살펴 보자.

UE_5.4/Engine/Source/Runtime/CoreUObject/Public/UObject/UObjectBaseUtility.h

  template<class T>
  bool IsA() const
  {
  return IsA(T::StaticClass());
  }

여기서 IsA 는 Editor 일떄와 아닐때 다르게 동작하는데, 우선 Editor 상에서는 UClass 기반으로 SuperClass(부모 Class)를 타고 올라가면서 일치 하는지 확인한다.

In Editor

bool UStruct::IsChildOf( const UStruct* SomeBase ) const
{
	// If you're looking at this check it is due to calling IsChildOf with a this nullptr. *MAKE* sure you do not call this function
	// with a this nullptr. It is undefined behavior, and some compilers, clang13 have started to optimize out this == nullptr checks.
	check(this);

	if (SomeBase == nullptr)
	{
		return false;
	}

	bool bOldResult = false;
	for ( const UStruct* TempStruct=this; TempStruct; TempStruct=TempStruct->GetSuperStruct() )
	{
		if ( TempStruct == SomeBase )
		{
			bOldResult = true;
			break;
		}
	}

#if USTRUCT_FAST_ISCHILDOF_IMPL == USTRUCT_ISCHILDOF_STRUCTARRAY
	const bool bNewResult = IsChildOfUsingStructArray(*SomeBase);
#endif

#if USTRUCT_FAST_ISCHILDOF_COMPARE_WITH_OUTERWALK
	ensureMsgf(bOldResult == bNewResult, TEXT("New cast code failed"));
#endif

	return bOldResult;
}

Editor 가 아닐떄는 미리 BaseChain 을 구해두고, 부모Class 가 해당 Chain에 있는지 확인한다. 다중상속을 허용하지 않으므로 ParentClass 가 해당 ChildClass 의 부모라면 ChildClass 의 Chain 상에 정해진 Depth 에 놓이게 된다.

Not In Editor

class FStructBaseChain
{
protected:
	COREUOBJECT_API FStructBaseChain();
	COREUOBJECT_API ~FStructBaseChain();

	// Non-copyable
	FStructBaseChain(const FStructBaseChain&) = delete;
	FStructBaseChain& operator=(const FStructBaseChain&) = delete;

	COREUOBJECT_API void ReinitializeBaseChainArray();

	

private:
	FStructBaseChain** StructBaseChainArray;
	int32 NumStructBasesInChainMinusOne;

	friend class UStruct;
};  
void FStructBaseChain::ReinitializeBaseChainArray()
	{
		delete [] StructBaseChainArray;

		int32 Depth = 0;
		for (UStruct* Ptr = static_cast<UStruct*>(this); Ptr; Ptr = Ptr->GetSuperStruct())
		{
			++Depth;
		}

		FStructBaseChain** Bases = new FStructBaseChain*[Depth];
		{
			FStructBaseChain** Base = Bases + Depth;
			for (UStruct* Ptr = static_cast<UStruct*>(this); Ptr; Ptr = Ptr->GetSuperStruct())
			{
				*--Base = Ptr;
			}
		}

		StructBaseChainArray = Bases;
		NumStructBasesInChainMinusOne = Depth - 1;
	}                                                                                  

따라서 In Editor 가 아닐때 결국 다음 두줄로 확인이 끝난다

  FORCEINLINE bool IsChildOfUsingStructArray(const FStructBaseChain& Parent) const
	{
		int32 NumParentStructBasesInChainMinusOne = Parent.NumStructBasesInChainMinusOne;
		return NumParentStructBasesInChainMinusOne <= NumStructBasesInChainMinusOne && StructBaseChainArray[NumParentStructBasesInChainMinusOne] == &Parent;
	}

마무리

UE5 에서 Cast(...) 은 매우 빠르다.

profile
Game Client Programmer

0개의 댓글