2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

なぜ、あなたのデストラクタは呼ばれないのか? UE5スマートポインタの寿命をログで可視化実験してみた

Last updated at Posted at 2025-12-08

はじめに

おはこんです。もうアドカレの季節ですね。1年があっという間で驚きます。 私は今年からUEに触り始めました。とにかくC++が面倒です
今回は、UEC++を書く上で重要なスマートポインタについて、実際にコードを書いて実験しながら、その挙動を目で見て確認していきたいと思います。

今回注目するもの

  • ユニークポインタ : TUniquePtr
  • シェアードポインタ : TSharedPtr
  • 共有の参照TSharedRef
  • 弱いポインタ : TWeakPtr

UEにおけるスマートポインタの嬉しいところ

  1. メモリ管理の自動化: delete を忘れてメモリリークする心配がなくなります。個人的にはこれが嬉しいです。
  2. 安全性の向上: オブジェクトが破棄されたかを IsValid() 等で安全に確認でき、クラッシュを防げます。
  3. UObject以外でも使える: ガベージコレクション(GC)の対象ではない、純粋なC++クラスでもUEの作法で安全に管理できます。

1. TUniquePtr

コンセプト

ユニークポインタは「1人の所有者」しか許可をしていません。ユニークポインタがスコープを抜けると、オブジェクトが自動的に破棄されます。ユニークポインタはコピーできず、所有権の「転送」のみ可能です。

1-A : 基本的なスコープと破棄

TestUniquePtrScope.cpp
#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 ---"));
}

実行結果
TestUniqurePtrScope.png

1-B : 所有権の転送

TestUniquePtrMove.cpp
#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がスコープを抜け、オブジェクトが破棄される
}

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

UniquePtrComplileError.png
コンパイルエラーになっていますね。このようになる理由として、TUniqurePtrは単一を保証するからコピーは禁止されているいます

... と理由づけできるのでしょうが、恐らくTUniqurePtrにおいて、pure c++と同様にコピーコンストラクタが禁止されており、どうしても値を移動したい場合は右辺値参照を利用したムーブコンストラクタを用いているのでしょうね...
ここら辺はUEのエンジンコードを見ていないので何とも言えないのですが...

2. TSharedPtr

コンセプト

複数のポインタがオブジェクトの所有権を「共有」できます。シェアードポインタは内部で共有の参照カウントを持ち、オブジェクトから参照されなくなる(カウントが0)になったら、オブジェクトが破棄されます。

2-A 参照アカウントの可視化

TestSharedPtrRefCount.cpp
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 ---"));
}

実行結果
TestSharedPtrRefCount.png

3. TSharedRef

コンセプト

TSharedRefは、参照を共有するといった機能は、TSharedPtrと同様です。しかし、TSharedPtrと違い、Nullになることがなく、参照されているオブジェクトが必ずあることを保証します。ここが大きな違いです。

3-A : Nullにならない共有ポインタ

TestSharedRef.cpp
#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 ---"));
}

実験結果
TestSharedRef.png
TSharedRefは、有効なオブジェクトしか受け付けないため、IsValid()関数自体を持ちません(チェック不要)。確実にnon-nullなポインタ参照をしたいのであれば、TSharedRefで定義し、TSharedPtrで有効なオブジェクトを参照すれば、安全性が保証されます。またTSharedRefからTSharedPtrへの変換は暗黙的に行えます。

4. TWeakPtr

コンセプト

TWeakPtrはオブジェクトの所有はせずに、「監視」を行います。外からオブジェクトを確認する監視員みたいな役割です。そのため、参照カウントは増やさず、オブジェクトが(TShareddPtrなどによって)破棄されたどうかを外から安全に確認できます。

4-A : オブジェクトの有効期限切れを監視

TestWeakPtrExpiry.cpp
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 ---"));
}

実行結果
TestWeakPtrExpiry.png
しっかりTSharedが破棄された後に、TWeakPtrが無効になっていますね。

5. TWeakPtrの必要性

コンセプト

そとから、何もポインタに作用を働かせないTWeakPtrがなぜ必要になるか。それはTSharedPtr同士がお互いに持ち合う循環参照の解決ができるためです。循環参照が起こると、参照カウントが0にならずメモリリークが発生します。

5-A : 循環参照によるメモリリーク

TestCircularReference_Problem.cpp
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.png
実行結果を見ると、本来TestCircularReference_Problems()のスコープ外に出たのであればMyParentMyChildのデストラクタが呼ばれるはずですが、今回のケースでは呼ばれませんでした。
その理由として、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に変更することで循環参照を断ち切ります。

TestCircularReference_Solution.cpp
#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 ---"));
}

実行結果
TestCircularReference_Solve.png
実行結果よりMyParentが破棄され、RefCountが0になり、Parentが破棄されます。そして、Parentの破棄に伴いChildPtrも破棄され、ChildのRefCountが0になり、Childも破棄されます。このようにして循環参照を防ぎ、オブジェクトの適切なライフサイクルを回せます。

おわり

いかがでしょうか、C++にはたくさんの落とし穴があります。適切にスマートポインタを使い分けることで、安全なC++ライフを送りましょう。(適切なポインタ管理なんて人間のやる仕事じゃないよ(´д`;)トホホ...)

参考

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?