はじめに
おはこんです。もうアドカレの季節ですね。1年があっという間で驚きます。 私は今年からUEに触り始めました。とにかくC++が面倒です
今回は、UEC++を書く上で重要なスマートポインタについて、実際にコードを書いて実験しながら、その挙動を目で見て確認していきたいと思います。
今回注目するもの
-
ユニークポインタ :
TUniquePtr -
シェアードポインタ :
TSharedPtr -
共有の参照:
TSharedRef -
弱いポインタ :
TWeakPtr
UEにおけるスマートポインタの嬉しいところ
-
メモリ管理の自動化:
deleteを忘れてメモリリークする心配がなくなります。個人的にはこれが嬉しいです。 -
安全性の向上: オブジェクトが破棄されたかを
IsValid()等で安全に確認でき、クラッシュを防げます。 - UObject以外でも使える: ガベージコレクション(GC)の対象ではない、純粋なC++クラスでもUEの作法で安全に管理できます。
1. TUniquePtr
コンセプト
ユニークポインタは「1人の所有者」しか許可をしていません。ユニークポインタがスコープを抜けると、オブジェクトが自動的に破棄されます。ユニークポインタはコピーできず、所有権の「転送」のみ可能です。
1-A : 基本的なスコープと破棄
#include "SmartPointerTester.h"
void ASmartPointerTester::TestUniquePtrScope()
{
UE_LOG(LogTemp, Display, TEXT("--- TestUniquePtrScope: START ---"));
{ // 新しいスコープを開始
UE_LOG(LogTemp, Display, TEXT(" Entering inner scope..."));
// MakeUniqueでオブジェクトを作成し、TUniquePtrで所有
TUniquePtr<FMyVisualObject> MyUniquePtr = MakeUnique<FMyVisualObject>(1);
MyUniquePtr->DoSomething();
UE_LOG(LogTemp, Display, TEXT(" Exiting inner scope..."));
} // MyUniquePtrがここでスコープを抜ける
UE_LOG(LogTemp, Display, TEXT("--- TestUniquePtrScope: END ---"));
}
1-B : 所有権の転送
#include "SmartPointerTester.h"
void ASmartPointerTester::TestUniquePtrMove()
{
UE_LOG(LogTemp, Display, TEXT("--- TestUniquePtrMove: START ---"));
TUniquePtr<FMyVisualObject> Ptr1 = MakeUnique<FMyVisualObject>(2);
UE_LOG(LogTemp, Display, TEXT(" Ptr1 created."));
// TUniquePtr<FMyVisualObject> Ptr2 = Ptr1;
// 所有権をPtr1からPtr2へ「移動」する
TUniquePtr<FMyVisualObject> Ptr2 = MoveTemp(Ptr1);
UE_LOG(LogTemp, Display, TEXT(" Ownership MOVED to Ptr2."));
// Ptr1は所有権を失い、nullptrになる
if (!Ptr1.IsValid())
{
UE_LOG(LogTemp, Warning, TEXT(" Ptr1 is now nullptr."));
}
if (Ptr2.IsValid())
{
Ptr2->DoSomething();
}
UE_LOG(LogTemp, Display, TEXT("--- TestUniquePtrMove: END ---"));
// ここでPtr2がスコープを抜け、オブジェクトが破棄される
}
実行結果

いい感じに所有権が移ってますね。
ここでTUniquePtr<FMyVisualObject> Ptr2 = Ptr1;コメントアウトを外し、値をコピーしようとした結果を見てみましょう。

