Unityのnullチェックの挙動が若干理解しづらいと思ったので調査しました。
今回はUnityEngine.Object
で行われている等値演算子のオーバーロードに焦点を当て、次回は、今回説明した仕組みによって生じる注意すべき挙動や、A == null
とA is null
の違いについて記述したいと思います。
結論からいうと、UnityEngine.Object
を継承している型のnull
チェックはnull
かどうかの判定以外も行っているという話になります。
nullとは
まず簡単にnull
について触れますが、わかりきっていることだと思うので読み飛ばしていただいて構いません。
null
はC#では、参照型において無効なものを表すキーワード(予約語)です。
// nullを代入
MonoBehaviour sample = null;
nullが代入されている変数はどのインスタンスへの参照も保持しないため、null
が代入された変数のメンバにアクセスしようとするとNullReferenceException
が発生します。
using UnityEngine;
public class SampleTest : MonoBehaviour
{
private void Start()
{
MonoBehaviour sample = null;
// nullが代入されている変数のメンバにアクセス
Debug.Log(sample.name);
}
}
NullReferenceException: Object reference not set to an instance of an object
SampleTest2.Start () (at Assets/Scripts/SampleTest2.cs:9)
昨今はnull安全という考え方が既に浸透しつつあると思いますが、UnityでC#を扱う場合は現状特段の設定をしていなければ全ての参照型がnull
の代入を許容します(Unity 2021時点)。
null安全について
私は、null安全について、null
に関する例外を静的に発生(静的型付け言語ならコンパイル時、動的型付け言語なら静的な解析時)させることで、null
な変数にアクセスした際に起こる動的な例外(C#ならNullReferenceException
)に煩わされることを避けるための考え方だと理解しています。
具体的には以下のような機能を指し示すものだと考えています。
- 基本的に変数に
null
が代入されることを許容しない(静的に例外が発生) -
null
が許容される型(null許容型)が用意されている場合は、メンバアクセス時に事前に変数にnull
が代入されていないかチェックを要求する(されていない場合は静的に例外が発生)
しかし、普段null安全な言語を主に使っているわけではないので、誤解があればご指摘をお願い致します。
C#でもC# 8.0からnull安全に関係する機能としてnull許容参照型が提供されるようになりましたが、ここではその詳細を省略します。
実験
上記のようなnullの仕様を踏まえて、こんなコードを書くとします。
using System.Collections;
using UnityEngine;
public class SampleTest : MonoBehaviour
{
private IEnumerator Start()
{
// GameObjectにSampleMonoBehaviourをアタッチして返す
var sampleMonoBehaviour = gameObject.AddComponent<SampleMonoBehaviour>();
Debug.Log($"{nameof(sampleMonoBehaviour)} == null: {sampleMonoBehaviour == null}");
// Destroyメソッドに変数を渡す
Destroy(sampleMonoBehaviour);
// 1フレーム待つ
yield return null;
Debug.Log($"{nameof(sampleMonoBehaviour)} == null: {sampleMonoBehaviour == null}");
}
}
ローカル変数sampleMonoBehaviour
に新規アタッチしたコンポーネントを代入しnull
チェックを行い、Destroy
メソッドに変数を渡して呼び出した後1フレーム待って後もう一度null
チェックを行うだけのシンプルなコードです。
Destroy
はオブジェクトの「破棄」を行うUnityに用意されている静的なメソッドですが、実際に「破棄」が行われるのはUpdateループの後なので(公式ドキュメントに記載されています)、1フレーム「破棄」の完了を待機しています。
即座に「破棄」を行うDestroyImmediate
もありますが、ランタイムでの使用は推奨されていないのでここでは使用していません。
なおドキュメントのdestroy
という語彙に対して「破棄」という訳をここでは用いていますが、意味については後述します(但し推測の話になります)。
これのコンソールへの出力結果はこうなります。
2回目(Destroy
実行後)のsampleMonoBehaviour == null
ではTrue
が出力されることが確認できます。
sampleMonoBehaviour == null
がTrue
であるということは、直感的に考えればsampleMonoBehaviour
がnull
であるということになります。
それなら、sampleMonoBehaviour
のメンバにアクセスすればnullとはの時と同様にNullReferenceException
が発生するはずです。
試しにアタッチされているGameObject
の名前を返すname
プロパティを呼び出してみます。
// 1フレーム待つ
yield return null;
Debug.Log($"{nameof(sampleMonoBehaviour)} == null: {sampleMonoBehaviour == null}");
// 名前を表示するコードを追加
Debug.Log($"sampleMonoBehaviour.name: {sampleMonoBehaviour.name}");
実際に上記のコードを実行すると、確かに例外が発生しますが、想定したNullReferenceException
ではなくMissingReferenceException
になっています。
これはUnityEngine
名前空間に定義されている例外であり、Unityフレームワークが独自に例外を発生させていると考えられます。
では、アクセスするメンバを変更して、変数の型を取得するGetType()
メソッドを呼び出してみるとどうでしょうか。
// 1フレーム待つ
yield return null;
Debug.Log($"{nameof(sampleMonoBehaviour)} == null: {sampleMonoBehaviour == null}");
// 変数の型を表示するコードを追加
Debug.Log($"sampleMonoBehaviour.GetType(): {sampleMonoBehaviour.GetType()}");
普通にメソッドを呼び出して型を取得することができてしまいました。
以上の結果を踏まえると、sampleMonoBehaviour
にはnull
は代入されていないということになります。
そもそもコード上でnull
を代入していない以上、sampleMonoBehaviour
にnull
が代入されたと考えるのは原理的にありえない話ではあります(Destroy
に変数を渡す際もref
等のキーワードを付けた参照渡しは行っていないため)。
変数にnull
が代入されていないのにも関わらず変数がnull
と等価だと判断されているのは、他言語でプログラムを書いていた人やUnityを触りたての人にはやや違和感があるのではないでしょうか。
等値演算子のオーバーロード
C#には演算子をオーバーロードできる機能が用意されています。
このオーバーロードできる演算子の中には、等値演算子==
と非等値演算子!=
も含まれます。
GameObject
やMonoBehaviour
(ひいてはComponent
)、ScriptableObject
などのUnityで使用される主要なクラスはUnityEngine.Object
を継承している場合が多く、このクラスは等値演算子と非等値演算子を以下のようにオーバーロードしています。
public static bool operator ==(Object x, Object y) => Object.CompareBaseObjects(x, y);
public static bool operator !=(Object x, Object y) => !Object.CompareBaseObjects(x, y);
オーバロードされたメソッドでは、Object.CompareBaseObjects
に処理を委譲しています。
その処理の内容はこんな感じです。
private static bool CompareBaseObjects(Object lhs, Object rhs)
{
bool flag1 = (object) lhs == null;
bool flag2 = (object) rhs == null;
if (flag2 & flag1)
return true;
if (flag2)
return !Object.IsNativeObjectAlive(lhs);
return flag1 ? !Object.IsNativeObjectAlive(rhs) : lhs.m_InstanceID == rhs.m_InstanceID;
}
やや分かりづらいので書き下してみます。
private static bool CompareBaseObjects(Object lhs, Object rhs)
{
// objectにアップキャストしてnullか判定
bool flag1 = (object)lhs == null;
bool flag2 = (object)rhs == null;
// 第1引数と第2引数がどちらもnullなら等価と判定
if (flag2 & flag1)
return true;
// Object.IsNativeObjectAlive: ObjectがUnityフレームワーク上で存在するものとして扱われているかを判定するメソッド(推定)
// 具体的にはDestroyされているかなどを返すと考えられる(他にもチェック項目がある可能性あり)
// 第1引数のみnullの時、第2引数がDestroyされているならtrue、されていないならfalse
if (flag1) return !Object.IsNativeObjectAlive(rhs);
// 第2引数のみnullの時、第1引数がDestroyされているならtrue、されていないならfalse
if (flag2) return !Object.IsNativeObjectAlive(lhs);
// 両方nullでなくかつDestroyされていないなら、IDが一致するかどうかで等価を判定
return lhs.m_InstanceID == rhs.m_InstanceID;
}
なおパターンマッチング文法が導入されたC# 7.0以降にAPIが作られていたら、最初のnull判定は以下のような記述だったかもしれません。
// bool flag1 = (object) lhs == null;
bool flag1 = lhs is null;
// bool flag2 = (object) rhs == null;
bool flag2 = rhs is null;
この辺については次回解説できればと思います。
Destroyによる「破棄」
前の説明から連続した話です。
IsNativeObjectAlive
メソッドの機能を書き下し版メソッドの中で「ObjectがUnityフレームワーク上で存在するものとして扱われているかを判定するメソッド(推定)」と記載しましたが、IsNativeObjectAlive
メソッドがfalse
を返す場合に、そのオブジェクトはUnityフレームワーク上で「破棄」されていると考えられます。
逆に言うと、Destroy
メソッドなどが行う「破棄」の処理は、このメソッドがfalseを返すようにするということだと考えられます。
先程から断定的でない記述が連続していますが、IsNativeObjectAlive
メソッドは、実際にはこのような実装になっています。
private static bool IsNativeObjectAlive(Object o)
{
if (o.GetCachedPtr() != IntPtr.Zero)
return true;
return !(o is MonoBehaviour) && !(o is ScriptableObject) && Object.DoesObjectWithInstanceIDExist(o.GetInstanceID());
}
これは書き下すとこんな感じのメソッドです。
private static bool IsNativeObjectAlive(Object o)
{
// oのm_CachedPtrフィールドがIntPtr.Zero(実質的なNULLポインタ)でないならtrue(生存していると判定)
if (o.GetCachedPtr() != IntPtr.Zero)
return true;
// oがMonoBehaviourでなく、かつScriptableObjectでもなく、かつDoesObjectWithInstanceIDExistがtrueを返すならtrue(生存していると判定)
// それ以外ならfalse(破棄されていると判定)
return o is not MonoBehaviour && o is not ScriptableObject && Object.DoesObjectWithInstanceIDExist(o.GetInstanceID());
}
GetCachedPtr
はIntPtr
型(C#でポインタを扱うために用意されている構造体)のフィールドm_CachedPtr
を返すだけのメソッドです。
これを見るに、Destroy
メソッドを呼び出すことによって起こる「破棄」は、MonoBehaviour
またはScriptableObject
なら、m_CachedPtr
にIntPtr.Zero
を代入することによって実行され、それ以外の型の場合はDoesObjectWithInstanceIDExist
メソッド内で行われる何らかの判定がfalseになるように設計されていると考えられます(追記:下記検証記事の実行結果を参考にすると殆どの場合はm_CachedPtr
には破棄された時点でIntPtr.Zero
が代入されるようです)。
しかし、IsNativeObjectAlive
内で呼ばれているDoesObjectWithInstanceIDExist
が以下のようにexternなメソッドであり、Destroy
やDestroyImmediate
も同様にexternなメソッドであるため、実際にどのような判定が行われているかは未検証です。
2022/12/16追記
Destroyによってどのようにメソッドの実行結果が変わるか検証した記事を投稿しました。
[NativeMethod(Name = "UnityEngineObjectBindings::DoesObjectWithInstanceIDExist", IsFreeFunction = true, IsThreadSafe = true)]
[MethodImpl(MethodImplOptions.InternalCall)]
internal static extern bool DoesObjectWithInstanceIDExist(int instanceID);
実験で生じた疑問の解消
以上の議論を踏まえると、実験のコードの謎(?)が解明されます。
using System.Collections;
using UnityEngine;
public class SampleTest : MonoBehaviour
{
private IEnumerator Start()
{
// GameObjectにSampleMonoBehaviourをアタッチして返す
var sampleMonoBehaviour = gameObject.AddComponent<SampleMonoBehaviour>();
Debug.Log($"{nameof(sampleMonoBehaviour)} == null: {sampleMonoBehaviour == null}");
// Destroyメソッドに変数を渡す
Destroy(sampleMonoBehaviour);
// 1フレーム待つ
yield return null;
// オーバーロードされた==を呼び出すことで、nullかどうかの判定+そのオブジェクトがUnityフレームワーク上で破棄されているかどうかを確認している
Debug.Log($"{nameof(sampleMonoBehaviour)} == null: {sampleMonoBehaviour == null}");
}
}
sampleMonoBehaviour == null
は、そのオブジェクトがnullであるかどうかという判定に加えて、Unityフレームワーク上で破棄されていないかということも確認しているため、Destroyなどの実行後はnullが代入されていなくてもtrue
として判定されるということになります。
※余談 if (hogeComponent) という書き方について
以下のような記述を実際に書いたりサンプルコードで目にしたことがある人は多いと思います。
using UnityEngine;
public class SampleTest : MonoBehaviour
{
public SampleMonoBehaviour SampleMonoBehaviour;
public SampleClass SampleClass;
private void Start()
{
if (SampleMonoBehaviour)
// if (SampleMonoBehaviour != null)と同義
{
// 何らかの処理
SampleMonoBehaviour.Hoge();
}
// 無加工の自作クラスではコンパイルエラーになる
// if (SampleClass)
if (SampleClass != null)
{
}
}
}
public class SampleClass {}
これはif (SampleMonoBehaviour)
の部分でnullチェックを行っているというコードなのですが、UnityEngine.Object
を継承しない自作クラスでは何もしていなければコンパイルエラーになります。
これも先程と似たような話で、UnityEngine.Object
において暗黙的な型変換演算子(cast)がオーバーロードされているために許容される記述です。
public static implicit operator bool(Object exists) => !Object.CompareBaseObjects(exists, (Object) null);
自作クラスでも以下のように型変換演算子のオーバーロードを定義すればこの記述ができるようになりますが、初見ではわかりづらい記法なので乱用しないほうがいいかもしれません。
public class SampleClass
{
//boolへの暗黙的キャストをnullチェックを行うようオーバーロード
public static implicit operator bool(SampleClass sampleClass)
{
return sampleClass != null;
}
}
さいごに
いかがだったでしょうか。
Unityにおけるnullチェックの挙動について考えるきっかけになれば幸いです。
次回は、今回説明した仕組みによって生じる注意すべき挙動や、A == null
とA is null
の違いについて記述したいと思います。