【UE】Unreal C++ のスマートポインタについて

Unreal Engine

はじめに

押さえておきたい用語

ポインタ

変数の値はコンピュータのメモリに保存される。
アドレスは値が格納されているメモリの位置を表す。
ポインタ変数、またはポインタとは、アドレスを保持する変数のこと。

C++ポインタまとめ

メモリ領域の確保

プログラムさんが「メモリのここの部分は今から僕が使うからね。他の人は使わないでね」と場所取りをすることです。

メモリ領域の確保とは

メモリの解放

プログラムさんが「メモリのここの部分は今から僕が使うからね。他の人は使わないでね」と場所取りしていたメモリ(の一部)を「ここはもう使わないから他の人が使っていいよ!」と、誰でも使える状態に戻すことです。

メモリの解放とは

派生クラス

クラスの継承の話で登場するクラスのひとつであり新しく作られた方(継承先)のクラスのこと

派生クラスとは

スコープ

「範囲」をカッコ付けて言った表現。
あるいはプログラミングの世界で登場する用語で「おまえの名前が通じるのは、ここまでだ!」な影響範囲のことです。

スコープとは

循環参照

「その指示に従うと、ずっとたらい回しにされますよ!」な状態のこと。
もう少し具体的に書くとAには「Bを見ろ」と書いてあって、Bには「Aを見ろ」と書いてあるように、いつまでたってもゴールに辿りつけないような指示になっていることです。

循環参照とは

マルチスレッド

処理の開始から終了まで線を引いたときに、枝分かれの発生する処理のこと。
つまり並行処理が発生するプログラムのことです。

マルチスレッドとは

スレッドセーフ

「マルチスレッドでも(並行処理が発生するようになっていても)大丈夫だよ~」のこと。
もう少し具体的に書くと「マルチスレッドな環境で、複数のスレッドが同時に実行したり同じデータを扱ったりしても、ぶっ壊れないよ~」のことです。

スレッドセーフとは

ヒープ領域

動的に確保と解放を繰り返せるメモリ領域のことです。

ヒープ領域とは?スタック領域との違いや具体的な管理方法を解説!

メモリの二重解放

new などで確保したメモリ領域(ヒープ領域)を2回 delete などで解放することを言う.

メモリの二重解放回避テク

ダングリングポインタ

プログラムの実行中にポインタが不正なメモリ領域を指している状態。

ダングリング・ポインタ(dangling pointer)

ガベージコレクション(GC)

プログラムで使っていないメモリを解放する機能のことです。

ガベージコレクションとは

UObject クラス

UObjectは、UE4においてnewやdeleteを置き換える概念と考えると分かりやすいかもしれません。 UE4で定義される多くのクラスはUObjectを継承し、直接または間接にNewObject関数で生成します。
一方、UObjecctの解放は必ずGCによって行われます。プログラマは自分でUObjectを解放することはできません。その代わりに、ポインタにUPROPERTYを付ける事によりオブジェクトを参照中であることをGCに伝えることになります。UObjectへの全ての参照が無くなるといずれGCにより解放されます。

【UE4】UObjectのPendingKillについて

インスタンス

オブジェクト指向の話で出てくる用語のひとつで「実際に作ったもの」に相当するものが「インスタンス」です。

インスタンスとは

ライフタイム

「生存期間」という意味だ。プログラムでは、ライフタイムには2種類ある。「値のライフタイム」と「参照のライフタイム」だ。値のライフタイムはその値のスコープが切れるまでで、参照のライフタイムは値への参照が使われる期間になる。

Rustの革新性を支える「ライフタイム」、メモリー関連の脆弱性を防ぐ

プリミティブ型

そのプログラミング言語に最初から用意されている変数の型のうち、基本的な型っぽいやつ

プリミティブ型【変数の型】とは

ラッパー

プログラミングの分野では、既存のコード(関数、クラス、ライブラリなど)を包んで、別の形で提供することがあります。この手法を「ラップする」とか「ラッピング(wrapping)」といい、ラップして作成した新しいクラスや関数を「ラッパー(wrapper)」といいます。