コンパイルエラーになっていますね。このようになる理由として、TUniqurePtrは単一を保証するからコピーは禁止されているいます
... と理由づけできるのでしょうが、恐らく
TUniqurePtrにおいて、pure c++と同様にコピーコンストラクタが禁止されており、どうしても値を移動したい場合は右辺値参照を利用したムーブコンストラクタを用いているのでしょうね...
ここら辺はUEのエンジンコードを見ていないので何とも言えないのですが...
2. TSharedPtr
コンセプト
複数のポインタがオブジェクトの所有権を「共有」できます。シェアードポインタは内部で共有の参照カウントを持ち、オブジェクトから参照されなくなる(カウントが0)になったら、オブジェクトが破棄されます。
2-A 参照アカウントの可視化
void ASmartPointerTester::TestSharedPtrRefCount()
{
UE_LOG(LogTemp, Display, TEXT("--- TestSharedPtrRefCount: START ---"));
TSharedPtr<FMyVisualObject> Ptr1;
{ // 内側スコープ1
UE_LOG(LogTemp, Display, TEXT(" Entering scope 1..."));
// オブジェクトを作成
Ptr1 = MakeShared<FMyVisualObject>(3);
UE_LOG(LogTemp, Display, TEXT(" Ptr1 created. Ref Count = %d"), Ptr1.GetSharedReferenceCount());
{ // 内側スコープ2
UE_LOG(LogTemp, Display, TEXT(" Entering scope 2..."));
// Ptr1をコピー、参照カウントが増加する
TSharedPtr<FMyVisualObject> Ptr2 = Ptr1;
UE_LOG(LogTemp, Display, TEXT(" Ptr2 created by copy. Ref Count = %d"), Ptr1.GetSharedReferenceCount());
Ptr2->DoSomething();
UE_LOG(LogTemp, Display, TEXT(" Exiting scope 2..."));
} // Ptr2がスコープを抜ける、参照カウントが減る
UE_LOG(LogTemp, Display, TEXT(" Ptr2 destroyed. Ref Count = %d"), Ptr1.GetSharedReferenceCount());
UE_LOG(LogTemp, Display, TEXT(" Exiting scope 1..."));
} // Ptr1がスコープを抜ける、参照カウントが0になる
UE_LOG(LogTemp, Display, TEXT("--- TestSharedPtrRefCount: END ---"));
}
3. TSharedRef
コンセプト
TSharedRefは、参照を共有するといった機能は、TSharedPtrと同様です。しかし、TSharedPtrと違い、Nullになることがなく、参照されているオブジェクトが必ずあることを保証します。ここが大きな違いです。
3-A : Nullにならない共有ポインタ
#include "SmartPointerTester.h"
void ProcessObjectSafely(TSharedRef<FMyVisualObject> SafeObj)
{
UE_LOG(LogTemp, Display, TEXT("ProcessObjectSafely Called. No IsValid() check needed!"));
SafeObj->DoSomething();
}
void ASmartPointerTester::TestSharedRef()
{
UE_LOG(LogTemp, Display, TEXT("--- TestSharedRef: START ---"));
{
UE_LOG(LogTemp, Display, TEXT(" Creating SharedRef..."));
// MakeSharedの戻り値はTSharedRef。TSharedPtrへの代入は「暗黙の変換」が行われている。
TSharedRef<FMyVisualObject> MyRef = MakeShared<FMyVisualObject>(5);
// 関数に渡す
ProcessObjectSafely(MyRef);
UE_LOG(LogTemp, Display, TEXT(" Converting Ref to Ptr..."));
// RefはいつでもPtrになれる
TSharedPtr<FMyVisualObject> MyPtr = MyRef;
UE_LOG(LogTemp, Display, TEXT(" Ptr RefCount: %d"), MyPtr.GetSharedReferenceCount());
} // ここでスコープを抜け、RefもPtrも消えるので破棄される
UE_LOG(LogTemp, Display, TEXT("--- TestSharedRef: END ---"));
}
実験結果

