自定义的==运算符,我们应该保留吗?
该文章译自Unity官网博客,原文:Custom == operator, should we keep it?
TL;DR: Unity的
==
是魔改版,与原生C#里的行为不一样。所以不要对UnityEngine.Object
派生出的任何东西(包括MonoBehaviour
)使用C#的空值相关语法糖,包括?.
,??
,??=
等等(三元运算符可以用),而应该老老实实使用== null
来判空。
¶正文
当你在Unity中进行这样的操作时:
1 | if (myGameObject == null) |
Unity对==
运算符做了一些特殊处理。与大多数人所期望的不同,我们对==
运算符有一个特殊实现。
这样做是为了达到两个目的:
- 当一个
MonoBehaviour
有字段时,仅仅在编辑器中1,我们不会将这些字段设置为“真null”,而是设置为一个“假null”对象。我们自定义的==
操作符能够检查某物是否是这种假null,并采取相应的行为。虽然这种机制怪怪的,但它能让我们在假null中存储信息,这样当你对它调用一个方法,或者读取一个属性时,它可以给你更多的上下文信息。如果没有这个技巧,你只会得到一个NullReferenceException
,一个堆栈跟踪,但你不知道到底哪个GameObject
的MonoBehaviour
的字段是空的。而有了这个技巧,我们就可以在检查器中突出显示GameObject
,也可以给你更多的提示:“看起来你正在访问这个MonoBehaviour
中的一个未初始化的字段,请用Inspector将这个字段指向某个东西”。
目的二就有点复杂了:
- 当你得到一个
GameObject
类型的C#对象时2,它几乎什么都不包含。这是因为Unity引擎是一个C/C++引擎。GameObject
的所有实际信息(名称、Component列表、HideFlags等)都在C++端。C#对象只有一个指向本地对象的指针。我们称这些C#对象为“包装对象”。这些C++对象的生命周期,如GameObject
和其他所有从UnityEngine.Object
派生的对象,都是被明确地托管的。当你加载一个新场景,或者当你对它们调用Object.Destroy(myObject);
时,这些对象会被销毁。C#对象的生命周期通过垃圾回收以C#的方式管理。这意味着可能有一个仍然存在的C#包装对象,包装着已经被销毁的C++对象。如果你把这个C#对象和null进行比较,我们自定义的==
操作符在这种情况下会返回true
,尽管实际的C#变量实际上并不是真正的null。
虽然这两个理由非常合理,但自定义判空也带来了一堆缺点。
- 违反直觉。
- 将两个
UnityEngine.Object
互相比较或者与null
比较,比你想象中的要慢。 - 自定义的
==
运算符不是线程安全的,所以你不能在主线程之外比较对象(这个我们可以解决)。 - 它和
??
运算符的行为不一致,虽然后者也会判空,但它做的是纯C#的判空,不能绕过它来调用我们的自定义判空。
考虑到这些优缺点,如果我们从头构建API,我们会选择不做自定义判空,而是设计一个myObject.destroy
属性,让你用它来检查对象是否已经死亡,同时接受这样的事实:如果你真的在空字段上调用函数,我们没法再给出较好的错误信息。
我们正在考虑是否应该做出改变。这是我们对“修整旧事物”和“不破坏旧项目”之间正确平衡的不断探索中的一步。为此,我们想知道你的想法。对于Unity5更新,我们一直在努力让Unity能够自动升级你的脚本(详见之后的博文)。不幸的是,对于这种情况,我们无法自动升级你的脚本。(因为我们无法区分“这是一个实际想要旧行为的旧脚本”,和“这是一个实际想要新行为的新脚本”)。
我们倾向于“删除自定义的==
运算符”,但是很让人纠结,因为这将改变你的项目中目前所有判空的意义。对于对象不是“真正的null”而是一个被destroy的对象的情况,== null
判空曾经返回true
,如果我们做了改变,它将返回false
。如果你想检查你的变量是否指向一个被destroy的对象,你需要将代码改为检查if (myObject.destroy) {}
。我们对此有点紧张,因为如果你没有读过这篇博文,十有八九就算你读过,也很容易意识不到这种行为的改变,何况大多数人根本都没意识到这种自定义判空的存在3。
即使我们要改变它,也应该在Unity5里做,毕竟我们希望让用户在非主要版本更新里能少受点折磨。
你觉得我们怎么做好?是给你一个更清晰的体验,但你必须为此改变项目中的判空逻辑,还是保持原样?
以上,Lucas (@lucasmeijer)
¶备注
-
我们只在编辑器中这样做。这就是为什么当你调用
GetComponent()
查询一个不存在的组件时,你会发现发生了C#的内存分配,因为我们在新分配的假null中生成了这个自定义的警告字符串。这种内存分配不会发生在构建出的游戏中。这是一个很好的例子,如果你要对你的游戏做性能分析,你总该分析实际的电脑端或移动端,而不是在编辑器里进行分析,因为我们在编辑器里对安全性、使用方法做了很多额外的检查便于你开展工作,但却牺牲了一些性能。当对性能表现和内存分配进行分析时,绝对不要对编辑器进行分析,而是总应对构建出的游戏进行分析。 -
这不仅适用于GameObject,而且适用于所有从UnityEngine.Object派生出来的东西。
-
有趣的故事:我在优化
GetComponent<T>()
性能时遇到了这个问题,在为Transform
组件实现一些缓存时,我没得到任何性能提升。然后@jonasechterhoff看了看这个问题,也得出了同样的结论。缓存代码看起来像这样:
1 | private Transform m_CachedTransform |
事实证明,我们的两位工程师没有料到判空的开销比预期的高,这就是没看到缓存带来的速度优势的原因。“如果就连我们都踩坑了,那么踩坑的用户会有多少呢?”,于是乎就有了这篇博文:)