UE5:Unreal Engineのポインタについてまとめた 前編

に公開

はじめに

本稿では Unreal Engine 5のポインタについて一覧しまとめます。
どのようなポインタがあり、それぞれどう使うのか、そしてpure C++のポインタとどう違うのかについて言及します。正しい扱い方について述べますので、アクセスバイオレーションを起こさないように気を付けましょう。

あまりに内容が膨大なので、三編に分けました。
本稿は前編です。

用語説明

用語定義

説明のために以下の構造体とUCLASSを定義します。
C++においてはstructとclassにほとんど違いはないため基本的にstructで説明します。

namespace MyPlugin
{
    // pure C++な構造体
    struct FCppStruct
    {
        int Value = 0;
    }
}

// Unreal C++なUSTRUCT
USTRUCT()
struct FStruct
{
    GENERATED_BODY();

    int Value = 0;
}

// Unreal C++なUCLASS
UCLASS()
class UMyClass : public UObject
{
    GENERATED_BODY();

public:
    UPROPERTY()
    int Value = 0;
}

ポインタ一覧

Unreal Engineには以下のポインタがあります。

アンマネージドポインタ

UnrealEngine によって管理されないメモリ領域を指すポインタです。
ガベージコレクションの対象ではありません。
使い方も機能もpure C++に該当するそれらとほぼ同じです。

アンマネージド 名前 補足
FCppStruct* Object; 生ポインタ 使うべきでない
TUniquePtr<FCppStruct> Pointer; ユニークポインタ std::unique_ptrに該当
TSharedPtr<FCppStruct> Pointer; シェアードポインタ std::shared_ptrに該当
TWeakPtr<FCppStruct> Pointer; ウィークポインタ std::weak_ptrに該当

マネージドポインタ

UnrealEngine によって管理されるメモリ領域を指すポインタです。
ガベージコレクションの対象です。

マネージド 名前 補足
UPROPERTY() Ubject* Pointer{}; ハードオブジェクトポインタ 古い書き方で今どき使わない。TObjectPtr使え
UPROPERTY() TObjectPtr<UObject> Pointer; オブジェクトポインタ ハードオブジェクトポインタの進化版。基本はこれ。
UPROPERTY() TSoftObjectPtr<UObject> Pointer; ソフトオブジェクトポインタ UE5独自のやわらかい参照(後述)
UPROPERTY() TWeakObjectPtr<UObject> Pointer; ウィークブジェクトポインタ TObjectPtrの所有権を持たない版
UPROPERTY() TStrongObjectPtr<UObject> Pointer; ストロングブジェクトポインタ TObjectPtrの所有権を持つ版
Ubject* Pointer{}; ワイルドポインタ UPROPERTY()としてリフレクションシステムに辿られない野生のポインタ。ダングリングポインタになる。バグなので直せ。TObjectPtrにしろ

特別なマネージドポインタ

マネージド 名前 補足
UPROPERTY() TScriptInterface<IMyInterface> Pointer; Nativeインターフェースへのポインタ pure C++なinteface型を指すポインタ(後述)
UPROPERTY() TSubClassOf<UMyBase> Pointer; UCLASSへのポインタ BP含め任意のUMyBase派生型を指すポインタ(後述)
UPROPERTY() TObjectPtr<AActor> Actor; AActorへのポインタ BP含め任意のAActor派生型を指すポインタ(後述)
UPROPERTY() TObjectPtr<UActorComponent> Component; UActorComponentへのポインタ BP含め任意のUActorComponent派生型を指すポインタ(後述)
UPROPERTY() TArray<TObjectPtr<UObject>> PointerArray; TArrayの中のポインタ コンテナの中のオブジェクトポインタ
UPROPERTY() TMap<TObjectPtr<UObject>, TObjectPtr<UObject>> PointerMap; TMapの中のポインタ コンテナの中のオブジェクトポインタ
UPROPERTY() TSet<TObjectPtr<UObject>> PointerSet; TSetの中のポインタ コンテナの中のオブジェクトポインタ

詳解

Raw Pointer (生ポ)

FStruct* Pointer = nullptr;
UObject* Object = nullptr;

生ポ尽く死すべし!

pure C++と全く同じです。言及することはありません。
今時のC++では使いませんし、UnrealEngineでも使いません。生ポはとにかくダングリングポインタになりやすいので、スマートポインタを使いましょう。

関数の戻り値として生ポを返すことはあります。BP関数のように、ゲームスレッドで使われることが前提とされている関数では生ポでも良いのです。

TUniquePtr

 TUniquePtr<FCppStruct> Pointer;

