KoreanFoodie's Study

[언리얼] 언리얼의 Cast 동작 원리 본문

Game Dev/Unreal C++ : Study

[언리얼] 언리얼의 Cast 동작 원리

GoldGiver 2023. 1. 13. 15:52

언리얼의 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

참고 자료 : 언리얼 캐스트 분석
Comments