【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の革新性を支える「ライフタイム」、メモリー関連の脆弱性を防ぐ

プリミティブ型

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

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

スマートポインタの概要

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

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

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

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

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

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

//コンストラクタ(このクラスのインスタンス生成時に呼び出される)
ASampleActor::ASampleActor()
{
    // UObject クラスの派生クラスを生成してポインタをメンバ変数に格納する
    SampleObjectPtr = NewObject<UObject>(this);
}

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

前述のように UObject クラスの派生クラスはガベージコレクションにより管理されているが、ガベージコレクションのタイミングは不定であり、「MarkPendingKill()」や「ConditionalBeginDestroy()」等を明示的に呼び出して任意のタイミングでガベージコレクションを実行させる事も出来る。

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

void Hoge()
{
    //スマートポインタを用いた変数を宣言(メモリ領域を確保)
    TSharedPtr<FSampleStruct> SampleStructPtr = MakeShareable(new FSampleStruct());

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

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

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

//ポインタが nullptr かどうかを比較して null チェック
//(UObject クラスの派生クラスの場合は「IsValid(UObject*)」で null チェックするのが一般的)
if(SampleClassPtr != nullptr) 
{
    SampleClassPtr->Hoge();
}
//スマートポインタ
TSharedPtr<FSampleStruct> SampleStructPtr = MakeShareable(new FSampleStruct());

//「{スマートポインタ}.IsValid()」で null チェックするのが一般的
//「if(SampleStructPtr)」といった形でも null チェックが可能
if(SampleStructPtr.IsValid())
{
    SampleStructPtr->Hoge();
}

更に一部のスマートポインタは宣言時にテンプレート引数で「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 を代入する事が出来ず、常に有効なポインタが格納されているため、nullptr にならない事の保証されているポインタに対して使用可能なスマートポインタであり、常に有効なため「IsValid()」が用意されていない。

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

// TSharedRef には nullptr を代入できない(コンパイルエラーを吐く)
TSharedRef<FSampleStruct> SharedRef = nullptr;

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

TWeakPtr

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

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

    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 も無効になる
    //もし WeakPtr 変数が宣言時に 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;

その他

参考記事

用語

スマートポインタ

その他

お問い合わせ

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