深入 UnityEngine.Object 中重载的几个运算符

前言

UnityEngine.Object 类作为 Unity 中所有内建对象的基类,可以在 Unity 中被任意引用。继承自 System.Object,并且重载了 ==!=bool 几个特殊的操作符。

问题

问题从一行错误日志开始。在最近的一个线上项目中收到这样一条错误上报:

MissingReferenceException: The object of type 'GameObject' has been destroyed but you are still trying to access it. Your script should either check if it is null or you should not destroy the object.

定位到具体代码,大致如下:

1
Transform transform = gameObject?.transform;

当前 GameObject 已经被销毁仍然尝试读取其 transform,但是这里明明已经使用 ?. 操作符判定了,为什么还会出现这个问题?下面尝试模拟这种情况。

1
2
3
4
GameObject gameObject = new GameObject("go");
DestroyImmediate(gameObject);
Transform transform = gameObject?.transform;

运行上面的代码,确实抛出了一样的错误异常。难道是 ?. 操作符的问题?下面我们换一种方式来 check null,如下:

1
2
3
4
5
6
7
8
GameObject gameObject = new GameObject("go");
DestroyImmediate(gameObject);
Transform transform;
if (gameObject != null)
{
transform = gameObject.transform;
}

运行这一段代码,没有错误异常抛出,说明 check null 成功。为什么 ?. 操作符会检验失败?带着疑问来深入看一下编译器为我们生成的部分 CIL 代码。

探究

在探究之前,首先补充一段关于 UnityEngine.Object 知识。在 Unity 中,所有实际的 UnityEngine.Object 的数据均存储在一段本地原生的对象空间中,与 CLR 无关;在 CLR 层中的 UnityEngine.Object 对象实际上是一个指向本地原生对象的指针,这类对象又被称为 "wrapper objects"。

原生对象的生命周期由 Unity 管理,在加载新场景、显式调用 Destroy 方法(当前帧的某个时刻执行)或调用 DestroyImmediate 方法(立即执行)时原生对象会被销毁。C# 中的 UnityEngine.Object 对象的销毁回收由 CLR 决定。因此就可能出现一种情况: 本地原生对象已经被销毁,但是由于 CLR GC 未发生,导致 C# 中的 UnityEngine.Object 对象未被回收。

了解了这个知识点,下面开始探究问题所在。

看看第一段使用了 ?. 操作符生成的的 CIL 代码指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
.locals init (
[0] class [UnityEngine.CoreModule]UnityEngine.GameObject gameObject,
[1] class [UnityEngine.CoreModule]UnityEngine.Transform transform
)
IL_0001: ldstr "go"
IL_0006: newobj instance void [UnityEngine.CoreModule]UnityEngine.GameObject::.ctor(string)
IL_000b: stloc.0
IL_000c: ldloc.0
IL_000d: call void [UnityEngine.CoreModule]UnityEngine.Object::DestroyImmediate(class [UnityEngine.CoreModule]UnityEngine.Object)
// check null 部分指令
IL_0013: ldloc.0
IL_0014: brtrue.s IL_0019
IL_0016: ldnull
IL_0017: br.s IL_001f
IL_0019: ldloc.0
IL_001a: call instance class [UnityEngine.CoreModule]UnityEngine.Transform [UnityEngine.CoreModule]UnityEngine.GameObject::get_transform()
IL_001f: stloc.1
IL_0020: ret

上面生成的 CIL 指令,从 IL_0001IL_000d 主要是生成新的 GameObject(存在于 Managed Heap 中) 并将其引用存入 Record frame 的第 0 个变量处,然后调用 DestroyImmediate 方法销毁这个 GameObject。

接着就是 ?. 操作符部分的 CIL 指令(从 IL_0013 开始)。首先加载当前 Record frame 中的第一个变量值(这里是 GameObject 的引用)到 Evaluation stack 中,使用 brtrue.s 判断这个引用的对象是否为空,不为空则跳转到 IL_0019 处开始调用 get_transform 方法,为空则返回 null。这样看下来 ?. 操作符就是判断了当前 GameObject 引用对象是否为空,和普通 System.Object 对象 != null 处理类似。

那么绕过空检测出现错误异常的原因也就知道了。当我们调用 DestroyImmediate 方法销毁一个 GameObject 对象的时候,此时在底层对应的原生对象是被销毁了,但是其存在于 CLR 层的引用对象(方法结束后对象临时引用变量被释放销毁,存放于 Managed heap 中的 GameObject 对象等待 GC 释放)并未被销毁,若使用 ?. 操作符在 CLR 层面判断不为空,跳转到 get_transform 方法所在指令附近获取 transform,由于底层的 GameObject 已经被销毁所以底层抛出 'MissingReferenceException' 异常。

