KoreanFoodie's Study

언리얼 스마트 포인터(Unreal Smart Pointer) 정리 1 : 종류, 이점과 예제 본문

Game Dev/Unreal C++ : Study

언리얼 스마트 포인터(Unreal Smart Pointer) 정리 1 : 종류, 이점과 예제

GoldGiver 2022. 4. 1. 13:44

스마트 포인터 3대장

C++ 에는 대표적인 스마트 포인터 3 가지가 있는데, 언리얼에서는 이를 T로 시작하는 라이브러리로 제공한다.

 

  1. TUniquePtr
  2. TSharedPtr
  3. TWeakPointer

기본적인 원리와 기능은 C++ 와 비슷하다. 기본적인 메서드 차이만 잠깐 짚고 넘어가면,

  • TUniquePtr : C++ 에서 std::move 를 이용해 소유권을 이전했다면, 언리얼에서는 MoveTemp 를 사용한다.
  • TSharedPtr : C++ 에서 std::maked_shared 를 이용했다면, 언리얼에서는 MakeShared 을 사용한다. 해제 시 Reset( ) 을 호출한다. shared_ptr(new MyClass()) 같은 식으로 사용할 때는 MakeShareable 을 사용한다. MakeShareable(new MyClass()).

 

그런데, 사실 언리얼에는 한 가지가 더 추가되었다. 바로... TSharedRef 라는 녀석이다.

공식 문서 설명은 위와 같은데, 간단히 생각해서 null 을 가리킬 수 없는 TSharedPtr 라고 이해하면 편하다.

공식 문서의 권장 사항도 확인하고 넘어가자.

 

 

스마트 포인터의 이점

이 중, 메모리 부분을 보자. Reference Controller 때문에, 스마트 포인터의 크기는 C++ 포인터 크기의 2배가 된다. SharedPointer.h 쪽 구현을 보면...

/**
 * MakeShared utility function.  Allocates a new ObjectType and reference controller in a single memory block.
 * Equivalent to std::make_shared.
 *
 * NOTE: If the constructor is private/protected you will need to friend the intrusive reference controller in your class. e.g.
 * 	  template <typename ObjectType>
 *	  friend class SharedPointerInternals::TIntrusiveReferenceController;
 */
template <typename InObjectType, ESPMode InMode = ESPMode::Fast, typename... InArgTypes>
FORCEINLINE TSharedRef<InObjectType, InMode> MakeShared(InArgTypes&&... Args)
{
	SharedPointerInternals::TIntrusiveReferenceController<InObjectType>* Controller = SharedPointerInternals::NewIntrusiveReferenceController<InObjectType>(Forward<InArgTypes>(Args)...);
	return UE4SharedPointer_Private::MakeSharedRef<InObjectType, InMode>(Controller->GetObjectPtr(), (SharedPointerInternals::FReferenceControllerBase*)Controller);
}

 

MakeShared 는 제어 블록 객체를 생성하고, MakeSharedRef 를 위한 생성자를 호출한다. 실제 TSharedRef 를 보면...

/**
 * TSharedRef is a non-nullable, non-intrusive reference-counted authoritative object reference.
 *
 * This shared reference will be conditionally thread-safe when the optional Mode template argument is set to ThreadSafe.
 */
// NOTE: TSharedRef is an Unreal extension to standard smart pointer feature set
template< class ObjectType, ESPMode Mode >
class TSharedRef
{
public:
    using ElementType = ObjectType;
	
    // 생성자 및 각종 Helper Function 들

private:

    /** The object we're holding a reference to.  Can be nullptr. */
    ObjectType* Object;

    /** Interface to the reference counter for this object.  Note that the actual reference
    controller object is shared by all shared and weak pointers that refer to the object */
    SharedPointerInternals::FSharedReferencer< Mode > SharedReferenceCount;
};

ObjectType* 의 Object 와 SharedReferenceCount 가 존재하는 것을 알 수 있다!

 

 

헬퍼 클래스와 함수

공식 문서에 적혀 있는 헬퍼 클래스들에 대한 설명을 한 번 보자.