単一の所有権を持つポインタです。std::unique_ptrと同じです。
MakeUniqueで作りMoveTempによって転送することができます。
ラムダキャプチャする場合は、Moveキャプチャが必要です。

{
    TUniquePtr<FCppStruct> Ptr0 = MakeUnique<FCppStruct>(); // New FCppStructする
    TUniquePtr<FVector> Vector = MakeUnique<FVector>(1, 2, 3); //New FVector(1,2,3)する

    TUniquePtr<FVector> Ptr1 = MoveTemp(Ptr0); //Ptr0をPtr1に移動する
    
    check(!Ptr0.IsValid()); // Ptr0の所有権は手放し済み
    check(Ptr1.IsValid()); // Ptr1は所有権を譲り受けた

    if(Vector) // null checkは operator bool でok.
    {
        int Sum = Vector->X + Vector->Y + Vector->Z;// 生ポと同じ感じで触れる
    } 
    Ptr1.Reset(); // 明示的にdelete される

    //ラムダキャプチャはmoveキャプチャすること
    auto Lamda = [Vec = MoveTemp(Vector)]()
    {
        check(*Vec == FVector(1,2,3));
    };

} // ここでデストラクタが呼ばれてオブジェクトはdeleteされる

TSharedPtr

参照を共有する共有ポインタです。std::shared_ptrと大体同じです。
参照カウントによって管理されています。そのため、循環参照には気を付けないといけません。
MakeSharedで作り、コピーで参照を増やせます。

TSharedPtr<FCppStruct> SharedPtr0 = MakeShared<FCppStruct>(); // New FCppStructする
TSharedPtr<FCppStruct> SharedPtr1 = SharedPtr0; // 参照カウント増やす

check(SharedPtr0.GetSharedReferenceCount() == 2);

参照カウントを増やしたくない場合は ToWeakPtrで WeakPtrに変換できます。

TWeakPtr<int> WeakPtr = SharedPtr0.ToWeakPtr();

TSharedRefという共有参照

UEにはTSharedRefという型が存在します。これは非nullな共有参照型です。pure c++にはありません。
非null保障が出来る状態であるならば、TSharedRefを引き回すとnullチェックを省略できます。

TSharedRef<int> SharedRef0 = MakeShared<int>(100); // MakeSharedはTSharedRefを返す
TSharedPtr<int> SharedPtr = SharedRef0.ToSharedPtr(); //相互変換可能
if(SharedPtr.IsValid())
{
    TSharedRef<int> SharedRef1 = SharedPtr.ToSharedRef();
}

TSharedRefTSharedPtrは相互に暗黙的に変換できます。無駄なコストなので引数や返り値型に取るときは統一した方がよさげです。
ただし、nullptrになりうる文脈では TSharedPtrを、必ず非nullptrを期待したい文脈ではTSharedRefを、要求するという設計もありです。
例えばファクトリー関数です。

class IMyObject{};
class FMyFactory
{
    TSharedRef<IMyObject> MakeDefaultInstance(); //必ず成功するので 非nullptr

    TSharedPtr<IMyObject> TryMakeInstance(); //失敗したらnullptr
}

TSharedPtrのスレッドセーフ性は選択可能

std::shared_ptrの参照カウントはスレッドセーフです。逆にスレッドセーフじゃなくすることはできません。単一のスレッドでしか使わないような状況の場合はこのオーバーヘッドを削りたくなります。一方、TSharedPtrではスレッドセーフ性を型パラメータによって選択することができます。
デフォルトでは 非スレッドセーフです。 よく見たらスレッドセーフでした。

TSharedPtr<int, ESPMode::NotThreadSafe> SharedPtr = MakeShared<int, ESPMode::NotThreadSafe>(123);
TSharedPtr<int, ESPMode::ThreadSafe> SharedPtr = MakeShared<int, ESPMode::ThreadSafe>(123);
TSharedPtr<int> SharedPtr = MakeShared<int>(123); // 指定しないときはスレッドセーフ

Unreal スマート ポインタ ライブラリ の説明では、
デフォルトではスレッドセーフじゃない、という記載がありますが、少なくともUE5.5時点では事実上デフォルトは ESPMode::ThreadSafeです。

TSharedPtrの 前方宣言でESPMode::ThreadSafeになってる

単にTSharedPtrと述べていますが、本名は TSharedPtr<T, ESPMode>という型です。そのため、必ず型パラメータにESPModeを設定しないとコンパイルエラーになります。
暗黙的に推論されたとしてもESPMode::NoThreadSafe=0が採用されるはずなのですが、普通にTSharedPtr<T>が使えています。なぜかと思って調べました。

