7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

横国ゲーム制作部Advent Calendar 2022

Day 14

Unityにおけるnullチェック(1) - AがnullでなくともA == nullがtrueになる

Last updated at Posted at 2022-12-14

Unityのnullチェックの挙動が若干理解しづらいと思ったので調査しました。
今回はUnityEngine.Objectで行われている等値演算子のオーバーロードに焦点を当て、次回は、今回説明した仕組みによって生じる注意すべき挙動や、A == nullA is nullの違いについて記述したいと思います。

結論からいうと、UnityEngine.Objectを継承している型のnullチェックはnullかどうかの判定以外も行っているという話になります。

nullとは

まず簡単にnullについて触れますが、わかりきっていることだと思うので読み飛ばしていただいて構いません。

nullはC#では、参照型において無効なものを表すキーワード(予約語)です。

// nullを代入
MonoBehaviour sample = null;

nullが代入されている変数はどのインスタンスへの参照も保持しないため、nullが代入された変数のメンバにアクセスしようとするとNullReferenceExceptionが発生します。

SampleTest.cs
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の仕様を踏まえて、こんなコードを書くとします。

SampleTest.cs
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という語彙に対して「破棄」という訳をここでは用いていますが、意味については後述します(但し推測の話になります)。

これのコンソールへの出力結果はこうなります。

スクリーンショット 2022-12-15 004527.png

2回目(Destroy実行後)のsampleMonoBehaviour == nullではTrueが出力されることが確認できます。
sampleMonoBehaviour == nullTrueであるということは、直感的に考えればsampleMonoBehaviournullであるということになります。
それなら、sampleMonoBehaviourのメンバにアクセスすればnullとはの時と同様にNullReferenceExceptionが発生するはずです。
試しにアタッチされているGameObjectの名前を返すnameプロパティを呼び出してみます。

// 1フレーム待つ
yield return null;
Debug.Log($"{nameof(sampleMonoBehaviour)} == null: {sampleMonoBehaviour == null}");
// 名前を表示するコードを追加
Debug.Log($"sampleMonoBehaviour.name: {sampleMonoBehaviour.name}");

実際に上記のコードを実行すると、確かに例外が発生しますが、想定したNullReferenceExceptionではなくMissingReferenceExceptionになっています。
スクリーンショット 2022-12-15 010201.png

これはUnityEngine名前空間に定義されている例外であり、Unityフレームワークが独自に例外を発生させていると考えられます。

では、アクセスするメンバを変更して、変数の型を取得するGetType()メソッドを呼び出してみるとどうでしょうか。

// 1フレーム待つ
yield return null;
Debug.Log($"{nameof(sampleMonoBehaviour)} == null: {sampleMonoBehaviour == null}");
// 変数の型を表示するコードを追加
Debug.Log($"sampleMonoBehaviour.GetType(): {sampleMonoBehaviour.GetType()}");

普通にメソッドを呼び出して型を取得することができてしまいました。
スクリーンショット 2022-12-15 012351.png

以上の結果を踏まえると、sampleMonoBehaviourにはnullは代入されていないということになります。
そもそもコード上でnullを代入していない以上、sampleMonoBehaviournullが代入されたと考えるのは原理的にありえない話ではあります(Destroyに変数を渡す際もref等のキーワードを付けた参照渡しは行っていないため)。

変数にnullが代入されていないのにも関わらず変数がnullと等価だと判断されているのは、他言語でプログラムを書いていた人やUnityを触りたての人にはやや違和感があるのではないでしょうか。

等値演算子のオーバーロード

C#には演算子をオーバーロードできる機能が用意されています。
このオーバーロードできる演算子の中には、等値演算子==と非等値演算子!=も含まれます。
GameObjectMonoBehaviour(ひいてはComponent)、ScriptableObjectなどのUnityで使用される主要なクラスはUnityEngine.Objectを継承している場合が多く、このクラスは等値演算子と非等値演算子を以下のようにオーバーロードしています。

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に処理を委譲しています。
その処理の内容はこんな感じです。

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;
}

やや分かりづらいので書き下してみます。

CompareBaseObjectsメソッド(書き下し版)
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メソッドは、実際にはこのような実装になっています。

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());
}

これは書き下すとこんな感じのメソッドです。

IsNativeObjectAliveメソッド(書き下し版)
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());
}

GetCachedPtrIntPtr型(C#でポインタを扱うために用意されている構造体)のフィールドm_CachedPtrを返すだけのメソッドです。
これを見るに、Destroyメソッドを呼び出すことによって起こる「破棄」は、MonoBehaviourまたはScriptableObjectなら、m_CachedPtrIntPtr.Zeroを代入することによって実行され、それ以外の型の場合はDoesObjectWithInstanceIDExistメソッド内で行われる何らかの判定がfalseになるように設計されていると考えられます(追記:下記検証記事の実行結果を参考にすると殆どの場合はm_CachedPtrには破棄された時点でIntPtr.Zeroが代入されるようです)。
しかし、IsNativeObjectAlive内で呼ばれているDoesObjectWithInstanceIDExistが以下のようにexternなメソッドであり、DestroyDestroyImmediateも同様にexternなメソッドであるため、実際にどのような判定が行われているかは未検証です。

2022/12/16追記

Destroyによってどのようにメソッドの実行結果が変わるか検証した記事を投稿しました。

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

実験で生じた疑問の解消

以上の議論を踏まえると、実験のコードの謎(?)が解明されます。

SampleTest.cs
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) という書き方について

以下のような記述を実際に書いたりサンプルコードで目にしたことがある人は多いと思います。

SampleTest.cs
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)がオーバーロードされているために許容される記述です。

UnityEngine.Object→boolへのキャスト
public static implicit operator bool(Object exists) => !Object.CompareBaseObjects(exists, (Object) null);

自作クラスでも以下のように型変換演算子のオーバーロードを定義すればこの記述ができるようになりますが、初見ではわかりづらい記法なので乱用しないほうがいいかもしれません。

SampleClass.cs
public class SampleClass
{
    //boolへの暗黙的キャストをnullチェックを行うようオーバーロード
    public static implicit operator bool(SampleClass sampleClass)
    {
        return sampleClass != null;
    }
}

さいごに

いかがだったでしょうか。
Unityにおけるnullチェックの挙動について考えるきっかけになれば幸いです。
次回は、今回説明した仕組みによって生じる注意すべき挙動や、A == nullA is nullの違いについて記述したいと思います。

7
3
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
7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?