那么为什么使用常规 != null 形式判空没出现这个问题了,再来看看这种方式生成的 CIL 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.locals init (
[0] class [UnityEngine.CoreModule]UnityEngine.GameObject gameObject,
[1] class [UnityEngine.CoreModule]UnityEngine.Transform transform,
[2] bool
)
// 生成 GameObject 以及销毁 GameObject ...
// check null 部分指令
IL_0013: ldloc.0
IL_0014: ldnull
IL_0015: call bool [UnityEngine.CoreModule]UnityEngine.Object::op_Inequality(class [UnityEngine.CoreModule]UnityEngine.Object, class [UnityEngine.CoreModule]UnityEngine.Object)
IL_001a: stloc.2
IL_001b: ldloc.2
IL_001c: brfalse.s IL_0027
IL_001f: ldloc.0
IL_0020: callvirt instance class [UnityEngine.CoreModule]UnityEngine.Transform [UnityEngine.CoreModule]UnityEngine.GameObject::get_transform()
IL_0025: stloc.1
IL_0027: ret

前面的指令基本都相同,我们主要看看判空部分。首先加载了生成的 GameObject 以及 null,然后在 IL_0015调用了 UnityEngine.Object 类重载的 op_Inequality 操作符(operator !=)判断 GameObject 是否不为空,如果判定为空则跳转到 IL_0027 指令结束方法,否则从 IL_001a 处继续执行指令获取 transform。下面就来重点解读 UnityEngine.Object 重载的 != 操作符的实现部分。

首先看看源码:

1
2
3
4
public static bool operator !=(Object x, Object y)
{
return !Object.CompareBaseObjects(x, y);
}

具体的实现在 CompareBaseObjects 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
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);
if (flag1)
return !Object.IsNativeObjectAlive(rhs);
return lhs.m_InstanceID == rhs.m_InstanceID;
}

上面的方法用来比较两个 UnityEngine.Object 对象是否相等,lhs 和 rhs 分别代表操作符两边的参数:

  • 若两边参数在 CLR 层均为 null,判定相等;

  • 若右边参数在 CLR 层为 null,左边不为 null,根据左边参数 CLR 层所在对象对应的底层原生对象是否 Alive 返回比较结果;

  • 若左边参数在 CLR 层为 null,右边不为 null,根据右边参数 CLR 层所在对象对应的底层原生对象是否 Alive 返回比较结果;

  • 否则左右两边对象在 CLR 层对象均不为 null,比较它们的 m_InstanceID 是否相等。

在我们的测试代码中分别是 GameObject 对象和 null。通过前面的分析指导,调用 DestroyImmediate 方法销毁一个 GameObject 对象时仅底层对应的原生对象是被销毁了,但是其存在于 CLR 层的引用对象未被销毁;所以上面方法中 flag1 为 false,flag2 为 true,最终返回结果由 UnityEngine.Object 类的 IsNativeObjectAlive 方法决定,这个方法正是判断 CLR 层所在 GameObject 对应的原生对象是否处于 Alive 状态。

在测试代码中,调用了 DestroyImmediate 方法销毁了原生的对象,最终 CompareBaseObjects 方法返回 true,重载的 != 操作符返回 false。因此 get_transform 方法所在的指令不会被调用,从而不会出现错误异常。

!= 和 bool 操作符

!= 操作符同重载的 == 操作符逻辑刚好相反;bool 操作符具体实现也是调用 CompareBaseObjects 方法:

1
2
3
4
public static implicit operator bool(Object exists)
{
return !Object.CompareBaseObjects(exists, (Object) null);
}

从上面源码可以看出 bool 操作符内部同样使用了 CompareBaseObjects 方法将判定对象与 null 比较,如果判定对象为 null 返回 false,否则返回 true。

总结

到这里应该清楚某些情况下出现错误异常的原因了,在 Unity 文档中关于 Object 有下面这样一句提及:

This class doesn't support the null-conditional operator (?.) and the null-coalescing operator (??).

UnityEngine.Object 类重载了 ==!= 以及 bool 操作符,对于这几类操作 Unity 会结合 CLR 层的对象以及其底层对应原生对象来得到比较结果。

所以对于 UnityEngine.Object 类(子孙类)类型相关变量使用 ?.?? 操作符一定要谨慎,有时显式的使用 null 判断或 bool 重载符对 UnityEngine.Object 往往更“安全”。

关于 MonoBehaviour 可序列化的域

对于 MonoBehaviour 可序列化的域,在 Editor 模式下就算这些域没有真正的被“赋值”,Unity 默认也会为其 CLR 层所在的 GameObject 对象默认设置上 “fake null” object (底层原生对象不会被赋值),通过这样的小 track Unity 能够为开发者调试提供更多的有用信息。

参考