跳到主要内容

最佳实践

更新日期:2025-04-11

共轭对象的生命周期

缓存共轭对象的引用

当你想缓存一个共轭对象的引用时,你应该调用 Preserve()

Vector vec = ...;
Vector cache = vec.Preserve();

Preserve() 会确保返回的对象是可缓存的,对于非 UObject,它等价于:

Vector cache = vec.IsBlack ? vec : vec.Clone();

对于 UObject,它等价于:

UnrealObject cache = obj;

使用 UObject 共轭

逻辑上,UObject 共轭的行为和 WeakObjectPtr 完全相同:它可以查询对象的有效性,但不会影响垃圾回收。 和 WeakObjectPtr 类似,在使用之前,你需要确保 IExplicitLifecycle.IsExpired 属性为 false。 需要注意的是,每当你从 await 语句中返回,你都应该重新判断,因为 await 语句可能是异步的。 当然,你也需要留意其他异步情况,如闭包中的 upvalue

UObject 共轭对象的存在不会影响原对象的回收,如果你想在托管侧持有一个 UObject 的强引用,必须使用 StrongObjectPtr:

UnrealObject obj = ...;
StrongObjectPtr<UnrealObject> strongObj = new(obj);

上述代码构造了一个 StrongObjectPtr 对象,并指向 obj。 它的效果和你在 C++ 中使用 TStrongObjectPtr 是相同的。 因此,在 strongObj 被销毁或指向其他对象之前,obj 都不会被回收。

你当然也可以声明一个 UProperty 来持有 UreanlObject 的强引用,但这本质上是非托管侧持有而不是托管侧持有,因为 UProperty 的状态储存在非托管堆上。

上面提到,在托管侧,普通的 UnrealObject 引用和 WeakObjectPtr 拥有等价的功能。 并且,在查询有效性时,普通的 UnrealObject 引用比 WeakObjectPtr 快得多,因为前者只需要检查一个托管侧的标记。 虽然 Z# 导出了 TWeakObjectPtr,但除了以下两种情况,你一般不会需要它:

  1. 与引擎原生 API 交互。
  2. 声明一个 WeakObjectPtr 类型的 UProperty,目的是在不影响对象回收的情况下支持 UE 反射。

显式释放黑色共轭

共轭类型都实现了 IDisposable 接口。 如果你在作用域中创建了一个黑色共轭对象,并且能确定它不会逃逸出该作用域,你可以使用以下写法来将它的有效期与作用域绑定,这样做可以略微减轻 GC 压力:

{
using Vector vec = new(1, 2, 3);
...
// vec will be disposed just before this scope exits.
}
注意

你不能显式释放一个红色共轭对象,因为你没有它的所有权。如果你这样做,将会抛出 InvalidOperationException

对虚幻字符串和容器使用 LINQ

虚幻字符串的共轭类型实现了 IEnumerable<char> 接口,虚幻容器的共轭类型实现了 IEnumerable<T> 接口。 这意味着你可以对它们使用 LINQ。 但是,你在对这些类型使用 LINQ 时必须格外小心,因为 LINQ 中的很多操作都涉及 惰性求值。 惰性求值的前提是对象不可变,但虚幻字符串和容器显然比常规容器更容易破坏这个前提。 甚至,虚幻字符串和容器的实际生命周期是短于共轭对象的,因此如果你在使用 LINQ 时没有正确处理对象生命周期,很可能产生未定义行为。

粗略地讲,对于所有返回抽象结果 IEnumerable<T> 的操作,建议立即消费,不要缓存返回值,因为这类操作大多数都使用了惰性求值,等到真正消费的时候才会生成实际结果; 而对于返回具体结果的操作,如 int32 Count()bool Any()T[] ToArray() 等,则没有生命周期的问题,因为这类操作是原地求值,即在返回之前就必须遍历整个输入源。

Emit

给动态类使用带词缀的命名

众所周知,UE 的反射是没有命名空间的,为了防止命名冲突,原生开发者都有使用带词缀(如项目代号)的类名的习惯。 虽然托管侧支持命名空间,但由它生成的动态类仍然是没有命名空间的。 因此,建议在使用动态类时仍然保留类名词缀。

仅在可能与引擎交互时使用 Emit

建议仅用 Emit 提供脚本与引擎的接口,因为 U++ 太弱,会限制脚本语言的发挥。 引擎本身也大量使用了这种范式:只暴露一个 U++ 接口(胶水)层,系统底层不需要对外暴露的部分使用纯 C++ 实现。