TSharedRefは、有効なオブジェクトしか受け付けないため、IsValid()関数自体を持ちません(チェック不要)。確実にnon-nullなポインタ参照をしたいのであれば、TSharedRefで定義し、TSharedPtrで有効なオブジェクトを参照すれば、安全性が保証されます。またTSharedRefからTSharedPtrへの変換は暗黙的に行えます。
4. TWeakPtr
コンセプト
TWeakPtrはオブジェクトの所有はせずに、「監視」を行います。外からオブジェクトを確認する監視員みたいな役割です。そのため、参照カウントは増やさず、オブジェクトが(TShareddPtrなどによって)破棄されたどうかを外から安全に確認できます。
4-A : オブジェクトの有効期限切れを監視
void ASmartPointerTester::TestWeakPtrExpiry()
{
UE_LOG(LogTemp, Display, TEXT("--- TestWeakPtrExpiry: START ---"));
TWeakPtr<FMyVisualObject> MyWeakPtr;
{ // TSharedPtr用のスコープ
UE_LOG(LogTemp, Display, TEXT(" Entering SharedPtr scope..."));
TSharedPtr<FMyVisualObject> MySharedPtr = MakeShared<FMyVisualObject>(4);
// TSharedPtrからTWeakPtrを作成
MyWeakPtr = MySharedPtr;
// TWeakPtrは参照カウントを増やさない
UE_LOG(LogTemp, Display, TEXT(" SharedPtr created. Ref Count = %d"), MySharedPtr.GetSharedReferenceCount());
// TWeakPtrを使うには、Pin()でTSharedPtrに「昇格」させる
if (TSharedPtr<FMyVisualObject> LockedPtr = MyWeakPtr.Pin())
{
UE_LOG(LogTemp, Display, TEXT(" WeakPtr is Valid. Accessing..."));
LockedPtr->DoSomething();
}
UE_LOG(LogTemp, Display, TEXT(" Exiting SharedPtr scope..."));
} // MySharedPtrがスコープを抜け、オブジェクトが破棄される
UE_LOG(LogTemp, Warning, TEXT(" SharedPtr destroyed."));
// オブジェクトが破棄された後、TWeakPtrはどうなるか?
if (MyWeakPtr.IsValid())
{
UE_LOG(LogTemp, Error, TEXT(" WeakPtr is still valid (This shouldn't happen)"));
}
else
{
UE_LOG(LogTemp, Warning, TEXT(" WeakPtr is now EXPIRED (Invalid)."));
}
// Pin()してもnullptrが返る
if (TSharedPtr<FMyVisualObject> LockedPtr = MyWeakPtr.Pin())
{
UE_LOG(LogTemp, Error, TEXT(" WeakPtr could be locked (This shouldn't happen)"));
}
else
{
UE_LOG(LogTemp, Warning, TEXT(" WeakPtr.Pin() returns nullptr."));
}
UE_LOG(LogTemp, Display, TEXT("--- TestWeakPtrExpiry: END ---"));
}
実行結果

しっかりTSharedが破棄された後に、TWeakPtrが無効になっていますね。
5. TWeakPtrの必要性
コンセプト
そとから、何もポインタに作用を働かせないTWeakPtrがなぜ必要になるか。それはTSharedPtr同士がお互いに持ち合う循環参照の解決ができるためです。循環参照が起こると、参照カウントが0にならずメモリリークが発生します。
5-A : 循環参照によるメモリリーク
class FChild;
class FParent
{
public:
TSharedPtr<FChild> ChildPtr;
FParent() { UE_LOG(LogTemp, Warning, TEXT(" Parent CREATED")); }
~FParent() { UE_LOG(LogTemp, Warning, TEXT(" Parent DESTROYED")); }
};
class FChild
{
public:
TSharedPtr<FParent> ParentPtr; // ここが問題
FChild() { UE_LOG(LogTemp, Warning, TEXT(" Child CREATED")); }
~FChild() { UE_LOG(LogTemp, Warning, TEXT(" Child DESTROYED")); }
};
void TestCircularReference_Problem()
{
UE_LOG(LogTemp, Display, TEXT("--- TestCircularReference (PROBLEM): START ---"));
{ // スコープ
TSharedPtr<FParent> MyParent = MakeShared<FParent>();
TSharedPtr<FChild> MyChild = MakeShared<FChild>();
// 互いにTSharedPtrで参照しあう
MyParent->ChildPtr = MyChild;
MyChild->ParentPtr = MyParent;
UE_LOG(LogTemp, Display, TEXT(" Parent Ref Count: %d"), MyParent.GetSharedReferenceCount()); // 2になる
UE_LOG(LogTemp, Display, TEXT(" Child Ref Count: %d"), MyChild.GetSharedReferenceCount()); // 2になる
UE_LOG(LogTemp, Display, TEXT(" Exiting scope..."));
} // MyParentとMyChildがスコープを抜ける
// 本来ここでデストラクタが呼ばれる
UE_LOG(LogTemp, Error, TEXT("--- TestCircularReference (PROBLEM): END (Objects should be destroyed!) ---"));
}
実行結果

