Unity-UnityEngine.Object的自定义比较
详情
Unity 对象的空检测
UnityEngine.Object 有其自定义的空检测方法。[1]
因此 UnityEngine.Object 有两种空检测:
- 检测 Unity 原生对象是否被销毁 (使用 UnityEngine.Object 自定义空检测)
- 检测 Unity 对象是否初始化与正确引用 (使用 object.ReferenceEquals(monoBehaviour, null))[2]
Unity 对象的生与死
原生对象与包装对象:
Unity 是基于 C/C++ 的引擎,GameObject 的所有实际信息 (name、component list、HideFlags 等等) 都存储在 C++ 对象中。此类对象被称为**“原生对象” (native object)**。
C# GameObject 所有的仅是指向原生对象的指针 (pointer)。此类对象被称为**“包装对象” (wrapper object)**。
C# 与 C++ 有不同的内存管理方式,这意味着包装对象与其包裹的原生对象有着不同的生命周期。[3]
当原生对象已被销毁,包装对象依然存在时,将包装对象其与 null 比较,UnityEngine 的 == 运算符严格执行 Unity object 底层的生命周期检查,返回 “true”。[4]
Real null 与 Fake null:
在 Editor only 时,MonoBehaviour 不是 “real null” 而是 “fake null”。[1][5]
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。[6]
Unity 相关代码
反编译获得,不是源码。
1 | // UnityEngine.Object |
如上所示,Unity 实现了自己的空判断,并将其应用于重载的 != 运算符、== 运算符、隐式 bool 转换运算符及重写的 System.Object 的 Equals(object obj) 中。[7]
其中涉及许多的逻辑,如确保方法调用于主线程,指定实例 id 的 UnityEngine.Object 是否存在,缓存的指针是否为 IntPtr.Zero,比较的两 UnityEngine.Object 的 实例 id 是否相同。及其他的外部方法调用。因此,相比于 object.ReferenceEquals() 的调用会被编译器优化为简单的空检查,Unity的自定义比较需要执行许多逻辑,速度较慢。[8]
编写规范
上文提到了 Unity 对象的两种 null 检测,编写代码时,一定要明确表意,确定为其中的一种。[9]
特别的,C# 的空合并运算符与空条件运算符将会绕过 Unity 的生命周期检查,导致表意不明:[2][10]
空合并运算符
以下示例的表意不明确:是检查 gameObject 是否正确引用,还是检查原生 Unity 引擎对象是否已销毁?
1 | // DON'T DO THIS! |
若目的是检查底层引擎对象的生命周期,则此代码不正确,因为生命周期检查被绕过。
使用显式 null 或 boolean 比较修复代码:
1 | var go = gameObject != null ? gameObject : CreateNewGameObject(); |
若目的是确保 gameObject 变量已被初始化并分配了有效的 C# 引用,推荐使用 object.ReferenceEquals():[11]
1 | return !object.ReferenceEquals(gameObject, null) ? gameObject : CreateNewGameObject(); |
虽然稍显冗长,但表意十分明确。
空条件运算符
以下示例的表意同样不明确:
1 | // DON'T DO THIS! |
同样的,若目的是简单地检查 monoBehaviour 变量是否已正确初始化与引用,推荐使用 object.ReferenceEquals():
1 | if (!object.ReferenceEquals(monoBehaviour, null)) |
若目的是检查底层引擎对象的生命周期,推荐使用显式的 null 或 boolean 比较:[12]
1 | if (monoBehaviour != null) |
个人解决方案
如果只是想检测 GameObject 是否初始化与正确引用,可以考虑使用 unity 平台宏 以及 C# 扩展方法对 ReferenceEquals 进行封装。[3]
这样避免了在 editor 时 fake null object 引发的 ReferenceEquals 判断错误的问题,也提高了代码的可读性。
1 | public static bool IsSet(this GameObject gameObject) |
个人思考
Unity 在与 null 进行比较时判断原生对象是否存活,而是不是检测 C# 对象。这种设计是反直觉的,大多数用户未注意到这种自定义比较。Custom == operator, should we keep it? | Unity Blog Unity 自己的开发者都忘记了。[13]
C# 的引用类型,若不是"值类" (Value class),应采用默认的比较逻辑 (直接对引用进行比较),不应重载的 !=、== 及隐式 bool 转换运算符,不应重写 System.Object 的 Equals(object obj) 方法。
UnityEngine.Object 的比较逻辑有把自己的本职工作做好 (直接对引用进行比较),又做了其他的工作 (判断原生对象是否存活),这不符合单一职责原则。导致了两种空判断的存在,造成了可能的语义不明,与潜在的性能下降。这样增添的逻辑也导致其表现与 C# 的空合并运算符和空条件运算符不一致。导致在使用 UnityEngine.Object 没法很好的使用这两种运算符。若使用,则表意不明确,若不使用,则降低了代码的可读性 (见编写规范)。
更好的方法可能是在 UnityEngine.Object 中加入 destroyed 这样的字段标识原生对象的存活情况。当用户想到知道时进行调用。[4][14]
参阅
Unity 的说明 Custom == operator, should we keep it? | Unity Blog
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 最终选择了不对其修改,而是修复由此带来的种种问题。
Unity 官方 Object 文档和
Object.operator ==文档说明,UnityEngine.Object与null比较时会同时检查 managed object reference 和 native object pointer;UnityCsReference 也显示operator ==进入CompareBaseObjects:https://docs.unity3d.com/6000.0/Documentation/ScriptReference/Object.html、https://docs.unity3d.com/6000.0/Documentation/ScriptReference/Object-operator_eq.html、https://github.com/Unity-Technologies/UnityCsReference/blob/master/Runtime/Export/Scripting/UnityEngineObject.bindings.cs。 ↩︎Unity programming best practices 明确区分 Unity null check 与真实 C# null check:检测已销毁的 Unity 对象用
obj == null,检测真实 C# null 引用可用object.ReferenceEquals或转成System.Object后比较;MicrosoftObject.ReferenceEquals文档说明它判断两个 object reference 是否同一实例或都为 null:https://docs.unity.cn/6000.5/Documentation/Manual/programming-best-practices.html、https://learn.microsoft.com/en-us/dotnet/api/system.object.referenceequals。 ↩︎Unity 官方 custom operator 博客说明 C# wrapper 与 C++ native object 生命周期不同;Unity 6
Object文档也描述 wrapper 仍存在但 native object 已销毁或卸载的 detached state:https://unity.com/blog/engine-platform/custom-operator-should-we-keep-it、https://docs.unity3d.com/6000.0/Documentation/ScriptReference/Object.html。 ↩︎Unity custom operator 博客说明 native object 被销毁而 C# wrapper 仍存在时,Unity 的 custom
== null让对象表现得像 null;Object.operator ==文档也说明与 null 比较会检查 native object pointer:https://unity.com/blog/engine-platform/custom-operator-should-we-keep-it、https://docs.unity3d.com/6000.0/Documentation/ScriptReference/Object-operator_eq.html。 ↩︎Unity custom operator 博客说明 Editor 中可能出现 fake null object,用来保存更多诊断上下文,帮助 Inspector 指出未初始化字段;这类行为是 Editor 诊断机制,不应直接外推为 Player 业务契约:https://unity.com/blog/engine-platform/custom-operator-should-we-keep-it。 ↩︎
Unity custom operator 博客把 fake null 纳入 custom null check 的讨论;UnityCsReference 中
CompareBaseObjects/IsNativeObjectAlive路径体现 Unity 比较并非普通 CLR reference comparison,而是 Unity object 生命周期语义:https://unity.com/blog/engine-platform/custom-operator-should-we-keep-it、https://github.com/Unity-Technologies/UnityCsReference/blob/master/Runtime/Export/Scripting/UnityEngineObject.bindings.cs。 ↩︎UnityCsReference 显示
operator ==、operator !=、implicit operator bool和Equals(object)都围绕CompareBaseObjects实现;这支撑原文对 Unity 自定义比较入口的归纳:https://github.com/Unity-Technologies/UnityCsReference/blob/master/Runtime/Export/Scripting/UnityEngineObject.bindings.cs。 ↩︎Unity programming best practices 提醒,在性能敏感代码中 Unity null comparison 比普通 C# 引用检查更昂贵;JetBrains Inspectopedia 也有 performance-critical code 中 Unity null comparison 的 inspection:https://docs.unity.cn/6000.5/Documentation/Manual/programming-best-practices.html、https://www.jetbrains.com/help/inspectopedia/Unity.PerformanceCriticalCodeNullComparison.html。 ↩︎
JetBrains ReSharper / Rider 的 Unity null inspections 针对
??、?.、pattern matching 等写法发出提示,本质是在要求开发者区分 CLR null 与 Unity native object liveness,而不是机械禁止某种语法:https://github.com/JetBrains/resharper-unity/wiki/Possible-unintended-bypass-of-lifetime-check-of-underlying-Unity-engine-object、https://www.jetbrains.com/help/inspectopedia/Unity.NoNullCoalescing.html、https://www.jetbrains.com/help/inspectopedia/Unity.NoNullPropagation.html。 ↩︎Microsoft C# 文档说明
??、??=、?.、?[]这类运算符不能重载,且 null-coalescing / null-conditional 语义基于左操作数是否为 null;因此 Unity 无法让这些语法自动进入UnityEngine.Object.operator ==的生命周期检查:https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/null-coalescing-operator、https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/member-access-operators、https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/operator-overloading。 ↩︎Microsoft
Object.ReferenceEquals文档说明它只判断两个 object reference 是否引用同一实例,或两者是否都为 null;Unity best practices 也把object.ReferenceEquals作为真实 C# null check 的推荐方式之一:https://learn.microsoft.com/en-us/dotnet/api/system.object.referenceequals、https://docs.unity.cn/6000.5/Documentation/Manual/programming-best-practices.html。 ↩︎Unity best practices 推荐用
obj == null检测已销毁的 Unity 对象;JetBrains Unity inspections 也建议需要 Unity 生命周期语义时用显式obj != null/if (obj),而不是??或?.:https://docs.unity.cn/6000.5/Documentation/Manual/programming-best-practices.html、https://www.jetbrains.com/help/inspectopedia/Unity.NoNullCoalescing.html、https://www.jetbrains.com/help/inspectopedia/Unity.NoNullPropagation.html。 ↩︎Unity 官方 custom operator 博客直接讨论了
UnityEngine.Object自定义==是否应该保留,承认该设计有反直觉和维护成本,同时也说明移除会带来大规模兼容性问题:https://unity.com/blog/engine-platform/custom-operator-should-we-keep-it。 ↩︎Unity custom operator 博客讨论过显式 destroyed 状态这类更清晰的 API 设计方向,但最终因为生态兼容性与迁移成本,没有直接移除 custom
==语义:https://unity.com/blog/engine-platform/custom-operator-should-we-keep-it。 ↩︎