⚠️

【Unity】UnityEngine.Objectの偽装null

2025/03/16に公開
3

今回はUnityにおける偽装nullの問題について。UnityEngine.Objectにおけるnullは通常のオブジェクトと異なる挙動をするため、Unityを始めたばかりの人がハマりがちです。既に色々な場所で取り上げられているトピックですが、改めて確認しておきましょう。

C#におけるnull

Unityの話の前にまずは通常のC#の話から。そもそもC#においてnullは何を表す値なのでしょうか。

値型と参照型

C#の型は「値型」と「参照型」に分類されます。

値型

値型は変数が値そのものを保持するような型を指します。値型の場合、代入時には都度値のコピーを受け取ります。

int x = 1;
int y = x; // xの値のコピーを受け取る

C#ではbyte,int,long,char,boolなどのプリミティブ型と列挙型(enum)、構造体(struct)のインスタンスが値型になります。

参照型

一方参照型は変数が値への参照を保持するような型を指します。参照型の場合、実態はヒープ領域に保持されており、代入時には値への参照が渡されます。

var ref1 = new Foo();
var ref2 = ref1; // 参照が代入されるため、実際に指す値はref1と同じ

// 適当なclass
class Foo() { ... }

C#ではstring,objectなどの型や、クラス(class)とインターフェース(interface)が参照型に相当します。

参照型とnull

値型は常に値そのものを保持していますが、参照型の場合は有効な値への参照が存在しないことがあります。この時の値がnullであり、nullが格納された変数にアクセスしようとするとNullReferenceExceptionが発生します。

Foo foo = null; // nullが入った参照型の変数
foo.Foo();      // アクセスしようとするとNullReferenceExceptionをスロー

これを避けるには事前にfoo != nullのような形でnullチェックを行っておく必要があります。

// fooがnullでないことをチェックする
if (foo != null)
{
    foo.Foo();
}

UnityEngine.Objectとnull

さて、ここからが問題のUnityEngine.Objectとnullの話です。

Unityで用いられるGameObjectScriptableObjectMonoBehaviourなどのあらゆる型はUnityEngine.Objectという型を基底クラスに持っています。

そして、UnityEngine.ObjectDestroy()を用いて破棄することができます。これを行うとオブジェクトがnullになる...ように見えます。

// 適当なPrefabがあるとして...
GameObject prefab;

// Prefabから適当なGameObjectを生成
var obj = Instantiate(prefab);

// GameObjectを破棄
Destroy(obj);

// これはtrueになるが...
Debug.Log(obj == null);

しかし、実際にはこのobjの値は nullではありません。 そもそもC#では明示的にnullを代入しない限り、値が勝手にnullになることはあり得ません。(objの参照自体は生きているため)

試しにobject.ReferenceEquals()を用いて参照を比較するとfalseが返ってきます。

// これはtrueなのに...
Debug.Log(obj == null);

// これがfalseになる!
Debug.Log(object.ReferenceEquals(obj, null));

このように、UnityEngine.Objectは実態が破棄されている場合には、値がnullではないのにも関わらずobj == nullがtrueを返すようになっています。これがUnityにおける偽装nullの問題です。

何が問題なのか?

null演算子の問題

さて、ではこの仕様の何が問題なのかというと、null比較の演算子が一切機能しないことにあります。

C#には???などのnull比較のための演算子があります。

// if (foo != null) foo.Foo(); と同じ
foo?.Foo();

// foo = (a != null ? a : b); と同じ
foo = a ?? b;

しかし、Unityの偽装nullは==演算子を実装することで実現されているため、これらの演算子の場合は機能しません。

Destroy(obj);

// これはDestroy済みでも機能するが...
if (obj != null) obj.Foo();

// これは正しい意味でnullかを見るため、チェックを通過してしまいエラーが出る
obj?.Foo();

またC#ではisでnullチェックをすることもできますが、残念ながらこれもUnityEngine.Objectでは想定通り動きません。

Destroy(obj);

// これはtrue
Debug.Log(obj == null);

// これはfalse..
Debug.Log(obj is null);

さらに、ジェネリクス経由の場合も問題になります。以下のようなメソッドがある場合、UnityEngine.Objectを渡すと正しく機能しません。

Destroy(obj);

// ジェネリクスを経由するとUnityEngine.Objectで実装された==の実装を経由しない
// そのためこれはfalseになる
Debug.Log(IsNull(obj));

// nullならtrueを返すだけの関数
static bool IsNull<T>(T obj) where T : class
{
    return obj == null;
}

これはwhere T : UnityEngine.Object制約をつけることで回避可能ですが、うっかり挙動が違うことを忘れて動かないことはあり得ます。

メモリリークの問題

また、明示的にnullを代入しないとGCに回収されずにメモリリークするという問題もあります。

先ほど説明したように、Destroy()された後のUnityEngine.Objectはnullのように振る舞いますが、実際の参照は生きているため値はnullではありません。そのため、これらの値はGC(ガベージコレクション)によって回収されず、無駄にメモリを食うことになります。

これを避けるには、Destroy()後に明示的にnullを代入する必要があります。

Texture2D tex = new Texture2D(256, 256);
Destroy(tex);

// nullを代入してメモリリークを回避
tex = null;

何故こんな仕様なのか

この仕様は初期のUnityの設計ミス...と言ってしまえばそうなのですが、もう少し理由を探ってみましょう。

UnityのC#側のコードはオープンソースになっているため、実際のUnityEngine.Objectがどのような実装になっているのかを見ることができます。

https://github.com/Unity-Technologies/UnityCsReference

namespace UnityEngine
{
    [StructLayout(LayoutKind.Sequential)]
    [RequiredByNativeCode(GenerateProxy = true)]
    [NativeHeader("Runtime/Export/Scripting/UnityEngineObject.bindings.h")]
    [NativeHeader("Runtime/GameCode/CloneObject.h")]
    [NativeHeader("Runtime/SceneManager/SceneManager.h")]
    public partial class Object
    {
        ...

        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()の呼び出しに対応しています。さらにCompareBaseObjects()の実装を抜粋すると以下の通り。

static bool CompareBaseObjects(UnityEngine.Object lhs, UnityEngine.Object rhs)
{
    bool lhsNull = ((object)lhs) == null;
    bool rhsNull = ((object)rhs) == null;

    if (rhsNull && lhsNull) return true;

    if (rhsNull) return !IsNativeObjectAlive(lhs);
    if (lhsNull) return !IsNativeObjectAlive(rhs);

    return lhs.m_InstanceID == rhs.m_InstanceID;
}

左辺または右辺のどちらかがnullの場合は!IsNativeObjectAlive()の結果を返すようになっています。

この関数名から想像がつく通り、UnityEngine.Objectの実体はC#側にはありません。Unityの根幹となる部分はC++で実装されているため、普段私たちが扱っているC#側のクラスはC++オブジェクトのラッパーに過ぎません。

そのため、実体であるC++ネイティブ側のオブジェクトが破棄されている場合はC#側でnullのように扱うという仕様になっています。

とはいえこの仕様は混乱を招くため何度も修正が検討されていましたが、根幹にある仕様のため互換性を考慮すると変更が難しく、結果としてそのままになっている状態です。おそらく今後も修正されることはないため、そういうものとして受け入れていくしかないでしょう。

まとめ

というわけで、Unityと偽装nullの問題についてでした。うっかり引っかかることが多い部分なので、Unityでコードを書くときは注意しておくと良いでしょう。

3

Discussion