헬퍼 설명
TSharedFromThis TSharedFromThis 에서 클래스를 파생시키면 AsShared 혹은 SharedThis 함수가 추가됩니다. 이러한 함수들을 통해 오브젝트에 대한 TSharedRef 를 구할 수 있습니다.
MakeShared  MakeShareable 일반 C++ 포인터로 쉐어드 포인터를 생성합니다. MakeShared 는 새 오브젝트 인스턴스와 레퍼런스 컨트롤러를 한 메모리 블록에 할당하지만, 오브젝트가 public 생성자를 제공해야만 합니다. MakeShareable 는 덜 효율적이지만 오브젝트의 생성자가 private 이더라도 접근 가능하여 직접 생성하지 않은 오브젝트에 대한 소유권을 가질 수 있고, 오브젝트를 소멸시킬 경우에는 커스텀 비헤이비어가 지원됩니다.
StaticCastSharedRef StaticCastSharedPtr 정적인 형변환 유틸리티 함수로, 주로 파생된 타입으로 내림변환(downcast)하는 데 사용됩니다.
ConstCastSharedRef  ConstCastSharedPtr const 스마트 레퍼런스 또는 스마트 포인터를 mutable 스마트 레퍼런스 또는 스마트 포인터로 각각 변환합니다.

설명만 들어서는 감이 잘 잡히지 않을 수 있으니, 간단한 예제 코드를 한 번 보자.

 

TSharedFromThis

먼저 TSharedFromThis 부터 살펴본다.

/* 
* 보상으로 주어지는 요소를 담고 있는 클래스
* 골드, 다이아 등의 재화 뿐만 아니라 재료, 장비 등의 아이템이 포함될 수 있다.
*/
class FMpRewardBox final : FNonCopyable, public TSharedFromThis<FMpRewardBox>
{
private:
    // RewardElement 는 보상 요소를 나타냄. 때로는 대체 보상이 존재할 수 있음
    TArray<TSharedRef<FMpRewardElementBase>> _rewardElements;
    // 보상 수령 시 보너스, 패널티 등의 옵션 저장
    TOptional<TArray<TWeakPtr<FMpRewardOption>>> _rewardOptions;

    /* 기타 변수 및 메소드 */

public:
    TSharedRef<FMpRewardBoxMultiple> MakeRewardBoxMultiple(const int32 InMultiple = 1) const
    {
        return MakeShared<FMpRewardBoxMultiple>(AsShared(), InMultiple);
    }
}

/* 
* 단일 보상의 경우 FMpRewardBox 로, 중복 보상의 경우 FMpRewardBoxMultiple 로 처리 
* 반복 퀘스트 등의 완료 시, 동일한 보상을 여러 번 중복해서 수령 가능하는 경우 등에 대응
*/
class FMpRewardBoxMultiple final
{
public:
    FMpRewardBoxMultiple(TSharedRef<const FMpRewardBox> InRewardBox, const int32 InMultiple);

private:
    TSharedRef<const FMpRewardBox> _rewardBox;
    int32 _multipleCount;
}

FMpRewardBoxMultiple::FMpRewardBoxMultiple(
    TSharedRef<const FMpRewardBox> InRewardBox, const int32 InMultiple)
    : _rewardBox(InRewardBox), _multipleCount(InMultiple) {}

위에서는 보상을 담을 수 있는 FMpReward 클래스를 선언하고, 해당 클래스를 TSharedFromThis<FMpRewardBox> 로 설정했다.

FMpRewardBox 클래스의 MakeRewardBoxMultiple 함수를 보면, 자기 자신으로부터 TSharedRef 타입의 오브젝트를 만들어 전달하는 것을 볼 수 있다.

FMpRewardBoxMultiple 은 중복 보상을 처리할 수 있는 클래스로, FMpRewardBox 를 TSharedRef 로 받아, _multipleCount 만큼 중복 보상을 수령할 수 있도록 처리될 것이다.

FMpRewardBox 클래스에는 실제 보상 요소들의 배열인 _rewardElements 와 보너스 등의 옵션을 관리하는 _rewardOptions 가 정의되어 있다. 참고로, TOptional 이 무엇인지 잘 모르겠다면 이 글을 참고하자.

 

MakeShared 및 MakeShareable

MakeShared 는 오브젝트 인스턴스와 레퍼런스 컨트롤러를 한 메모리 블록에 할당하고, MakeShareable 은 그렇지 않다(물론 구현을 보면, MakeShared 도 당연히 레퍼런스 컨트롤러를 따로 new 로 생성해 주어야 한다).

MakeShared 의 구현은 위에서 보았으니, MakeShareable 의 구현을 잠깐 보면...

/**
 * MakeShareable utility function.  Wrap object pointers with MakeShareable to allow them to be implicitly
 * converted to shared pointers!  This is useful in assignment operations, or when returning a shared
 * pointer from a function.
 */