「ラッパー」・「ラップする」・「ラッピング」の意味

スマートポインタの概要

通常、C++ で new 等を用いてメモリ領域(ヒープ領域)を確保したら、その変数の使用後に delete 等を用いてメモリを解放する必要がある。

// new を用いて変数を初期化し、メモリ領域(ヒープ領域)を確保
SampleClass* SampleClassPtr = new SampleClass();

//変数を使用
SampleClassPtr->SampleFunction();

// delete を用いて変数を破棄(メモリを解放)
delete SampleClassPtr;

(「int32 Num = 1;」のように new 等を用いずに変数を宣言、初期化した場合はヒープ領域ではなくてスタック領域にメモリが割り当てられ、スコープを抜けると自動でメモリが解放される)

しかし、UObject クラスのポインタの場合は UE 独自のガベージコレクションにより、そのオブジェクトがどこからも参照されなくなると自動で破棄(メモリが解放)されるため、デストラクタ等で手動でメモリを解放する必要が無い。

//コンストラクタ
ASampleActor::ASampleActor()
{
    // UObject クラスを生成し、そのオブジェクトのポインタをメンバ変数に代入する
    SampleObjectPtr = NewObject<UObject>();
}

//デストラクタ
ASampleActor::~ASampleActor()
{
    //通常はメンバ変数のメモリを解放する処理を記述する必要があるが、UObject クラスはガベージコレクションにより自動で破棄(メモリが解放)されるため、手動でメモリを解放する処理を記述する必要が無い
}

前述のように UObject クラスはガベージコレクションにより管理されているが、ガベージコレクトのタイミングは不定であり、「UEngine::ForceGarbageCollection()」等を呼び出して任意のタイミングでガベージココレクトさせる事も出来る。

そして基本的にメモリ領域(ヒープ領域)を確保したポインタのライフタイムを明確に制御するために使用するものがスマートポインタである。

void Hoge()
{
    //スマートポインタを用いた変数を初期化(ヒープ領域を確保)
    TSharedPtr<FSampleStruct> SampleStructPtr = MakeShared<FSampleStruct>();

    //変数を使用
    SampleStructPtr->SampleFunction();
}
//変数のスコープを抜けると自動でオブジェクトが破棄(メモリが解放)される

また、通常のポインタは nullptr がどうかを確認する事で有効なポインタかチェックするが、UE のスマートポインタの場合は「{スマートポインタ}.IsValid()」を使用してポインタが有効かチェックするのが一般的である。

//生ポインタ
SampleClass* SampleClassPtr = new SampleClass();

//ポインタが nullptr かどうかを比較して有効性を確認
//(UObject クラスの場合は「IsValid(UObject*)」で確認するのが一般的)
if(SampleClassPtr != nullptr) 
{
    SampleClassPtr->SampleFunction();
}
//スマートポインタ
TSharedPtr<FSampleStruct> SampleStructPtr = MakeShared<FSampleStruct>();

//「{スマートポインタ}.IsValid()」で確認するのが一般的
if(SampleStructPtr.IsValid())
{
    SampleStructPtr->SampleFunction();
}

更に一部のスマートポインタは宣言時にテンプレート引数で「ESPMode::ThreadSafe」を渡す事でスレッドセーフなポインタにする事が出来る。
(UE5 からはスマートポインタのテンプレート第二引数がデフォルトで「ESPMode::ThreadSafe」になったという噂もある)

TSharedPtr<FSampleStruct, ESPMode::ThreadSafe> SampleStructPtr = MakeShared<FSampleStruct, ESPMode::ThreadSafe>();

スマートポインタを使用すべき場合と使用しなくても良い場合

スマートポインタを使用すべき場合

  • とある関数内で new 等を用いて動的にメモリ領域(ヒープ領域)を確保したオブジェクトが関数のスコープを抜けても破棄(メモリを解放)されずに生存し続けて欲しい場合
  • 1つのポインタを複数の場所で共有し、共有した全てのオブジェクトが破棄(メモリから解放)された際にそのポインタの指し示すオブジェクトを自動で破棄(メモリから解放)して欲しい場合
  • 所有権の移動や共有を明確にしてダングリングポインタや二重解放などを事前に防ぎたい場合

