Unity-UnityEngine.Object的自定义比较

详情

Unity 对象的空检测

UnityEngine.Object 有其自定义的空检测方法

因此 UnityEngine.Object 有两种空检测:

  1. 检测 Unity 原生对象是否被销毁 (使用 UnityEngine.Object 自定义空检测)
  2. 检测 Unity 对象是否初始化与正确引用 (使用 object.ReferenceEquals(monoBehaviour, null))

Unity 对象的生与死

原生对象与包装对象:

Unity 是基于 C/C++ 的引擎,GameObject 的所有实际信息 (name、component list、HideFlags 等等) 都存储在 C++ 对象中。此类对象被称为“原生对象” (native object)

C# GameObject 所有的仅是指向原生对象的指针 (pointer)。此类对象被称为“包装对象” (wrapper object)

C# 与 C++ 有不同的内存管理方式,这意味着包装对象与其包裹的原生对象有着不同的生命周期

当原生对象已被销毁,包装对象依然存在时,将包装对象其与 null 比较,UnityEngine 的 == 运算符严格执行 Unity object 底层的生命周期检查,返回 “true”

Real null 与 Fake null:

在 Editor only 时,MonoBehaviour 不是 “real null” 而是 “fake null”。[1]

Unity 在 fake null object 中存储信息。当执行其方法 (method),或访问其属性 (property) 时,可提供更多的上下文信息:

在 fake null object 中存储信息,Unity 能够在检视窗口 (Inspector) 中高亮该 GameObject,并给出更多指引。如:”looks like you are accessing a non initialized field in this MonoBehaviour over here, use the inspector to make the field point to something” (看来您试图访问此 MonoBehaviour 的未实例化字段,请在检视窗口使其指向实例)。

若不在 fake null object 中存储信息,只能得到 NullReferenceException 与堆栈跟踪。并不知道具体是哪个带有 MonoBehaviour 的 GameObject 有字段为 null。

UnityEngine 的 == 运算符能够检测是否存在 fake null object

Unity 相关代码

反编译获得,不是源码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// 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);

public static implicit operator bool(Object exists) => !Object.CompareBaseObjects(exists, (Object) null);

public override bool Equals(object other)
{
Object rhs = other as Object;
return (!(rhs == (Object) null) || other == null || other is Object) && Object.CompareBaseObjects(this, rhs);
}

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

private static bool IsNativeObjectAlive(Object o)
{
if (o.GetCachedPtr() != IntPtr.Zero)
return true;
return !(o is MonoBehaviour) && !(o is ScriptableObject) && Object.DoesObjectWithInstanceIDExist(o.GetInstanceID());
}

/// <summary>
/// <para>Returns the instance id of the object.</para>
/// </summary>
[SecuritySafeCritical]
public int GetInstanceID()
{
this.EnsureRunningOnMainThread();
return this.m_InstanceID;
}

private void EnsureRunningOnMainThread()
{
if (!Object.CurrentThreadIsMainThread())
throw new InvalidOperationException("EnsureRunningOnMainThread can only be called from the main thread");
}

private IntPtr GetCachedPtr() => this.m_CachedPtr;

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

[NativeMethod(IsFreeFunction = true, IsThreadSafe = true, Name = "CurrentThreadIsMainThread")]
[MethodImpl(MethodImplOptions.InternalCall)]
private static extern bool CurrentThreadIsMainThread();

如上所示,Unity 实现了自己的空判断,并将其应用于重载的 != 运算符、== 运算符、隐式 bool 转换运算符及重写的 System.Object 的 Equals(object obj) 中。

其中涉及许多的逻辑,如确保方法调用于主线程,指定实例 id 的 UnityEngine.Object 是否存在,缓存的指针是否为 IntPtr.Zero,比较的两 UnityEngine.Object 的 实例 id 是否相同。及其他的外部方法调用。因此,相比于 object.ReferenceEquals() 的调用会被编译器优化为简单的空检查,Unity的自定义比较需要执行许多逻辑,速度较慢

编写规范

上文提到了 Unity 对象的两种 null 检测,编写代码时,一定要明确表意,确定为其中的一种

特别的,C# 的空合并运算符与空条件运算符将会绕过 Unity 的生命周期检查,导致表意不明:[2]

空合并运算符

以下示例的表意不明确:是检查 gameObject 是否正确引用,还是检查原生 Unity 引擎对象是否已销毁?

1
2
// DON'T DO THIS!
var go = gameObject ?? CreateNewGameObject();

若目的是检查底层引擎对象的生命周期,则此代码不正确,因为生命周期检查被绕过。