// NOTE: The following is an Unreal extension to standard shared_ptr behavior
template< class ObjectType >
FORCEINLINE SharedPointerInternals::FRawPtrProxy< ObjectType > MakeShareable( ObjectType* InObject )
{
	return SharedPointerInternals::FRawPtrProxy< ObjectType >( InObject );
}

template< class ObjectType, class DeleterType >
FORCEINLINE SharedPointerInternals::FRawPtrProxy< ObjectType > MakeShareable( ObjectType* InObject, DeleterType&& InDeleter )
{
	return SharedPointerInternals::FRawPtrProxy< ObjectType >( InObject, Forward< DeleterType >( InDeleter ) );
}

내부적으로 FRawPtrProxy 를 호출한다. 다시 FRawPtrProxy 를 보면...

/** Proxy structure for implicitly converting raw pointers to shared/weak pointers */
// NOTE: The following is an Unreal extension to standard shared_ptr behavior
template< class ObjectType >
struct FRawPtrProxy
{
    /** The object pointer */
    ObjectType* Object;

    /** Reference controller used to destroy the object */
    FReferenceControllerBase* ReferenceController;

    /** Construct implicitly from an object */
    FORCEINLINE FRawPtrProxy( ObjectType* InObject )
        : Object             ( InObject )
        , ReferenceController( NewDefaultReferenceController( InObject ) )
    {
    }

    /** Construct implicitly from an object and a custom deleter */
    template< class Deleter >
    FORCEINLINE FRawPtrProxy( ObjectType* InObject, Deleter&& InDeleter )
        : Object             ( InObject )
        , ReferenceController( NewCustomReferenceController( InObject, Forward< Deleter >( InDeleter ) ) )
    {
    }
};

생성자를 한 번 더 호출해 주는 것을 알 수 있다. 심지어 Object 가 이동 생성이 불가능할 타입일 경우에는, 복사 생성까지 이루어 질 것이다.

다만 MakeShareable 의 장점은, 오브젝트의 생성자가 private 인 경우에도 사용할 수 있으며, 커스텀 삭제자(Deleter)를 지정할 수 있다는 것이다. 만약 이 두 경우가 아니라면, MakeShareable 보다 MakeShared 를 사용하자. 특히, 아래와 같은 상황에서는 MakeShared 를 사용하자!

// 비효율적 : new 로 할당 한 번, MakeShareable 로 할당 또 한 번!
MakeShareable(new MyClass());

// 효율적 : 할당을 한 번에!
MakeShared<MyClass>();

이와 비슷한 내용은 이미 Modern Effective C++ 에서도 다룬 바 있다. 더 자세한 내용은 이 글을 참고하자!

 

StaticCastSharedRef 및 StaticCastSharedPtr

사실 위 함수는 기존의 StaticCast 와 거의 동일하다. 다만 차이가 있다면, 대상 인자가 TSharedRef 거나 TSharedPtr 라는 것 정도일 것이다.

아까 FMpRewardBox 에 들어 있던, FMpRewardElement 를 생각해 보자. 보상 요소의 타입은 실제로 재화, 재료, 아이템 등 매우 다양할 것이다. 그럼 보상 요소의 타입을 다음과 같이 세분화 하여 정의해 볼 수 있을 것이다 :

// 보상 타입의 기초 클래스
struct FMpRewardElementBase {};

// 재화 타입
struct FMpRewardElementCurrency final : public FMpRewardElementBase {};

// 아이템 기본 타입, 아이템 일반 타입, 레벨이 존재하는 아이템 타입
struct FMpRewardElementItemBase : public FMpRewardElementBase {};
struct FMpRewardElementItem : public FMpRewardElementItemBase {};
struct FMpRewardElementItemLevel final : public FMpRewardElementItem {};

// 캐쉬
struct FMpRewardElementCategoryCash final : public FMpRewardElementBase {};

그리고, 아까 TArray<FMpRewardElementBase> 타입으로 선언한 _rewardElements 로부터, 특정 재화 타입의 보상이 존재하는지 알고 싶다고 하자. 일단 간략화했던 보상 타입 클래스를 조금 더 확장해 보자.

// 보상 타입의 종류
enum class EMpRewardElementType
{
	RewardElementCurrency,
	RewardElementItem,
	RewardElementItemCash
};

// 보상 타입의 기초 클래스 (추상 클래스)
struct FMpRewardElementBase 
{
	virtual EMpRewardElementType GetRewardElementType() const  = 0;
};