スマートポインタを使用しなくても良い場合

  • new 等を用いる事なくメモリ領域を確保した場合(ヒープ領域ではなくてスタック領域を確保した場合)
  • int や float 等のプリミティブ型や非常に単純な構造体などの軽量な値を扱う場合
  • new や delete 等を用いて既にオブジェクトのライフタイムを明確に制御してある場合

スマートポインタ一覧

UObject クラスを継承できないオブジェクトで使用可能

TUniquePtr

所有権を持つ唯一のスマートポインタであり、ポインタのコピーは出来ないが「MoveTemp()」 を使用する事で所有権を移す事が可能で、所有権の移譲後は元のポインタは 無効になり、ポインタのスコープを抜けると自動でオブジェクトを破棄(メモリを解放)してくれるため、ダングリングポインタや二重解放などの問題を事前に回避する事ができ、有効なポインタが同時に複数存在しない事が保証される。

TUniquePtr<FSampleStruct> UniquePtr1 = MakeUnique<FSampleStruct>();

//他の TUniquePtr をコピーして渡そうとするとコンパイルエラーを吐く
TUniquePtr<FSampleStruct> UniquePtr2;// = UniquePtr1;

//「MoveTemp()」を使用して所有権を渡す事でポインタを受け取る事が出来る
UniquePtr2 = MoveTemp(UniquePtr1);

//所有権移譲後の元のポインタは無効になる
if (!UniquePtr1.IsValid()) UE_LOG(LogTemp, Log, TEXT("UniquePtr1 is not valid!"));

//所有権移譲後の受取先のポインタは有効
if (UniquePtr2.IsValid()) UE_LOG(LogTemp, Log, TEXT("UniquePtr2 is valid!"));

TSharedPtr

ポインタをコピーして共有する事ができ、共有した全てのポインタが使用されなくなる(オブジェクトが参照されなくなる)と自動でオブジェクトを破棄(メモリを解放)するが、循環参照を引き起こす可能性があり、その場合は全ての参照が無くなってもオブジェクトは破棄(メモリは解放)されない。

TSharedPtr<FSampleStruct> SharedPtr1 = MakeShared<FSampleStruct>();

// TSharedPtr 同士はポインタをコピーして渡す事が出来る
TSharedPtr<FSampleStruct> SharedPtr2 = SharedPtr1;
TSharedPtr<FSampleStruct> SharedPtr3 = SharedPtr2;

//ユニークな所有権を渡している訳では無いため受け渡し後でも元のポインタは有効
if (SharedPtr1.IsValid()) UE_LOG(LogTemp, Log, TEXT("SharedPtr1 is valid!"));

//参照カウント(ポインタを何個共有しているか)を確認できる
//このコードでは「3」と表示される
UE_LOG(LogTemp, Log, TEXT("The shared reference count is %d"), SharedPtr1.GetSharedReferenceCount());

TSharedRef

TSharedPtr と機能はほぼ同じだが、TSharedPtr とは違って TSharedRef には nullptr を代入する事が出来ず、常に有効なポインタが格納されているため、破棄(メモリを解放)される事の無いオブジェクトに対して使用可能なスマートポインタであり、常に有効なため「IsValid()」が用意されていない。

// TSharedPtr には nullptr を代入できる
TSharedPtr<FSampleStruct> SharedPtr = nullptr;

// TSharedRef に nullptr を代入しようとするとコンパイルエラーが発生する
TSharedRef<FSampleStruct> SharedRef = nullptr;

// TSharedRef は nullptr になり得ないため「IsValid()」を含んでいない
//以下のコードでは「IsValid()」が定義されていないというコンパイルエラーが発生する
if(SharedRef.IsValid())
{

}

TWeakPtr

TSharedPtr からポインタをコピーして受け取る事が可能だが、TSharedPtr とは違って共有可能な所有権を持たず、ポインタのライフタイムは受け取り元の TSharedPtr に依存するため、元の TSharedPtr が参照されなくなると、例え TWeakPtr のスコープを抜けていなくても TWeakPtr の指し示しているオブジェクトは破棄される(メモリが解放される)ため、主に循環参照(参照カウントがゼロにならない事)を避けるために使用される。

