对象模型
虚幻引擎的应用层采用 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 显式注册的引用,SubclassOf
,ScriptInterface
和 FieldPath
指向的对象,以及容器的元素,都能正确被 GC 扫描到。
在黑色共轭对象释放前,它们引用的 UObject 不会被释放;如果你想要弱引用,则需要使用 WeakObjectPtr
。
这与 UE GC 原本的行为是一致的。
考虑到这个特性对性能的影响比较大,我们预留了 ZSHARP_TREATS_BLACK_CONJUGATE_AS_GC_OBJECT 宏来开关这个特性。 但通常情况下,建议开启这个特性,并应该通过 Dispose() 及时释放黑色共轭这种安全的方式来避免给 GC 造成压力。 如果你认为它真的很影响性能,那么你可以关闭这个特性。 一旦你关闭这个特性,脚本将不再保证内存安全——它可能会造成崩溃。
共轭类型表
非托管类型 | 托管类型 | 分类 |
---|---|---|
UE Object | UnrealObject | User Type |
UE Struct | UnrealScriptStructBase | User Type |
FString | UnrealString | String |
FUtf8String | UnrealUtf8String | String |
FAnsiString | UnrealAnsiString | String |
FName | UnrealName | String |
FText | UnrealText | String |
TSubclassOf | SubclassOf | Object Wrapper |
TSoftClassPtr | SoftClassPtr | Object Wrapper |
TSoftObjectPtr | SoftObjectPtr | Object Wrapper |
TLazyObjectPtr | LazyObjectPtr | Object Wrapper |
TWeakObjectPtr | WeakObjectPtr | Object Wrapper |
TScriptInterface | ScriptInterface | Object Wrapper |
TArray | UnrealArray | Container |
TSet | UnrealSet | Container |
TMap | UnrealMap | Container |
TOptional | UnrealOptional | Container |
FScriptDelegate | UnrealDelegateBase | Delegate |
FMulticastScriptDelegate | UnrealMulticastInlineDelegateBase | Delegate |
FSparseDelegate | UnrealMulticastSparseDelegateBase | Delegate |
FFieldPath | FieldPath | Misc |