// 재화 타입
struct FMpRewardElementCurrency final : public FMpRewardElementBase 
{
	EMpRewardElementType GetRewardElementType() const final
	{
		return EMpRewardElementType::RewardElementCurrency;
	}
};

// 아이템 기본 타입, 아이템 일반 타입, 레벨이 존재하는 아이템 타입, 캐쉬 아이템
struct FMpRewardElementItemBase : public FMpRewardElementBase {};

struct FMpRewardElementItem : public FMpRewardElementItemBase 
{
	EMpRewardElementType GetRewardElementType() const final
	{
		return EMpRewardElementType::RewardElementItem;
	}
};

struct FMpRewardElementItemLevel final : public FMpRewardElementItem {};

struct FMpRewardElementItemCash final : public FMpRewardElementItemBase 
{
	EMpRewardElementType GetRewardElementType() const final
	{
		return EMpRewardElementType::RewardElementItemCash;
	}
};

보상 타입은 EMpRewardElementType 이라는 Enum Class 에 정의가 되어 있고, 각 타입별로 어떻게 동작하면 좋을지가 상속받은 클래스 내부에서 구현되어 있을 것이다.

 

이제 해당 기능을 제공하는 함수를 짜 보자.

bool FMpRewardBox::HasCurrencyType(CurrencyType InCurrencyType) const
{
	for (const TSharedRef<FMpRewardBox>& reward : _rewardElements)
	{
		if (EMpRewardElementType::RewardElementCurrency == reward->GetCostElementType() &&
			InCurrencyType == StaticCastSharedRef<FMpRewarElementCurrency>(reward)->_currencyType)
			return true;
	}
	
	return false;
}

함수명을 보면 알듯이, 이 함수는 해당 리워드에 특정 Currency 가 존재하는지를 검사한다.

이 때, 먼저 추상 클래스로 FMpRewardElementBase 에 정의되었던 타입을 Enum 값 비교를 통해 재화 타입인지 확인한 후, 실제 타입을 인자로 넘긴 타입과 비교하면 된다.

만약 GetCostElementType 이 순수 가상 함수면 어쩌냐고 물어본다면.. 애초에 순수 가상 함수를 가지고 있는 추상 클래스는 인스턴스화될 수 없기 때문에, 그럴 일이 존재할 수 없다고 대답하겠다.

심지어, 요즘은 컴파일러가 좋아서 생성자에 기반 클래스의 순수 가상 함수를 호출하는 경우도 컴파일 타임에서 검출해 주는 세상에 살고 있다...

 

'ConstCastSharedRef 및 ConstCastSharedPtr' 의 경우, 기존의 const_cast 와 동일한 내용일 것이므로 생략하도록 하겠다. 😄

 

 

언리얼 오브젝트의 메모리관리

위의 스마트 포인터 라이브러리는 일반 C++ 객체를 위한 라이브러리이고, 언리얼 오브젝트에는 사용할 수 없다. UPROPERTY 가 붙은 언리얼 오브젝트는 가비지 컬렉터(GC)에 의해 자동으로 관리되기 때문이다.

언리얼의 GC 에 의해 메모리가 해제될 경우, C++ 의 스마트 포인터 라이브러리와 달리 해제되는 시점을 정확히 예측할 수 없다. 따라서 언리얼 오브젝트의 포인터를 소멸할 때에는 BeginConditionalDestroy( ) 라는 함수를 호출하고 해제가 될 때까지 기다려야 한다(액터의 경우에는 DestroyActor 함수도 호출해 월드의 씬 정보를 업데이트 해 주어야 한다).

가비지 컬렉션은 프로젝트 세팅에서 지정된 시간마다 돌아가도록 설정되어 있는데, "Time Between Pruging Pending Kill Objects" 옵션을 조정하면 간격을 더 짧게 조정할 수 있다.

 

 

언리얼 오브젝트의 약포인터

언리얼 C++ 에서는 언리얼 오브젝트를 약참조하는 TWeakObjectPtr 이라는 별도의 라이브러리를 제공한다. 특정 오브젝트를 참조할 때 반드시 소유권이 필요하지 않은 경우에는 약참조를 걸어 놓는 것이 바람직한 방식이다.

예를 들어, 인벤토리 시스템에서 아이템을 가리킬 때, 약참조가 아닌 공유참조를 걸어 놓으면, 아이템이 사라진 후에도 레퍼런스 카운팅이 0이 되지 않아 아이템 객체의 메모리가 회수되지 않을 수 있다!

Comments