Under Construction
Unity16 min208 views

Unity’s Biggest Trap: null That Isn’t Null

Minh Khoa

Minh Khoa

Author

Today, let’s dissect a topic that seems basic but is actually a deadly “trap” for many Unity developers, including experienced ones:The difference between C# Object (System.Object) and Unity Engine Object (UnityEngine.Object) in handling Null and Destruction.

image.png## 1. The nature of two worlds: Managed and Unmanaged

To understand the core reason, we need to know Unity’s deeper architecture. When you code in Unity, you are standing as a bridge between two worlds:

  1. **Managed world (C#):**The place that contains C# object (System.Object)s, allocated in Heap memory. This memory is automatically cleaned up by the Garbage Collector system (GC) of the C runtime# (Mono/IL2CPP).
  2. **World Native/Unmanaged (C++):**The place that contains the actual data structures of Unity (that representGameObjectTransformMonoBehaviourTexture2D...). They are written in C++ to optimize performance, and Unity’s core system takes direct control of managing their memory.

When you create aGameObjectin C#Unity creates a heavy C++ object in the background, while also creating a**wrapper shell (wrapper)**in C# so that you can interact with that C++ object. 👉 That is:UnityEngine.Objectis in fact just a thin C# shell structure containing a*pointer (pointer)*linked to the core C++ object in the background.

2. Object Destruction process (Destruction)

The biggest difference lies in how these two types of object are destroyed:

  • **C# Object standard:**You cannot “actively” destroy it with a command. You can only remove all references (assign references withnull). The Garbage Collector system (GC) of C# will come to clean it up at some point in the future with a process non-deterministic (whose destruction time you cannot predict).
  • UnityEngine.Object:You decide everything by callingDestroy(gameObject). This function willdestroy immediately (or at the end of the frame) the original Unity object in C space++. HOWEVER, the C shell# that you are holding a reference tois not destroyed right away at all, it still sits there on C memory# (Heap) until GC it is cleaned up.

3. The “Fake Null” trick (Null handling)

It is precisely because of the strange fact that the “C body++” has disappeared but the “C shell#” still persists that a thorny problem arises:How does C# know that the resource system under the c++ shell one UnityEngine.Object has been Destroyed?

In ordinary C logic# , if the shell is still in memory then itmyObject == nullwill returnfalse. But wait, when you writeif (myObject == null)in Unity after Destroy, it returnstrue! Clearly C# object is not actuallynull?

Unity’s secret: Operator overloading == and !=

Unity deliberatelyoverrides (overload) operator == and !=for the base classUnityEngine.Objectinstead of the natural behavior of checking whether the C memory area# of the shell class points tonullthe standard one or not, Unity's comparison function goes down and checks whether**"the Native C object++ that this shell class points to exists/is still alive?"**.

  • If the Native C++ object is dead or Destroyed -> Unity reportstrue. It does not care whether the C shell# is still there or gone. This phenomenon is called by advanced Unity programmers**"Fake Null"**.

If you use the pure C function# object.ReferenceEquals(myObject, null) will return false - this is the real way to tell whether the C shell class# has not yet been GC cleaned up.

Deadly traps from "Fake Null"

Because the Overload algorithm==only handles basic code fragments, it gradually breaks and shatters modern C syntax# (since C# 6.0), specifically:

  • Operator ?. (Null-conditional operator)
  • Operator ?? (Null-coalescing operator)

These operators in the C standard# are designed to be deeply embedded at the core language level (IL). They bypass (completely bypass) the operator function==overloaded by Unity, and directly assessthe C memory shell class# whether it is truly Null.

Of course, the C wrapper# is not Null at all (has not yet been GC garbage-collected)! Therefore, the Operator checker?.considers the variable valid and permits access to that object's functions. At this point, the access command is sent down to call the C++ object, but... it has hitDestroy().

BAM! Exception: MissingReferenceException a bright red console card is thrown!

A common foot-gun example:

public class DestroyExample : MonoBehaviour
{
    public GameObject myPlayer;

    void Start() {
        Destroy(myPlayer); // Hủy object bên C++

        // 1. Dùng quy chuẩn Classic -> HOẠT ĐỘNG HOÀN HẢO! Nhờ Overload == hỗ trợ
        if (myPlayer != null) {
            Debug.Log(myPlayer.name);
        } else {
            Debug.Log("Player đã tèo"); // Sẽ in ra dòng này
        }

        // 2. Dùng cú syntax mới của C# ("hiện đại" nhưng hại điện) -> GÂY LỖI CRASH
        // Dấu '?.' cho rằng myPlayer != null (vì vỏ C# còn) -> tiếp tục gọi property .name
        // -> Unity cố mò chọt vào Object C++ đã chết -> MissingReferenceException!
        Debug.Log(myPlayer?.name);

        // 3. Fallback toán tử ?? cũng dễ ăn hành:
        GameObject clone = myPlayer ?? new GameObject(); // Sẽ giữ nguyên trả về Object hỏng (myPlayer) chứ KHÔNG khởi tạo new GameObject
    }
}

4. Ultimate Solutions & Best Practices

  1. **Follow the traditional Null-checking method (Classic check):**When working with objects inherited fromUnityEngine.Object (MonoBehaviourTransformGameObject...), kindly useif (obj == null)orif (obj != null). Do not try to abbreviate.
  2. Say NO to ?. and ?? **for UnityEngine.Object:**Absolutely avoid using these "sweet" syntax (sugar syntax) of C# unless they are pure objects (System.ObjectListpure C classes# that you create and do not inherit from the Engine framework).
  3. Furthermore, Unity also supports implicit boolean casting, so you can write something very cool likeif (!myPlayer)to check NULL. This approach is often widely used by large Unity codebases.

5. Summary

  • C# Object (System.Object): GC automatically manages the lifecycle. Dereferenced references -> GC clean up -> completely Null.
  • **UnityEngine.Object:**The two-faced architecture (C shell# and C Core++). The Core lifecycle is deterministically destroyed manually (Deterministic) by the functionDestroy()but the dead shell is not deterministic (Non-Deterministic) dependent GC.
  • The operator of Unity Object is a=="Fake Null"; it reports missing when the Core, covering the true face of the C++ Prohibit operator overloading#.
  • and ?. with.? ?? ?? UnityEngine.Object.