KoreanFoodie's Study
[언리얼] 언리얼의 Cast 동작 원리 본문
언리얼의 Cast 동작 원리
핵심
1. Cast<T> 는 UObject 에 쓰여야 한다. Cast<T> 는 static_cast 와는 달리 타입 안정성을 지닌다(nullptr 를 리턴).
2. Cast<T> 의 런타임 비용은 에디터 환경에서는 O(O(Depth(InheritanceTree))) 이고, 에디터가 아닐 때는 O(1) 이다.
3. Cast<T> 는 dynamic_cast 를 사용하지 않는다. dynamic_cast 대신 Cast<T> 를 사용하라!
실제로 언리얼의 Cast 는 다음과 같이 구현되어 있다 :
// Dynamically cast an object type-safely.
template <typename To, typename From>
FORCEINLINE To* Cast(From* Src)
{
return TCastImpl<From, To>::DoCast(Src);
}
내부적으로 TCastImpl 을 부르고 있는데... 이 녀석은 뭘까? 실질적으로 TCastImpl 이 중요한 부분이다!
template <typename From, typename To, ECastType CastType = TGetCastType<From, To>::Value>
struct TCastImpl
{
// This is the cast flags implementation
FORCEINLINE static To* DoCast( UObject* Src )
{
return Src && Src->GetClass()->HasAnyCastFlag(TCastFlags<To>::Value) ? (To*)Src : nullptr;
}
FORCEINLINE static To* DoCastCheckedWithoutTypeCheck( UObject* Src )
{
return (To*)Src;
}
};
위에서 (To*)Src 를 보면, C 스타일의 캐스팅이 사용되고 있다. C 스타일의 캐스팅이므로 dynamic_cast 처럼 동작할 걱정은 하지 않아도 된다. 또한 const 와 non-const 포인터 두 경우 둘 다 캐스팅이 잘 이루어 질 것을 기대할 수 있다. C 스타일의 캐스팅은 const_cast 를 먼저 시도하고 그 다음 static_cast 를 시도하기 때문이다.
DoCast 부분을 보면, HasAnyCastFlag 가 호출되는 것을 볼 수 있는데, 이건 얼마나 효율적으로 구현되어 있을까?
FORCEINLINE bool HasAnyCastFlag(EClassCastFlags FlagToCheck) const
{
return (ClassCastFlags&FlagToCheck) != 0;
}
HasAnyCastFlag 는 실제로 bitmask 를 체크하는 녀석이고, FORCEINLINE 으로 선언되어 있다!
언리얼에서 제공하는 Cast 를 사용했을 때, C++ 에서의 reinterpret_cast 처럼 동작하는 경우가 존재할 수 있을까?
정답은... '그렇지 않다'이다. 그 이유는, 언리얼은 리플렉션 시스템을 활용하여 CDO(Class Default Object) 에 클래스와 관련된 정보를 저장해 놓기 때문이다. CDO 에 저장된 UStruct 객체 혹은 ClassCastFlags 를 검사하여 두 객체가 연관이 있는지 없는지를 파악한다(연관이 없으면 nullptr 리턴).
참고로, ECastType 과 TCastFlags 는 다음과 같이 정의되어 있다 :
enum class ECastType
{
UObjectToUObject,
InterfaceToUObject,
UObjectToInterface,
InterfaceToInterface,
FromCastFlags
};
template <typename Type>
struct TCastFlags
{
static const EClassCastFlags Value = CASTCLASS_None;
};
TCastImpl 은 다음과 같이 ECastType 에 따라 4 가지 종류를 가지게 된다 :
template <typename From, typename To, bool bFromInterface = TIsIInterface<From>::Value, bool bToInterface = TIsIInterface<To>::Value, EClassCastFlags CastClass = TCastFlags<To>::Value>
struct TGetCastType
{
#if USTRUCT_FAST_ISCHILDOF_IMPL == USTRUCT_ISCHILDOF_STRUCTARRAY
static const ECastType Value = ECastType::UObjectToUObject;
#else
static const ECastType Value = ECastType::FromCastFlags;
#endif
};
template <typename From, typename To > struct TGetCastType<From, To, false, false, CASTCLASS_None> { static const ECastType Value = ECastType::UObjectToUObject; };
template <typename From, typename To > struct TGetCastType<From, To, false, true , CASTCLASS_None> { static const ECastType Value = ECastType::UObjectToInterface; };
template <typename From, typename To, EClassCastFlags CastClass> struct TGetCastType<From, To, true, false, CastClass > { static const ECastType Value = ECastType::InterfaceToUObject; };
template <typename From, typename To, EClassCastFlags CastClass> struct TGetCastType<From, To, true, true , CastClass > { static const ECastType Value = ECastType::InterfaceToInterface; };
우리가 위에서 든 예제는 ECastType::UObjectToUObject 일 것이다. 이제 UObject 에서 UObject 로 캐스팅하는 Specialization 버전을 보면...
template <typename From, typename To>
struct TCastImpl<From, To, ECastType::UObjectToUObject>
{
FORCEINLINE static To* DoCast( UObject* Src )
{
return Src && Src->IsA<To>() ? (To*)Src : nullptr;
}
FORCEINLINE static To* DoCastCheckedWithoutTypeCheck( UObject* Src )
{
return (To*)Src;
}
};
IsA( ) 를 사용해서 해당 객체의 타입을 체킹하고 있다. 만약 IsA( ) 가 true 를 return 하면 C 스타일의 캐스팅을 해 준다.
그렇다면 IsA( ) 의 비용은 어떻게 될까? 다음 코드를 보자.
/** Returns true if this object is of the specified type. */
template <typename OtherClassType>
FORCEINLINE bool IsA( OtherClassType SomeBase ) const
{
// We have a cyclic dependency between UObjectBaseUtility and UClass,
// so we use a template to allow inlining of something we haven't yet seen, because it delays compilation until the function is called.
// 'static_assert' that this thing is actually a UClass pointer or convertible to it.
const UClass* SomeBaseClass = SomeBase;
(void)SomeBaseClass;
checkfSlow(SomeBaseClass, TEXT("IsA(NULL) cannot yield meaningful results"));
const UClass* ThisClass = GetClass();
// Stop the compiler doing some unnecessary branching for nullptr checks
UE_ASSUME(SomeBaseClass);
UE_ASSUME(ThisClass);
return IsChildOfWorkaround(ThisClass, SomeBaseClass);
}
/** Returns true if this object is of the template type. */
template<class T>
bool IsA() const
{
return IsA(T::StaticClass());
}
이것만 봐서는 IsA 의 비용 계산이 쉽지가 않은데... 실제로 IsChildOfWorkaround 는 다음과 같다 :
template <typename ClassType>
static FORCEINLINE bool IsChildOfWorkaround(const ClassType* ObjClass, const ClassType* TestCls)
{
return ObjClass->IsChildOf(TestCls);
}
결국 IsA 의 비용은 IsChildOf 의 비용이 결정짓는다고 봐도 된다.
IsChildOf 는 다음과 같이 구현되어 있다 :
/* Returns true if this struct either is SomeBase, or is a child of SomeBase.
* This will not crash on null structs
*/
#if USTRUCT_FAST_ISCHILDOF_COMPARE_WITH_OUTERWALK || USTRUCT_FAST_ISCHILDOF_IMPL == USTRUCT_ISCHILDOF_OUTERWALK
bool IsChildOf( const UStruct* SomeBase ) const;
#else
bool IsChildOf(const UStruct* SomeBase) const
{
return (SomeBase ? IsChildOfUsingStructArray(*SomeBase) : false);
}
#endif
UE_EDITOR 가 1 로 잡혀 있으면, USTRUCT_FAST_ISCHILDOF_IMPL 가 USTRUCT_ISCHILDOF_OUTERWALK 이며, 이는 아래의 IsChildOf 함수를 호출하게 된다 :
#if USTRUCT_FAST_ISCHILDOF_COMPARE_WITH_OUTERWALK || USTRUCT_FAST_ISCHILDOF_IMPL == USTRUCT_ISCHILDOF_OUTERWALK
bool UStruct::IsChildOf( const UStruct* SomeBase ) const
{
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;
}
#endif
제일 중요한 부분은 for loop 인데, 여기서 자신이 속한 클래스와 인자로 넘겨받은 클래스의 reflection struct 를 비교한다. 위의 IsChildOf 함수의 비용은 inheritance tree 의 깊이에 비례한다. 즉 O(Depth(InheritanceTree)) 가 된다.
UE_EDITOR 가 0 으로 잡혀 있으면, USTRUCT_FAST_ISCHILDOF_IMPL 가 USTRUCT_ISCHILDOF_STRUCTARRAY이며, 이는 아래의 IsChildOf 함수를 호출하게 된다 :
bool IsChildOf(const UStruct* SomeBase) const
{
return (SomeBase ? IsChildOfUsingStructArray(*SomeBase) : false);
}
#if USTRUCT_FAST_ISCHILDOF_IMPL == USTRUCT_ISCHILDOF_STRUCTARRAY
class FStructBaseChain
{
protected:
COREUOBJECT_API FStructBaseChain();
COREUOBJECT_API ~FStructBaseChain();
// Non-copyable
FStructBaseChain(const FStructBaseChain&) = delete;
FStructBaseChain& operator=(const FStructBaseChain&) = delete;
COREUOBJECT_API void ReinitializeBaseChainArray();
// this is O(1) implementation of IsChildOf
FORCEINLINE bool IsChildOfUsingStructArray(const FStructBaseChain& Parent) const
{
int32 NumParentStructBasesInChainMinusOne = Parent.NumStructBasesInChainMinusOne;
return NumParentStructBasesInChainMinusOne <= NumStructBasesInChainMinusOne && StructBaseChainArray[NumParentStructBasesInChainMinusOne] == &Parent;
}
private:
FStructBaseChain** StructBaseChainArray;
int32 NumStructBasesInChainMinusOne;
friend class UStruct;
};
#endif
IsChildOfUsingStructArray 함수는 StructBaseChainArray 을 이용해 O(1) 타임에 타입 체킹을 가능하게 만들어 준다.
성능을 정리하면 다음과 같다 :
- Linear, O(Depth(InheritanceTree)), in the editor environment (UE_EDITOR = 1).
- Constant, O(1), in the non-editor environment (UE_EDITOR = 0).
Cast<T> 보다 조금 더 빠른 ExactCast 도 있다 :
template< class T >
FORCEINLINE T* ExactCast( UObject* Src )
{
return Src && (Src->GetClass() == T::StaticClass()) ? (T*)Src : nullptr;
}
GetClass( ) 와 StaticClass( ) 가 둘다 O(1) 이므로, 전달되는 오브젝트의 타입을 미리 알고 있을 경우에는 유용하게 사용할 수도 있다. CastChecked 라는, 더 빠른 C 스타일의 캐스팅도 있지만 타입 안전성이 떨어지는 단점이 있다.
그렇다면 static_cast 를 쓸 수 있는 상황에서 왜 Cast<T> 를 굳이 사용해야 할까?
답은 타입 안정성 때문이다. 즉, 타입을 잘못 매치했거나 상속 구조가 변경되었을 때, Cast 는 nullptr 를 리턴한다. 하지만 static_cast 는 캐스팅을 수행한 후 부정확한 포인터를 반환할 수도 있다. 다음 코드를 보자.
// APawn is the parent of ACharacter
APawn* NewPawn = NewObject<APawn>(GWorld);
ACharacter* StaticCastCharacter = static_cast<ACharacter*>(NewPawn);
ACharacter* UnrealCastCharacter = Cast<ACharacter>(NewPawn);
위의 static_cast 는 NewPawn 이 APawn* 타입임에도 ACharacter* 에 대한 포인터를 리턴할 수도 있다. 반면, Cast 는 nullptr 를 리턴한다.
관련 소스들 위치 :
Cast and CastChecked
…\Engine\Source\Runtime\CoreUObject\Public\Templates\Casts.h
IsA
…\Engine\Source\Runtime\CoreUObject\Private\UObject\UObjectBaseUtility.cpp
Engine flags for IsA
…\Engine\Source\Runtime\CoreUObject\Public\UObject\ObjectMacros.h
Engine flags for Cast checks
…\Engine\Source\Runtime\Core\Public\Misc\Build.h
참고 자료 : 언리얼 캐스트 분석
'Game Dev > Unreal C++ : Study' 카테고리의 다른 글
언리얼 스마트 포인터(Unreal Smart Pointer) 정리 2 : 구현 세부사항과 팁 (0) | 2023.03.22 |
---|---|
[언리얼] TOptional 사용하기 + 예제 (0) | 2023.03.21 |
[언리얼] 위젯 리플렉터 - 언리얼 위젯 디버깅 (0) | 2022.11.16 |
Unreal 에서 Actor 와 ActorComponent 의 개념 (vs. Unity 에서의 GameObject 와 비교) (0) | 2022.10.20 |
언리얼 코딩 표준 간단 리뷰 (0) | 2022.10.14 |