原因はTemplates/SharedPointerFwd.hによってテンプレート部分特殊化がなされているからです。

Templates/SharedPointerFwd.h
template< class ObjectType, ESPMode Mode = ESPMode::ThreadSafe > class TSharedRef;
template< class ObjectType, ESPMode Mode = ESPMode::ThreadSafe > class TSharedPtr;
template< class ObjectType, ESPMode Mode = ESPMode::ThreadSafe > class TWeakPtr;
template< class ObjectType, ESPMode Mode = ESPMode::ThreadSafe > class TSharedFromThis;

スレッドセーフ機能がONになるようになっています。
よって次のクラスは ESPMode::ThreadSafeとしてtemplate解決されます。

TSharedRef<int> IntRef = ...; // TSharedRef<int, ESPMode::ThreadSafe>と同じ
TSharedPtr<int> IntPtr = ...; // TSharedPtr<int, ESPMode::ThreadSafe>と同じ

ヘッダによるテンプレート特殊化ということはそのヘッダファイルをインクルードしなかったら解釈されなくなっちゃうのですが、安心してください。
Tempaltes/SharedPointer.h > Templates/SharedPointerInternal.h > Templates/SharedPointerFwd.h という流れでインクルードされているので、TSharedPtrを知っているクラスなら必ず上記テンプレート特殊化は有効です。

という訳で指定を省いた場合はESPMode::ThreadSafeです。

MakeSharedは デフォルトでESPMode::ThreadSafe

MakeSharedを用いた場合もESPMode::ThreadSafeです。これはテンプレート関数なのですが、型パラメータにデフォルトパラメータが指定されています。

template <typename InObjectType, ESPMode InMode = ESPMode::ThreadSafe, typename... InArgTypes>
[[nodiscard]] FORCEINLINE TSharedRef<InObjectType, InMode> MakeShared(InArgTypes&&... Args){...}

よって、MakeShared<FVector>等はMakeShared<FVector, ESPMode::ThreadSafe>に推論されます。

auto Ptr = MakeShared<int>(123); // TSharedRef<int, ESPMode::ThreadSafe>に型推論される
TSharedPtr<int> Ptr = MakeShared<int>(123); // TSharedRef<int, ESPMode::ThreadSafe>から暗黙的にTShaedPtr<int, ESPMode::ThreadSafe>に変換される

MakeShared vs MakeSharable vs コンストラクタ

何が違うの、どれをいつ使えばいいのか悩ましいので調べました。
結論としては、ほとんどのユースケースのおいて MakeShared<T>を使ってOKです。

TSharedRef<T> Ref1 = MakeShared<T>();
TSharedRef<T> Ref2 = MakeShareable(new T()); //通常はonelinerで new した方がいい
TSharedRef<T> Ref3(); //デフォルトコンストラクタ. 内部で new T()が呼ばれて自動でdeleteされないので使っちゃいけない

使用感はどれも似た感じなのですが、微妙に挙動が違います。

  1. MakeShared<T> - 任意のコンストラクタを呼べる. ヒープが分かれないのでキャッシュ効率がいい
  2. MakeShareable<T> - 非侵襲型なのでweak参照と仲良し
  3. コンストラクタ - Please do not use! とコメントがあります

MakeSharableは特殊な限られた状況で使用します。

  1. Tのコンストラクタが呼べず、ファクトリー関数しか公開されていない
  2. やんごとなき事情により、外部から貰ったインスタンスを共有管理したい
  3. 非侵襲型の共有参照をどうしても使いたい
  4. Tのサイズがめちゃくちゃ大きい、かつWeakPtrの寿命がめっちゃ長い

こういうときは MakeShareableもありかなって思います。

MakeShared で作るSharedPtrは 侵襲型です

先に侵襲型と非侵襲型の説明をします。
侵襲型は 型T とコントロールブロックを同じ型に格納するという意味です。
非侵襲型は 型T とコントロールブロックを別々のインスタンスでnewしてポインタで持つという意味です。
もともとの型Tを破壊しなくてもいいという意味で非侵襲です。

疑似コード
// 侵襲型
template<class T>
class FIntrusiveReferenceCntroller
{
    int32 SharedCounter; //コントロールブロック
    int32 WeakCounter;  //コントロールブロック
    uint8 Data[sizeof(T)]; // sizeof(T)のメモリをthisに持っている

    T* Get() { return reinterpret_cast<T*>(Data); }
}