実行結果を見ると、本来TestCircularReference_Problems()のスコープ外に出たのであればMyParentとMyChildのデストラクタが呼ばれるはずですが、今回のケースでは呼ばれませんでした。
その理由として、TSharedPtrのルールがスコープを抜けたら即座にオブジェクトを破棄するのではなく、参照カウント(Reference Count)が0になったら破棄することになっているからです。
今回のケースでは、お互いがお互いを参照し合っているため、スコープを抜けた後も参照カウントが完全に0にならず、1残ってしまいます。具体的なカウントの変化は以下の通りです。
-
Parentの参照カウント: 2→1
ローカル変数MyParentは消えましたが、「Childオブジェクトの中にあるParentPtr」 がまだ Parent を掴んでいます -
Childの参照カウント: 2→1
ローカル変数MyChildは消えましたが、「Parentオブジェクトの中にあるChildPtr」 がまだChildを掴んでいます
この結果、メモリ上には以下の状態だけが取り残されます。
-
Parentオブジェクト (カウント: 1) ←
Childが持っている -
Childオブジェクト (カウント: 1) ←
Parentが持っている
これはまさに、以下のようなデッドロック状態です。
「自分が死ぬためには相手に手を離してもらう必要があるが、相手が死なないと手が離れない」 といった、なんと悲しいジレンマでしょうか。この相互依存により、参照カウントが永遠に0にならず、デストラクタが永久に呼ばれない現象が起きてしまいます。
5-B : 解決策(TWeakPtrの利用)
先ほど示したデッドロック状態にならないよう、FChildからFParentへの参照をTWeakPtrに変更することで循環参照を断ち切ります。
#include "SmartPointerTester.h"
class FParent_Solution;
class FChild_Solution
{
public:
// 子は親を「監視」するだけ
TWeakPtr<FParent_Solution> ParentPtr;
FChild_Solution() { UE_LOG(LogTemp, Warning, TEXT(" Child CREATED")); }
~FChild_Solution() { UE_LOG(LogTemp, Warning, TEXT(" Child DESTROYED")); }
};
class FParent_Solution
{
public:
// 親は子を「所有」する
TSharedPtr<FChild_Solution> ChildPtr;
FParent_Solution() { UE_LOG(LogTemp, Warning, TEXT(" Parent CREATED")); }
~FParent_Solution() { UE_LOG(LogTemp, Warning, TEXT(" Parent DESTROYED")); }
};
void ASmartPointerTester::TestCircularReference_Solution()
{
UE_LOG(LogTemp, Display, TEXT("--- TestCircularReference (SOLUTION): START ---"));
{ // スコープ
TSharedPtr<FParent_Solution> MyParent = MakeShared<FParent_Solution>();
TSharedPtr<FChild_Solution> MyChild = MakeShared<FChild_Solution>();
// 親が子を所有 (Child Ref Count = 2)
MyParent->ChildPtr = MyChild;
// 子が親を監視 (Parent Ref Count = 1 のまま)
MyChild->ParentPtr = MyParent;
UE_LOG(LogTemp, Display, TEXT(" Parent Ref Count: %d"), MyParent.GetSharedReferenceCount()); // 1
UE_LOG(LogTemp, Display, TEXT(" Child Ref Count: %d"), MyChild.GetSharedReferenceCount()); // 2
UE_LOG(LogTemp, Display, TEXT(" Exiting scope..."));
} // MyParentとMyChildがスコープを抜ける
UE_LOG(LogTemp, Display, TEXT("--- TestCircularReference (SOLUTION): END ---"));
}
実行結果

実行結果よりMyParentが破棄され、RefCountが0になり、Parentが破棄されます。そして、Parentの破棄に伴いChildPtrも破棄され、ChildのRefCountが0になり、Childも破棄されます。このようにして循環参照を防ぎ、オブジェクトの適切なライフサイクルを回せます。
おわり
いかがでしょうか、C++にはたくさんの落とし穴があります。適切にスマートポインタを使い分けることで、安全なC++ライフを送りましょう。(適切なポインタ管理なんて人間のやる仕事じゃないよ(´д`;)トホホ...)
参考
- Unreal スマートポインタライブラリ
-
共有のポインタ
公式です -
【UE】Unreal C++ のスマートポインタについて
より網羅的に記載されています。非常に参考になりました。 - Qtの循環参照でハマった話

