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 相关代码

反编译获得,不是源码。

// 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 引擎对象是否已销毁?

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

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

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

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

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

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

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

空条件运算符

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

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

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

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

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

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 判断错误的问题,也提高了代码的可读性。

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

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

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

译文 {% post_link 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 最终选择了不对其修改,而是修复由此带来的种种问题。

该文章转载自:https://blog.ryuu64.top/Unity-UnityEngine.Object%E7%9A%84%E8%87%AA%E5%AE%9A%E4%B9%89%E6%AF%94%E8%BE%83/

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值