//非侵襲
template<class T>
class FNonIntrusiveReferenceController
{
    int32 SharedCounter; //コントロールブロック
    int32 WeakCounter; //コントロールブロック
    T* Object;
}

侵襲型の場合は同じオブジェクトにTの実体とコントロールブロックがいます。メモリ配置は連続しています。そのため、デリファレンスするときにキャッシュ効率がいいのです。Objectがすぐそばにいるので。

非侵襲型の場合はポインタなので Objectの先はnewで確保したヒープのどこか別の場所を指しています。概念的にthis->Object->とデリファレンスを2回行う必要があります。
実行時効率上は非侵襲型の方が悪くなります。メモリの解放という点では有利です。(後述)

侵襲型はWeakPtrが生きている限り、メモリを解放できません。

弱参照なのにどういうことなんです?厳密に解説すると、SharedCounter == 0になった時に デストラクタを呼び出しWeakCounter == 0になったときに delete thisを実行します。
これはWeakPtrFIntrusiveReferenceCntroller::WeakCounterにアクセスしにくるからです。
WeakPtrが活きている限り、FIntrusiveReferenceControllerのインスタンスをdeleteするわけにはいかないのです。

一方、非侵襲型はオブジェクトが分かれていることから、コントロールブロックを残しつつ、T型を指す部分のメモリ解放ができます。つまり、SharedCounter == 0になった時に delete Object;します。メモリはsizeof(T)の分だけ解放されます。そしてWeakCounter == 0になったときに delete thisを実行します。メモリはsizeof(int) + sizeof(int) + sizeof(T*)で 16byte解放されます。2回deleteが走るんですね。

という訳で、MakeShared<T>で作ったポインタは侵襲型であるからして、長寿命のWeakPtrと相性が悪いです。

この点を考慮した上でやはり、ほとんどのケースにおいてMakeShared<T>で問題ないでしょう。
メモリ消費量が問題になるとしてもそんなに大きな型Tも滅多にないでしょう。たかだか数百byteならば解放が遅れてもいいじゃん、って思います。
ラムダキャプチャしたラムダ式がずっと残り続けるような、そんな特殊な状況なら MakeShareableを使うと改善した気持ちになれるかもしれません。

現実的にはMakeShareableへと対処しなければならないことはあるでしょうが、早すぎる最適化の類であり後回しでいいと思います。スマートポインタよりもアルゴリズムや仕様を見直した方が桁違いに効果を発揮するのですから。

参考

TSharedFromThis

std::enable_shared_from_this に該当する型です。
thisポインタからTSharedPtrを作成したい場合は、TSharedFromThisを継承します。
使いどころとしては木構造や親子構造を作るときに自身のweakポインタを渡したいときや、ラムダキャプチャしたいときです。

AsSharedthisへのSharedPtrを作れます。
以下はラムダ式にthisを渡すサンプルです。

struct FStruct : public TSharedFromThis<FStruct>
{
    void DoAsync()
    {
        // AsShared() によりTSharedRef<FStruct> ThisPtr としてコピーすることで
        // thisへの参照カウントを増やす
        AsyncTask(ENamedThreads::Type::AnyThread, [ThisPtr = AsShared()]()
        {
            // TSharedRefは非nullであるのでnullチェックは不要
            ThisPtr->DoSomthing();
        });
    }
    void DoSomthing(){}
}

void Main()
{
    TSharedPtr<FStruct> Instance = MakeShared<FStruct>();
    Instance->DoAsync();
    Instance.Reset(); // ここで参照カウントを減らしてもラムダ式はちゃんと実行される
}

AsWeak()thisへのWeakPtrを作れます。
親となるファクトリーやマネージャーが子オブジェクトへ自身への参照を渡すときに便利です。循環参照を避けられます。

struct FMyNode : public TSharedFromThis<FMyNode>
{
    explicit FMyNode(TWeakPtr<FMyNode> InParent)
		:Parent(InParent)
	{
	}

	TSharedPtr<FMyNode> CreateChild()
	{
		TWeakPtr<FMyNode> ThisPTr = this->AsWeak();
		TSharedPtr<FMyNode> Child = MakeShared<FMyNode>(ThisPTr);
		return Child;
	}

private:
    TWeakPtr<FMyNode> Parent;
    TSharedPtr<FMyNode> Left;
    TSharedPtr<FMyNode> Right;
}

void Main()
{
    TSharedPtr<FMyNode> Root = MakeShared<FMyNode>(nullptr); //ルートの親はnullptr
    TSharedPtr<FMyNode>= Root->CreateChild();
    TSharedPtr<FMyNode>=->CreateChild();
}

AsSharedで派生型を返したい場合

