Search code examples
c#unity-game-engineserialization

Why does C# null-conditional operator not work with Unity serializable variables?


I've noticed that if I have some variables exposed to the Unity inspector such as:

[SerializeField] GameObject _tickIcon;

If I leave them unassigned and try to use the null conditional operator and call a method on that object I get an exception saying the variable is not assigned. So basically instead of doing this:

_tickIcon?.SetActive(false);

It's forcing me to do this:

if(_tickIcon != null)
{
   _tickIcon.SetActive(false)
}

So I'm guessing this must be something specific to unity's runtime, it's not really null, but I can check for null and it work. I don't really understand this.


Solution

  • It does not work in general with anything inheriting from UnityEngine.Object!

    tl;dr: The ?? and ?. operators work on a System.Object (aca object) level while Unity's == operator works on a UnityEngine.Object level.


    Unity has a custom implementation of == for UnityEngine.Object which actually is a hook down into the underlying c++ engine. See Custom == operator, should we keep it?

    ReSharper explained it pretty well in Possible unintended bypass of lifetime check of underlying Unity engine object

    This warning is shown if a type deriving from UnityEngine.Object uses either the null coalescing (??) or null propagation or conditional (?.) operators. These operators do not use the custom equality operators declared on UnityEngine.Object, and so bypass a check to see if the underlying native Unity engine object has been destroyed. An explicit null or boolean comparison, or a call to System.Object.ReferenceEquals() is preferred in order to clarify intent.

    UnityEngine.Object is in some occasions not really null but still keeps some meta data. So the underlying object(= System.Object) is not null, UnityEngine.Object's overwritten == operator just returns true for == null.

    The reason for this: The c# layer UnityEngine.Object is just the developer API layer on top of the actual underlying c++ engine code. The custom == and implicit bool operator both basically boil down to

    (source code)

        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;
        }
       
        ...
    
        static bool IsNativeObjectAlive(UnityEngine.Object o)
        {
            if (o.GetCachedPtr() != IntPtr.Zero)
                return true;
    
            //Ressurection of assets is complicated.
            //For almost all cases, if you have a c# wrapper for an asset like a material,
            //if the material gets moved, or deleted, and later placed back, the persistentmanager
            //will ensure it will come back with the same instanceid.
            //in this case, we want the old c# wrapper to still "work".
            //we only support this behaviour in the editor, even though there
            //are some cases in the player where this could happen too. (when unloading things from assetbundles)
            //supporting this makes all operator== slow though, so we decided to not support it in the player.
            //
            //we have an exception for assets that "are" a c# object, like a MonoBehaviour in a prefab, and a ScriptableObject.
            //in this case, the asset "is" the c# object,  and you cannot actually pretend
            //the old wrapper points to the new c# object. this is why we make an exception in the operator==
            //for this case. If we had a c# wrapper to a persistent monobehaviour, and that one gets
            //destroyed, and placed back with the same instanceID,  we still will say that the old
            //c# object is null.
            if (o is MonoBehaviour || o is ScriptableObject)
                return false;
    
            return DoesObjectWithInstanceIDExist(o.GetInstanceID());
        }
    
        [NativeMethod(Name = "UnityEngineObjectBindings::DoesObjectWithInstanceIDExist", IsFreeFunction = true, IsThreadSafe = true)]
        internal extern static bool DoesObjectWithInstanceIDExist(int instanceID);
    

    background calls into the native engine code where the actual instances of those objects are handled and their lifecycle controlled. So e.g. after Destroy an object, in the native c++ engine the object is already marked as destroyed and the custom == and bool operator already return false. The c# layer UnityEngine.Object still exists though until it is actually garbage collected (usually at the end of the frame but there is no real guarantee for that either).

    The main reason why therefore things like _tickIcon?.gameObjct throw a NullReferenceException is that the ?. operator only directly works on the underlying object(System.Object) while the UnityEngine.Object works with their custom implementation on a different level.

    E.g. after

    Destroy(_tickIcon);
    _tickIcon.SetActive(false);
    

    you will note that you don't get a normal NullReferenceException which would be the case if it were actually null but rather get a Unity customs MissingReferenceException telling you a probable reason for why the exception was thrown.


    Long story short: As solution UnityEngine.Object has the implicit bool operator

    Does the object exist?

    You should always check the existence of anything derived from UnityEngine.Object like this:

    if(_tickIcon)
    {
        _tickIcon.SetActive(false);
    }
    

    or explicit (as this will use the custom ==/!= operators)

    if(_tickIcon != null)
    {
        _tickIcon.SetActive(false);
    }