跳到主要内容

对象模型

更新日期:2025-03-10

虚幻引擎的应用层采用 C++/脚本混合开发的模式,而 Z# 是一个非完全基于 UObject 系统的脚本引擎,因此无法像蓝图一样将底层细节完全抽象掉。 这意味着 Z# 中存在一些蓝图不需要考虑的问题,理解成本会稍高于蓝图。

Z# 要求开发者对 UObject 和 .NET 的对象模型都有一定了解,并在此基础上理解 Z# 的对象模型。 这篇文章介绍了 Z# 的对象模型,包含 C++ 和脚本对象如何建立绑定,两侧的对象生命周期如何同步等话题。 了解这些内容可以帮助你有效避免使用 Z# 时出现一些诡异的内存问题。

共轭映射

脚本引擎最核心的职责是提供与宿主引擎互操作的能力,但托管代码无法直接理解非托管对象,非托管代码也无法直接理解托管对象。 因此,当一个对象需要同时被两侧访问时,就必须要做以下处理:

  • 对象所在的侧直接访问对象本身,对象本身称为 原对象 (Primitive Object),原对象的类型称为 原类型 (Primitive Type)
  • 对象所在的对侧使用一个代理对象间接访问,代理对象称为 共轭对象 (Conjugate Object),共轭对象的类型称为 共轭类型 (Conjugate Type)
  • 存在一个原对象到共轭对象的高速双向映射,能够快速在原对象和共轭对象之间转换,这个转换操作称为 共轭映射 (Conjugate Mapping)

虽然定义上没有限制原对象是属于托管侧还是非托管侧,但实践上所有需要共轭映射的类型都是非托管侧的。 这是因为两侧存在明确的层级关系,即非托管侧是托管侧的底层,因此非托管侧没有理由依赖托管侧的具体类型。

共轭生命周期

由于 UObject 和 .NET 都有垃圾回收机制,但两者却完全无法同步。不是原对象先于共轭对象销毁,就是共轭对象先于原对象销毁。

这也是 Z# 与蓝图最重要的区别。 蓝图中的任何对象和引用都依托于 UProperty(这种说法不准确,但可以大致这么认为),即使是局部变量。这意味着蓝图中的所有对象和引用都能正确被 GC 管理; 而 Z# 与 C++ 一样,存在孤立对象和引用,即 GC 看不到的对象和引用,比如栈上的、未注册到 GC 的堆上的、寄存器中的对象和引用。 孤立对象和引用的存在使得内存管理变得更加复杂。

在 C++ 中,误用孤立对象往往会导致未定义行为。但 Z# 作为一个脚本引擎,希望最大限度实现内存安全。

对此,Z# 的策略是让共轭对象永远不早于原对象销毁(热重载会破坏这个假设,但不在这里讨论)。 这样做的原因是,我们可以监听任何托管对象的销毁,但无法监听所有非托管对象的销毁。 对于无法精确控制的非托管对象,Z# 采用保守策略——只要满足被销毁的某个必要非充分条件,就提前将共轭对象标记为无效,进而杜绝一切空悬引用的出现。 这种策略会在一定程度上降低 Z# 的易用性,但安全性对于一个脚本引擎而言是绝对的,我们认为这个牺牲是值得的。

运行时,托管侧可以通过 IExplicitLifecycle.IsExpired 属性判断一个共轭对象是否失效。

信息

Z# 选择将 UStruct 也实现为引用类型,因为 .NET 的值类型与 C++ 的对象语义并不等价,即使用值类型也无法正确实现拷贝和移动等操作。

共轭对象的颜色

实际上,如果不做任何补偿机制,上述机制会造成非常大的不便。 例如:托管侧得到了一个 Vector 引用,但由于这个引用可能在任意时刻被标记为无效,想缓存它就不得不通过间接手段实现。

为了解决这个问题,Z# 引入了颜色的概念,按原对象的所有权将共轭对象进一步划分为 红色黑色

  • 红色:所有权归属于 非托管侧,所有 UObject 都是红色,原对象被回收时将共轭对象标记为无效;其他共轭对象的有效期都可以粗略地视为与局部变量相同,即会在获取引用的方法返回后失效。
  • 黑色:所有权归属于 托管侧,当且仅当共轭对象被销毁或显式释放时,原对象才会被连带销毁。

逻辑上,托管侧直接实例化的共轭对象是黑色的,其他情况下全是红色的;运行时,托管侧可以通过 IConjugate.IsBlack 属性判断一个共轭对象是否是黑色。 你可以直接缓存任何 UObject 引用,因为 UObject 共轭的有效期是精确绑定到原对象生命周期的; 除 UObject 外,所有共轭类型都实现了 ICloneable<T> 接口,你可以通过 Clone() 方法创建一个拷贝。 所有的共轭对象的拷贝都是黑色的,这意味着你可以缓存红色共轭的拷贝。

黑色共轭对象会被引擎视为 GC Root,这意味着它们内部的强引用,包括结构体的属性,通过 ARO 显式注册的引用,SubclassOfScriptInterfaceFieldPath 指向的对象,以及容器的元素,都能正确被 GC 扫描到。 在黑色共轭对象释放前,它们引用的 UObject 不会被释放;如果你想要弱引用,则需要使用 WeakObjectPtr。 这与 UE GC 原本的行为是一致的。

注意

考虑到这个特性对性能的影响比较大,我们预留了 ZSHARP_TREATS_BLACK_CONJUGATE_AS_GC_OBJECT 宏来开关这个特性。 但通常情况下,建议开启这个特性,并应该通过 Dispose() 及时释放黑色共轭这种安全的方式来避免给 GC 造成压力。 如果你认为它真的很影响性能,那么你可以关闭这个特性。 一旦你关闭这个特性,脚本将不再保证内存安全——它可能会造成崩溃。

共轭类型表

非托管类型托管类型分类
UE ObjectUnrealObjectUser Type
UE StructUnrealScriptStructBaseUser Type
FStringUnrealStringString
FUtf8StringUnrealUtf8StringString
FAnsiStringUnrealAnsiStringString
FNameUnrealNameString
FTextUnrealTextString
TSubclassOfSubclassOfObject Wrapper
TSoftClassPtrSoftClassPtrObject Wrapper
TSoftObjectPtrSoftObjectPtrObject Wrapper
TLazyObjectPtrLazyObjectPtrObject Wrapper
TWeakObjectPtrWeakObjectPtrObject Wrapper
TScriptInterfaceScriptInterfaceObject Wrapper
TArrayUnrealArrayContainer
TSetUnrealSetContainer
TMapUnrealMapContainer
TOptionalUnrealOptionalContainer
FScriptDelegateUnrealDelegateBaseDelegate
FMulticastScriptDelegateUnrealMulticastInlineDelegateBaseDelegate
FSparseDelegateUnrealMulticastSparseDelegateBaseDelegate
FFieldPathFieldPathMisc