TSharedFromThisを継承したクラスから更に派生したとき、AsSharedで返るのはベースクラスの型です。少し困るのでそういうときは SharedThis(this)を使います。

struct FMyBase : public TSharedFromThis<FMyBase>
{
    virtual ~FMyBase() = default;
}

struct FMyDerived : public FMyBase
{
    virtual ~FMyDerived() override = default

    void DoFunc()
    {
        TSharedPtr<FMyBase> SharedThisPtr = this->AsShared(); // FMyBase型で返ってきて困る
        TSharedPtr<FMyDerived> SharedDerivedThisPtr= this->SharedThis(this); // FMyDerived型で貰える
    }
}

SharedThis(this) は後述の StaticCastSharedPtrを使って、TSharedPtr<FMyBase>TSharedPtr<FMyDerived>にキャストしているだけです。

TSharedPtrのキャスト

std::static_poiter_castに該当する機能はStaticCastSharedPtr()です。
専らダウンキャストに使いますが型が判明しているならアップキャストにも使えます。使いどころとしてはinterface型にキャストするときでしょう。

class ISoundService
{
    virtual int PlaySE(int SeNumber) = 0;
}

class FSoundServiceImpl : public ISoundService
{
    virtual int PlaySE(int SeNumber) override
    {
        // 実装省略
         return 0;
    }
}
class FMyServiceResolver
{
    // 実装を隠蔽できてinterfaceのみ公開出来ていい感じ
    static TSharedPtr<ISoundService> CreateDefaultSoundService()
    {
        TSharedPtr<FSoundServiceImpl> Impl = MakeShared<FSoundServiceImpl>();
        TSharedPtr<ISoundService> Service = StaticCastSharedPtr<ISoundService>(Impl)
        return Service;
    }
}
void Main()
{
    TSharedPtr<ISoundService> SoundService = FMyServiceResolver::CreateDefaultSoundService();
    SoundService->PlaySE(123);
}

TSharedPtrに格納する必要はあるのかと問われると疑問だし、UWorldSubSystem使えばいいじゃんと言われたそうなのですが、サンプルなので参考にとどめてください。
Slate周りなど、Unreal Editorに関わる箇所は TSharedPtrが使われるので有効かと思います。

TWeakPtr

所有権を持たない弱いポインタです。std::weak_ptrと同じです。TSharedPtrと異なり参照カウントを増やさないため、オブジェクトの破棄を妨げることがありません。

TWeakPtr<FStruct> Poitner;

TWeakPtrを使う際は必ずPinでピン止めして使用中に破棄されないようにしなければなりません。

if(TSharedPtr<FStruct> Ptr = WeakPointer.Pin())
{
    // このPtrが生きているスコープ内において、(*Ptr)は有効
    // なぜなら参照カウントを1増やしているから
    int Val = Ptr->Value;
}

これは WeakPtrの中身を触っている間に、参照カウントが0になりdeleteされる危険があるからです。マルチスレッドでアクセスする場合は操作中にdeleteされないよう、明示的に参照カウントを増やしておきます。自身が使い終わったらPinで止めた参照を速やかに解放するべく、ifスコープにするのが王道です。

TWeakPtr::Get は使わない

TWeakPtrGetPinで留められたスコープ内において、素早くデリファレンスするためのメソッドです。とはいえ、Pinの返り値を使えばいい話なのでほとんど使いどころはありません。

if(TSharedPtr<FStruct> _ = WeakPointer.Pin())
{
    // Pin止めしたスコープ内ではオブジェクトは破棄されないので
    // Get()で直接デリファレンスしてもいい
    // してもいいけどこんな書き方する意味ある?TSharedPtr使いなよ
    int Val = WeakPointer.Get()->Value;
}

nullかどうかが大事な場面であり、中身に興味がない場合はIsValidoperator boolを使います。

if(WeakPointer)
{
    UE_LOG(LogTemp, Dispaly, TEXT("ヌルじゃないことに意味があり中身にアクセスしないのでPinは不要"));
}
else
{
    UE_LOG(LogTemp, Dispaly, TEXT("オブジェクトが破棄されているかnullptrです"));
}

TWeakPtr使用上の注意

TMap, TSetのキーとしてTWeakPtrを使ってはいけません。所有権を持たないが故に、いついかなるときでも破棄される恐れがあるためです。

// TWeakPtrはValue型なら使ってもいい
TMap<int, TWeakPtr<FStruct>> WeakLookUpTable;

つづく

予想より書くことが多かったので中編に続きます。

Reference

GitHubで編集を提案

Discussion