在开发过程中,需要与引擎交互的情况基本都是需要使用引擎的序列化能力:

  • 需要与编辑器集成。
  • 需要序列化成 uasset。
  • 需要使用引擎原生的网络同步。
  • 需要暴露接口给其他脚本语言,如蓝图和 Python。

当然,也可以使用接口和实现完全分离的范式。 这时底层可以完全使用脚本开发,但需要编写单独的接口层。

使用 nameof 操作符引用成员名而不是字符串字面量

使用 Emit 时经常需要引用成员的名字,这时你应该使用 nameof 操作符而不是字符串字面量,因为 nameof 操作符可以被 IDE 跟踪到,而字符串字面量会在你重构时带来很多麻烦。

异步编程

优先使用 ZeroTask 而不是直接使用 EventLoop 和 Timer

由于 C++ 对异步支持的常年缺失,原生开发者更习惯使用基于回调的异步模型,而不是基于协程的异步模型。 但在 Z# 中,ZeroTask 提供了对异步模型的统一抽象,而 EventLoop 和 Timer 都属于底层组件。

建议优先使用 ZeroTask API 而不是底层 API,协程在游戏这种强异步场景下带来的开发效率提升是巨大的。

提供 sync 参数

游戏开发中的很多场景是强时效性的,副作用必须原地发生,不能异步等待。 这时,错误地使用 await 可能造成逻辑错误。 因此,如果可能的话,当你开发一个异步 API 时,应该提供一个 sync 参数,让调用者可以显式指定同步执行。 这种模式既保证了 API 在功能上的完备性,又保证了简洁性——你不需要给出同步和异步两个 API,用户也不需要根据同步和异步选择不同的写法。

慎用原生 Task

原生 Task 仍然使用线程池,这意味着它可能让你的代码运行在预期之外的线程上。 绝大多数引擎 API 要求在 GameThread 上执行,因此,你只能用多线程来做一些与引擎本身无关的计算。 同时,由于 Z# SynchronizationContext 的存在,绝大多数 await 语句需要回到 GameThread 执行。 ZeroTask 不提供同步等待接口,因为异步模型是单线程的;但原生 Task 的同步等待接口无法消除,如果你错误地使用它,尤其是与 ZeroTask 混用时,极有可能导致死锁。

错误处理

仅将断言用于检查内部错误

你应该只用断言检查程序的内部错误。而对于外部错误,如用户使用错误、不可控的错误,你应该抛异常。

断言和异常的区别在于是否可以被外部处理以及应不应该发生。 内部错误是你自己写的 bug,这意味着它们根本不应该发生。 这也是为什么发布版本中的断言会被忽略。 你应该从代码层面修复内部错误,而不是在运行时尝试恢复这类错误; 你的用户更不应该处理你的内部错误,他甚至不需要知道你发生了内部错误。 因此,你应该用断言来尽早暴露内部错误,而不是用异常把它们抛给用户,这没有意义,用户不知道怎么解决你的内部问题。 这也是为什么所有断言都伴随一条日志——断言是给人看的,而不是给计算机看的。

有时,为了保证开发体验,你可能需要在开发版本的运行时修复内部错误。 对于可以恢复的内部错误,推荐使用 ensure 系列的断言,这可以提高开发工具本身的健壮性; 对于无法恢复的内部错误,推荐使用 check 或 verify 系列的断言(取决于断言的内容有无副作用),这可以帮助你尽早发现严重问题。

不要捕获 FatalException

当你的代码发生了不可恢复的内部错误时,你应该抛出 FatalException,使用断言会自动帮你做这件事。 由于这个错误是不可恢复的,你不应该去捕获它,而是应该让它流转到最外层并终止程序的运行。 FatalException 利用异常传播的特性,可以在中间层不知情的前提下将一些具体的错误信息带到最外层,让程序在崩溃时可以留下一些有用的信息帮助你排查问题。

不要在跨边界的代码块中抛异常

在跨边界的代码块(这里特指从非托管侧跨到托管侧)中抛异常会导致异常最终流转到 CLR 内部导致进程终止,以这种方式终止进程时不会留下任何信息。 因此,如果你的跨边界代码块可能抛出异常,你应该使用以下写法:

try
{
...
}
catch (Exception ex)
{
UnhandledExceptionHelper.Guard(ex);
}

这种写法会将普通异常转换成 Error 日志,并让 FatalException 以合理的方式终止进程。