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

(Ryuu: 附上原文地址 : Custom == operator, should we keep it?)

正文

Unity 的 == 运算符有特殊实现 (UnityEngine.Object 重载了 == 及 != 运算符)。

  1. 当一个 MonoBehaviour 有字段,在 editor only [1] 时,这些字段不是 “real null”,而是 “fake null”。UnityEngine 的 == 运算符能够检测是否存在 fake null object。

    虽然这样做很奇怪,但这能让 Unity 在 fake null object 中存储信息。当执行其方法 (method),或访问其属性 (property),提供更多的上下文信息。

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

    在 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 中试图访问未实例化字段,请在检视窗口使其指向实例)。

  2. 第二点稍加复杂。

    当你获取 GameObject 类型的 c# object [2],他几乎不包含任何信息。这是因为 Unity 是基于 C/C++ 的引擎。关于此 GameObject 的所有实际信息 (name,component list,HideFlags,等等) 都存活在 C++ 侧。C# object 所有的仅是指向原生对象 (native object) 的指针 (pointer)。我们称这样的对象为“包装对象” (wrapper objects)

    这些如 GameObject 的 c++ objects 及其他一切继承自 UnityEngine.Object 的生命周期都被明确的管理。当你加载新场景,或在其上调用 Object.Destroy(myObject); 时,这些 Object 将会被销毁。

    C# object 的生命周期有 C# 的管理方式,其具有垃圾收集器 (garbage collector) [4]。这意味着,有可能被包裹的 C++ Object 已经被销毁,但包裹它的 C# 包装对象依然存在。将此对象与 null 比较,UnityEngine 重载的 == 会返回 true,尽管实际上的 C# 变量 (variable) 不为 null。

UnityEngine 自定义的空检测 (null check) 也导致许多缺陷

  • 这种自定义十分反直觉
  • 对两个 UnityEngine.Object 比较或与 null 比较,会比想象中的要慢
  • UnityEngine 重载的 == 是非线程安全 (not thread safe) 的 (这点 Unity 可在后续修复)
  • 其与 ?? 操作符的表现不一致,?? 同样进行空检测,但这是纯粹的 C# 空检测,会绕过 UnityEngine.Object 自定义空检测 [5]

回顾所有的这些优缺点,如果从头再构建 API,我们将不会选择自定义空检查,而是创建一个 myObject.destroyed 的属性,访问该属性以检测 object 的生死。让用户接受在空字段调用方法时不再提供更好的错误信息的事实。

我们在思考我们是否应该改变此自定义运算符,我们一直在寻找 “修复,清除原始项目” 与 “不要破坏原始项目” 之间的正确的平衡。在这种情况下,我们想了解其他人的思考。

对于 Unity5,我们一直在研究 Unity 自动更新脚本的能力 (于随后的博客中对此进行了详细介绍)。不幸的是,在本文情况下,我们无法使您的脚本自动升级 (无法准确辨识 “这个旧脚本确实需要旧的 behaviour” 和 “这个新脚本确实需要新的 behaviour” 的区分)。

我们倾向于 “移除自定义 == 运算符”,但还在犹豫,因为这将改变您工程中所有空检查的意义。对于对象不是 “really null” 而是已销毁对象的情况来说,空检查通常返回 true,如果我们修改了它,就会返回 false 了。若想检测变量是否指向被摧毁对象,需要把代码改成 “if (myObject.destroyed) {}”。我们对此有点紧张,无论你有没有读这篇文章,都很容易意识不到这种行为的改变,特别是大多数人根本没有意识到这种自定义空检查的存在。[3]

如果我们作修改,应在 Unity5 上。对于非主要发行版,我们允许用户承受的升级痛苦阈值更低。

你希望我们怎么做?以必须更改已有项目中的空检查为代价,提供更整洁的体验,或是保持现状?

再见, Lucas (@lucasmeijer) **[6]

注释

[1] 我们只在编辑器中执行此操作。这就是为什么在调用GetComponent() 查询不存在的组件时,会看到 C# 内存分配产生,因为我们正在新分配的伪空对象中生成自定义警告字符串。在打包的游戏中,这种内存分配不会发生。这就是为什么测试游戏时,应在打包出来的独立端 (standalone 如 Mac, Windows or Linux) 或移动端测试,而不是在编辑器测试,因为我们在编辑器中做了很多额外的 安全/用例检查,以使你的工作更轻松,而牺牲了一些性能。在分析性能和内存分配时,永远不要在编辑器,应始终分析构建出的游戏。

[2] 不仅适用于 GameObject,也适用于继承自 UnityEngine.Object 的所有类。

[3] 有趣的故事: 我在优化GetComponent()性能时遇到了这个问题,在为 transform 组件做一些缓存实现时,我没有看到任何性能优势。@jonasechterhoff 也研究了此问题,得出了相同的结论。缓存代码如下所示:

1
2
3
4
5
6
7
8
9
10
private Transform m_CachedTransform
public Transform transform
{
get
{
if (m_CachedTransform == null)
m_CachedTransform = InternalGetTransform();
return m_CachedTransform;
}
}

事实证明,我们自己的两位工程师都没有注意到空检查会比想象中更费时。这就是缓存没有带来速度提升的原因,”连我们自己都忘记了它 (Unity 自定义空检测),会有多少用户会忘记了它?”,这使得我写下了此篇文章 :)

[4] Ryuu: C# 托管对象的回收是 C# 垃圾回收器管理的,不能保证其生命周期和 C++ 侧对象完全一致。

[5] Ryuu: 原文是 “and cannot be bypassed to call our custom null check”(可以绕过自定义空检测) 个人认为是写错了 (也可能是我英语太差,理解错了),实际情况是,使用 ?? 或 ?. 都会绕过 UnityEngine.Object 的自定义空检测。附上 JetBrains/resharper-unity 的解释页: Possible unintended bypass of lifetime check of underlying Unity engine object

[6] Ryuu: 最后作者真香了,并没有移除自定义的空检查,而是想办法修复其产生的缺陷。