使用显式 null 或 boolean 比较修复代码:

1
2
3
var go = gameObject != null ? gameObject : CreateNewGameObject();
// 也可使用隐式的 bool 转换运算符进行同样的检测
go = gameObject ? gameObject : CreateNewGameObject();

若目的是确保 gameObject 变量已被初始化并分配了有效的 C# 引用,推荐使用 object.ReferenceEquals():

1
return !object.ReferenceEquals(gameObject, null) ? gameObject : CreateNewGameObject();

虽然稍显冗长,但表意十分明确。

空条件运算符

以下示例的表意同样不明确:

1
2
// DON'T DO THIS!
monoBehaviour?.Invoke("Attack", 1.0f);

同样的,若目的是简单地检查 monoBehaviour 变量是否已正确初始化与引用,推荐使用 object.ReferenceEquals():

1
2
if (!object.ReferenceEquals(monoBehaviour, null))
monoBehaviour.Invoke("Attack", 1.0f);

若目的是检查底层引擎对象的生命周期,推荐使用显式的 null 或 boolean 比较:

1
2
3
4
5
if (monoBehaviour != null)
monoBehaviour.Invoke("Attack", 1.0f);
// 也可使用隐式的 bool 转换运算符
if (otherBehaviour)
otherBehaviour.Invoke("Attack", 1.0f);

个人解决方案

如果只是想检测 GameObject 是否初始化与正确引用,可以考虑使用 unity 平台宏 以及 C# 扩展方法对 ReferenceEquals 进行封装。[3]

这样避免了在 editor 时 fake null object 引发的 ReferenceEquals 判断错误的问题,也提高了代码的可读性。

1
2
3
4
5
6
7
8
public static bool IsSet(this GameObject gameObject)
{
#if UNITY_EDITOR
return gameObject;
#else
return !ReferenceEquals(gameObject, null);
#endif
}

个人思考

Unity 在与 null 进行比较时判断原生对象是否存活,而是不是检测 C# 对象。这种设计是反直觉的,大多数用户未注意到这种自定义比较。Custom == operator, should we keep it? | Unity Blog Unity 自己的开发者都忘记了。

C# 的引用类型,若不是”值类” (Value class),应采用默认的比较逻辑 (直接对引用进行比较),不应重载的 !=、== 及隐式 bool 转换运算符,不应重写 System.Object 的 Equals(object obj) 方法。

UnityEngine.Object 的比较逻辑有把自己的本职工作做好 (直接对引用进行比较),又做了其他的工作 (判断原生对象是否存活),这不符合单一职责原则。导致了两种空判断的存在,造成了可能的语义不明,与潜在的性能下降。这样增添的逻辑也导致其表现与 C# 的空合并运算符和空条件运算符不一致。导致在使用 UnityEngine.Object 没法很好的使用这两种运算符。若使用,则表意不明确,若不使用,则降低了代码的可读性 (见编写规范)。

更好的方法可能是在 UnityEngine.Object 中加入 destroyed 这样的字段标识原生对象的存活情况。当用户想到知道时进行调用。[4]

参阅

Unity 的说明 Custom == operator, should we keep it? | Unity Blog

译文 Unity-自定义==运算符,我们应该保留它吗

Resharper-unity 的说明 Possible unintended bypass of lifetime check of underlying Unity engine object · JetBrains/resharper-unity Wiki

译文 Unity-Resharper-可能意外绕过Unity引擎对象的底层生命周期检查

?? 和 ??= 运算符 - C# 参考 | Microsoft Docs

成员访问运算符和表达式 Null 条件运算符 ?. 和 ?[] - C# 参考

扩展方法 - C# 编程指南 | Microsoft Docs

Real null 与 Fake null 的测试可见我的 github 仓库:UnityEngineObjectNullCheck (分别打包运行与编辑器运行对比区别)

注释

[1] 仅在编辑器中有这种情况。这也是为什么调用GetComponent() 查询不存在的组件时,有 C# 内存分配产生,因为此时 fake null object 中正在生成自定义警告字符串。

这也是为什么测试游戏需要打包测试,而不是在编辑器测试。为了给用户提供便利,编辑器中做了很多额外的工作 (用例、安全检查等),但是牺牲了性能。

[2] 空合并运算符与空条件运算符是无法重载的,可能是因为这点,Unity 无法令其进行自定义的空检查

[3] 扩展方法 - C# 编程指南 | Microsoft Docs

[4] Unity 最终选择了不对其修改,而是修复由此带来的种种问题。