0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Unity】MonoBehaviour をインターフェースにキャストすると、正しいnullチェックが出来ない

0
Last updated at Posted at 2025-09-08

導入

UnityEngine.Object では、以下のような演算子オーバーライドがあります。

public static implicit operator bool([MaybeNullWhen(false)][NotNullWhen(true)] Object exists)
{
    return !CompareBaseObjects(exists, null);
}

public static bool operator ==(Object x, Object y)
{
    return CompareBaseObjects(x, y);
}

public static bool operator !=(Object x, Object y)
{
    return !CompareBaseObjects(x, y);
}

いずれもCompareBaseObjects()を読んでいて、中身はこんな感じでした。
null比較の場合はIsNativeObjectAlive()を介して判定しているようです。

private static bool CompareBaseObjects(Object lhs, Object rhs)
{
    bool flag = (object)lhs == null;
    bool flag2 = (object)rhs == null;
    if (flag2 && flag)
    {
        return true;
    }

    if (flag2)
    {
        return !IsNativeObjectAlive(lhs);
    }

    if (flag)
    {
        return !IsNativeObjectAlive(rhs);
    }

    return lhs.m_InstanceID == rhs.m_InstanceID;
}

private static bool IsNativeObjectAlive(Object o)
{
    if (o.GetCachedPtr() != IntPtr.Zero)
    {
        return true;
    }

    if (o is MonoBehaviour || o is ScriptableObject)
    {
        return false;
    }

    return DoesObjectWithInstanceIDExist(o.GetInstanceID());
}

[MethodImpl(MethodImplOptions.InternalCall)]
[NativeMethod(Name = "UnityEngineObjectBindings::DoesObjectWithInstanceIDExist", IsFreeFunction = true, IsThreadSafe = true)]
internal static extern bool DoesObjectWithInstanceIDExist(int instanceID);

null比較だけ違う処理の理由

詳しい説明は割愛しますが、UnityはC#とC++が連携して動いています。
Unityオブジェクトの裏にはC++のネイティブオブジェクトが存在し、
異なるライフサイクルを持っている = 破棄されるタイミングが異なる のが原因です。

それで、結局何が言いたいのかというと、
Unityオブジェクトは「==」でnullチェックしないといけないということです。
知っていた人には、当たり前かもしれないですが…。

private void F()
{
-   string name = obj?.name ?? "No Name";                // NG
+   string name = (obj != null) ? obj.name : "No Name";  // OK
}

インターフェースにキャストすると…

ここで、このUnityオブジェクトがインターフェースを継承しており、それにキャストしたとします。
すると、インターフェースの継承ツリーにはUnityEngine.Objectクラスがいないので、
演算子オーバーライドが反映されないのでは? という懸念を持ちました。

IHoge castedObj = obj as IHoge;

実験してみた

以下2スクリプトを作成し、Managerクラスのみゲームオブジェクトにアタッチします。
オブジェクトをDestroy()してみて、その後nullチェックします。

調べたいこと

  • MonoBehaviour をインターフェースにキャストすると、nullチェックの挙動は変わるか
  • ガベージコレクションの影響は受けるか
  • ゲームオブジェクト/コンポーネントのDestroy()で、挙動は変わるか

用意した変数

  • useInterface : trueならインターフェースにキャストして、falseならキャストしないで処理を走らせる
  • destroyGameObject : trueならコンポーネントが付いているゲームオブジェクトをDestroyし、falseならコンポーネントを直にDestroyする

処理の流れ

  1. ゲームオブジェクトを生成し、自作コンポーネントをアタッチする。その参照を保持。
  2. Destroy()する。前後でnullチェック。
  3. 1フレーム待つ。
  4. ガベージコレクションを実行する。前後でnullチェック。
  5. 1フレーム待つ。
  6. 最後にダメ押しのnullチェック。

nullチェックの方法

