導入
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する
処理の流れ
- ゲームオブジェクトを生成し、自作コンポーネントをアタッチする。その参照を保持。
-
Destroy()する。前後でnullチェック。 - 1フレーム待つ。
- ガベージコレクションを実行する。前後でnullチェック。
- 1フレーム待つ。
- 最後にダメ押しのnullチェック。
nullチェックの方法
以下の3種類を試す。
-
(bool)obj: (おそらく)演算子オーバーライドの影響を受けるnullチェック(ただし、インターフェースにキャストするとこれは出来なくなるので、毎回trueとする) -
obj == null: 演算子オーバーライドの影響を受けるnullチェック -
obj is null: 演算子オーバーライドの影響を受けないnullチェック
using UnityEngine;
public interface IMyComp
{
}
public class MyComp : MonoBehaviour, IMyComp
{
}
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)objとobj != 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()した直後にガベージコレクションも実行する。
処理の流れ
- ゲームオブジェクトを生成し、自作コンポーネントをアタッチする。その参照を保持。
-
Destroy()する。(そして、その直後にガベージコレクションを実行する。) -
obj is nullがtrueになるまでのフレーム数をカウントする。 - 上記とは完全に別個で、スペースキーが押されたら、現在シーンを再ロードする。
※ UniTaskを使用するため、Managerコンポーネントが破棄されてもnull監視は継続します。
using UnityEngine;
public class MyComp : MonoBehaviour
{
}
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 nullがtrueになった。参照を切るためにnull代入をすることには、意味がある。
まとめ
- Unityオブジェクトに対するnullチェックは、以下のいずれかで行うべきである。
if (obj); // trueなら非null
if (obj == null); // trueならnull
Destroy()すると、その次フレームにnull判定となる。- Unityオブジェクトにnull代入して参照を切るのは、価値がある行為だ。
- インターフェースにキャストすると、望ましいnullチェックは出来なくなる。