void ASampleActor::BeginPlay()
{
    Super::BeginPlay();

    //スコープが「BeginPlay()」内である SharedPtr ローカル変数を初期化
    TSharedPtr<FSampleStruct> SharedPtr = MakeShared<FSampleStruct>();

    //TSharedPtr をクラスのメンバ変数である TWeakPtr にコピーして渡す
    WeakPtr = SharedPtr;

    //この時点ではまだ TSharedPtr のスコープを抜けていないため TWeakPtr も有効
    if (WeakPtr.IsValid()) UE_LOG(LogTemp, Log, TEXT("WeakPtr is valid!"));
}

void ASampleActor::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    // TSharedPtr のスコープを抜けた後は TWeakPtr も無効になる
    //(TWeakPtr はオブジェクトのライフサイクルに影響を与えない)
    //もし WeakPtr 変数が TWeakPtr ではなく TSharedPtr を使用していた場合は、ここでも WeakPtr 変数は有効
    if (!WeakPtr.IsValid()) UE_LOG(LogTemp, Log, TEXT("WeakPtr is not valid!"));
}

UObject クラスに対して使用可能

TWeakObjectPtr

TWeakPtr と機能はほぼ同じで TWeakPtr では UObject クラスを対象に出来なかったが、TWeakObjectPtr では UObject クラスを対象にでき、ガベージコレクションと連携して UObject がガベージコレクションによって破棄(メモリを解放)されてもポインタが自動的に無効化されるため、安全に UObject クラスを参照する事が出来る。

UObject* Object = NewObject<UObject>();

// TWeakPtr では UObject クラスを対象に出来ないため、このコードではコンパイルエラーが発生する
TWeakPtr<UObject> WeakPtr = Object;

// TWeakObjectPtr では UObject クラスを対象に出来るため、このコードでもコンパイルエラーは発生しない
TWeakObjectPtr<UObject> WeakObjectPtr = Object;

TObjectPtr

UE5 から新たに導入されたスマートポインタであり、生ポインタとほぼ同じ挙動をするが、「UPROPERTY()」においては生ポインタよりも TObjectPtr を使用する事が推奨されており、UObject クラスに対して使用する事が出来る。

private:
    UPROPERTY()
    TObjectPtr<UObject> Object;

その他

「MakeShared()」と「MakeShareable()」の違い

MakeShared()

「MakeShared()」は何も無い状態から TSharedPtr を1から作成する際に使用する。

TSharedPtr<FSampleStruct> SharedPtr = MakeShared<FSampleStruct>();

MakeShareable()

「MakeShareable()」は既にオブジェクトが存在する際、そのオブジェクトのポインタを TSharedPtr に変換(ラップ)したい時に使用する。

// TSharedPtr を作成する前から存在するポインタ
FSampleStruct* SampleStructPtr = new FSampleStruct();

//既に存在するポインタを TSharedPtr に変換(ラップ)する
TSharedPtr<FSampleStruct> SharedPtr = MakeShareable(SampleStructPtr);

TSharedPtr と TSharedRef の相互変換

TSharedRef → TSharedPtr

TSharedPtr では nullptr も許容されるため、そのまま暗黙的に変換できる。

TSharedPtr<FSampleStruct> SharedPtr = SharedRef;

TSharedPtr → TSharedRef

TSharedRef では nullptr が許容されていないが、TSharedPtr では nullptr が許容されているため、TSharedPtr から TSharedRef に変換する際は一度「IsValid()」で TSharedPtr が有効かどうか確認した後に「ToSharedRef()」で TSharedRef に変換する必要がある。

// TSharedPtr が有効か確認
if(SharedPtr.IsValid())
{
    TSharedRef<FSampleStruct> SharedRef = SharedPtr.ToSharedRef();
}

最後に

参考記事

用語

スマートポインタ

その他

お問い合わせ

    タイトルとURLをコピーしました