以下の3種類を試す。

  • (bool)obj : (おそらく)演算子オーバーライドの影響を受けるnullチェック(ただし、インターフェースにキャストするとこれは出来なくなるので、毎回 true とする
  • obj == null : 演算子オーバーライドの影響を受けるnullチェック
  • obj is null : 演算子オーバーライドの影響を受けないnullチェック
MyComp.cs
using UnityEngine;

public interface IMyComp
{
}

public class MyComp : MonoBehaviour, IMyComp
{

}
Manager.cs
using System;
using System.Collections;
using UnityEngine;

public class Manager : MonoBehaviour
{
    [SerializeField] private bool useInterface;
    [SerializeField] private bool destroyGameObject;

    private IEnumerator Start()
    {
        GameObject go = new("Obj", typeof(MyComp));
        MyComp comp = go.GetComponent<MyComp>();
        IMyComp icomp = comp as IMyComp;

        if (useInterface)
        {
            yield return DoIComp(go, icomp, destroyGameObject);
        }
        else
        {
            yield return DoComp(go, comp, destroyGameObject);
        }
    }

    private IEnumerator DoComp(GameObject go, MyComp comp, bool destroyGameObject)
    {
        CheckNullComp(comp, "Before Destroy");
        if (destroyGameObject)
        {
            Destroy(go);
        }
        else
        {
            Destroy(comp);
        }
        CheckNullComp(comp, "After Destroy");

        yield return null;
        Debug.Log("=== After One Frame ===");

        CheckNullComp(comp, "Before GC");
        GC.Collect();
        CheckNullComp(comp, "After GC");

        yield return null;
        Debug.Log("=== After One Frame ===");

        CheckNullComp(comp, "Final");
    }

    private IEnumerator DoIComp(GameObject go, IMyComp icomp, bool destroyGameObject)
    {
        CheckNullIComp(icomp, "Before Destroy");
        if (destroyGameObject)
        {
            Destroy(go);
        }
        else
        {
            Destroy(icomp as MyComp);
        }
        CheckNullIComp(icomp, "After Destroy");

        yield return null;
        Debug.Log("=== After One Frame ===");

        CheckNullIComp(icomp, "Before GC");
        GC.Collect();
        CheckNullIComp(icomp, "After GC");

        yield return null;
        Debug.Log("=== After One Frame ===");

        CheckNullIComp(icomp, "Final");
    }

    private void CheckNullComp(MyComp comp, string label = "No Label")
    {
        bool b1 = !(comp);
        bool b2 = (comp == null);
        bool b3 = (comp is null);
        Debug.Log($"【{label}】 '':<color=cyan>{b1}</color>; =:<color=cyan>{b2}</color>; is:<color=cyan>{b3}</color>");
    }

    private void CheckNullIComp(IMyComp icomp, string label = "No Label")
    {
        // bool b1 = !(icomp); // エラーになるのでこれはスキップ
        bool b1 = true;
        bool b2 = (icomp == null);
        bool b3 = (icomp is null);
        Debug.Log($"【{label}】 '':<color=cyan>{b1}</color>; =:<color=cyan>{b2}</color>; is:<color=cyan>{b3}</color>");
    }
}

実験結果

※ 1行に収めるため、下記の略記を用いています。

  • Before Destroy → Before Dest
  • After Destroy → After Dest

true ならnull、 false なら非nullです。

useInterface = false
destroyGameObject = false
useInterface = false
destroyGameObject = true
【Before Dest】 '':False; =:False; is:False
【After Dest】 '':False; =:False; is:False
=== After One Frame ===
【Before GC】 '':True; =:True; is:False
【After GC】 '':True; =:True; is:False
=== After One Frame ===
【Final】 '':True; =:True; is:False
【Before Dest】 '':False; =:False; is:False
【After Dest】 '':False; =:False; is:False
=== After One Frame ===
【Before GC】 '':True; =:True; is:False
【After GC】 '':True; =:True; is:False
=== After One Frame ===
【Final】 '':True; =:True; is:False
useInterface = true
destroyGameObject = false
useInterface = true
destroyGameObject = true
【Before Dest】 '':True; =:False; is:False
【After Dest】 '':True; =:False; is:False
=== After One Frame ===
【Before GC】 '':True; =:False; is:False
【After GC】 '':True; =:False; is:False
=== After One Frame ===
【Final】 '':True; =:False; is:False
【Before Dest】 '':True; =:False; is:False
【After Dest】 '':True; =:False; is:False
=== After One Frame ===
【Before GC】 '':True; =:False; is:False
【After GC】 '':True; =:False; is:False
=== After One Frame ===
【Final】 '':True; =:False; is:False

考察

>> MonoBehaviour をインターフェースにキャストすると、nullチェックの挙動は変わるか

  • 変わる。予想通り、演算子オーバーライドは反映されなくなる。
  • Destroy()されても、Unityオブジェクトの実体は暫く生存を続けるようだ。
  • (bool)objobj != null は、同じ結果になる。おそらく等価だと思われる
  • Unityオブジェクトは、Destroy()された次フレームにnull判定となる設計である。
  • 【疑問点】Destroy()された後、Unityオブジェクトの実体はいつ完全なnullになる?

>> ガベージコレクションの影響は受けるか

  • 受けない。別のタイミングで、メモリから解放されるようだ。

>> ゲームオブジェクト/コンポーネントのDestroy()で、挙動は変わるか

  • 変わらない。どちらでも以下のDestroy()が実行されるので、直感通り。
//
// Summary:
//     Removes a GameObject, component or asset.
//
// Parameters:
//   obj:
//     The object to destroy.
//
//   t:
//     The optional amount of time to delay before destroying the object.
[ExcludeFromDocs]
public static void Destroy(Object obj)
{
    float t = 0f;
    Destroy(obj, t);
}

追加実験

疑問点をさらに詳しく検証します!
以下2スクリプトを作成し、Managerクラスのみゲームオブジェクトにアタッチします。
オブジェクトをDestroy()し、完全にnullになるまでのフレーム数をカウントします。

調べたいこと

  • Destroy()された後、Unityオブジェクトの実体はいつ完全なnullになる?

用意した変数

  • doGcJustAfterDestroy : trueなら、Destroy()した直後にガベージコレクションも実行する。

処理の流れ

  1. ゲームオブジェクトを生成し、自作コンポーネントをアタッチする。その参照を保持。
  2. Destroy()する。(そして、その直後にガベージコレクションを実行する。)
  3. obj is nulltrue になるまでのフレーム数をカウントする。
  4. 上記とは完全に別個で、スペースキーが押されたら、現在シーンを再ロードする。

UniTaskを使用するため、Managerコンポーネントが破棄されてもnull監視は継続します。

MyComp.cs
using UnityEngine;

public class MyComp : MonoBehaviour
{

}
Manager.cs
using System;
using UnityEngine;
using UnityEngine.SceneManagement;
using Cysharp.Threading.Tasks;

public class Manager : MonoBehaviour
{
    [SerializeField] private bool doGcJustAfterDestroy;

    private void Start()
    {
        GameObject go = new("Obj", typeof(MyComp));
        MyComp comp = go.GetComponent<MyComp>();

        Destroy(comp);
        if (doGcJustAfterDestroy)
        {
            GC.Collect();
        }

        Observe(comp).Forget();
    }

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
            SceneManager.LoadScene(SceneManager.GetActiveScene().name);
    }

    private async UniTaskVoid Observe(MyComp comp)
    {
        Debug.Log("=== Destroy (And GC) Has Just Ended ===");
        Debug.Log("=== Start Observing Null ===");
        ulong frameCount = 0;
        while (true)
        {
            if (comp is null)
                break;

            await UniTask.NextFrame();
            frameCount++;
        }
        Debug.Log($"=== Null Observed After <color=cyan>{frameCount}</color> Frames ===");
    }
}

実験結果

  • Unityオブジェクトが 完全にnullになることは無かった。(つまりnull監視は終了しない。)
  • シーンを再ロードしてもnullになることは無い。(複数のnull監視タスクが動き続ける。)

考察

  • Observe() タスク内で MyComp コンポーネントの参照を握り続けているため、ずっとメモリから解放されないものと考えられる。(普通にメモリリークしてるんだが!!!
  • Unityオブジェクトに対しては、正しいnullチェックをするよう意識するべきだ。
  • comp = null; を実行すると、 即座に comp is nulltrue になった。参照を切るためにnull代入をすることには、意味がある

まとめ

  • Unityオブジェクトに対するnullチェックは、以下のいずれかで行うべきである。
if (obj);          // trueなら非null
if (obj == null);  // trueならnull
  • Destroy() すると、その次フレームにnull判定となる。
  • Unityオブジェクトにnull代入して参照を切るのは、価値がある行為だ。
  • インターフェースにキャストすると、望ましいnullチェックは出来なくなる。
0
1
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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?