错误处理与断言
Error Handling & Assertions
GEA 把错误分为用户错误和程序员错误,两者处理方式相反:用户错误要尽量平稳地告知并继续,程序员错误应立即停机并给出详细的底层信息。“用户”又分玩家和制作游戏的开发者。实现手段有错误返回码、异常和断言。Naughty Dog、EA、Midway、Insomniac 等在运行时代码中通常关闭 C++ 异常,靠断言和静态断言 (static_assert) 捕获 bug,并用 RAII(Naughty Dog 称为 janitor)管理资源。
错误的分类与处理策略(卷 I 3.2.1–3.2.2)
- 用户错误指使用者操作不当,如输入非法、打开不存在的文件;程序员错误就是 bug。两者的界线随语境移动:程序员 B 用非法参数调用 A 写的
f(),对 A 来说是用户错误,对 B 来说是程序员错误。 - 玩家错误应在玩法内部处理,比如没有弹药时换弹,只播放一个音效或动画提示,不把玩家拉出游戏。
- 开发者错误(如动画绑错骨骼、纹理尺寸不对、音频采样率不受支持)有两种对立观点:一种是遇到任何坏资产就让游戏无法运行,逼人立刻修;另一种是引擎对任何坏数据都保持鲁棒。作者取折中:让错误显眼,同时允许团队继续工作,因为多人停工的成本要乘以人数。例子是网格载入失败时,在原位置画一个大红框并悬浮文字“Mesh xxx failed to load”,这比写一条容易被忽略的日志好,也比直接崩溃好。特别严重的问题仍可直接报错退出。
- 程序员错误通常用断言系统来检测并停机,但要判断何时用断言、何时用更温和的处理。
三种实现手段(卷 I 3.2.3)
| 手段 | 优点 | 缺点 |
|---|---|---|
错误返回码(布尔、“不可能值”或枚举,如 enum Error { kSuccess, kAssetNotFound, ... }) | 简单可靠,枚举能说明错误原因 | 检测错误的函数可能离能处理它的函数很远(例如 40 层调用之下),每层都得往上传递 |
| C++ 异常 | 检测与处理彻底分离,栈展开时会自动析构局部对象 | 含 try-catch 的栈帧需要额外信息;只要有一处用了异常,整个程序都必须启用;像 goto 一样不可见,夹在中间的函数也会卷入栈展开,必须对“随时被抽走地毯”保持鲁棒;实践中代码体积会增大,可能损害指令缓存 (I-cache) 并阻止内联 |
| 断言 | 像“地雷”一样检查程序员的假设,在 bug 引入时就暴露出来 | 只在运行到那条路径时才检查;有性能开销 |
用沙盒隔离异常:把使用异常的第三方库的 API 封装在一个开启异常的翻译单元里,每个封装函数 catch 所有异常并转换成错误码,其余代码就可以关闭异常。
RAII 与异常:RAII 常和异常配合(构造失败就抛出),但没有异常也能用,只需在创建资源对象后检查一次状态;某些资源获取失败也可以改用断言报告(卷 I 3.2.3.2)。Naughty Dog 的 AllocJanitor 就是一例:构造时把某个分配器压入全局分配器栈,析构时弹出,作用域结束自动恢复(卷 I 3.1.1.6)。
立场:作者所在的 Naughty Dog,以及他参与的大多数 EA、Midway 项目都关闭了异常;Insomniac 的 Mike Acton 多次公开反对在运行时游戏代码中使用异常;JPL 和 NASA 的关键任务嵌入式软件也禁止异常。但书中也承认,谨慎使用时异常可以让代码更好写,“没有唯一正确的做法”。
断言的实现(卷 I 3.2.3.3)
典型实现是:一个展开成 if/else 的宏,加上失败时调用的报告函数,再加一条中断进调试器的汇编指令。
#if ASSERTIONS_ENABLED
#define debugBreak() asm { int 3 } // 因 CPU 而异
#define ASSERT(expr) \
if (expr) { } \
else { reportAssertionFailure(#expr, __FILE__, __LINE__); debugBreak(); }
#else
#define ASSERT(expr) // 展开为空
#endif
要点:
- 必须写成完整的
if/else。如果只写一个if,嵌在外层没加大括号的 if/else 中时,外层的 else 会错误地绑定到宏里的 if 上。 #expr把表达式字符串化,__FILE__、__LINE__给出出错位置。- 标准库的
assert()只区分 Debug 和 NDEBUG。引擎通常想更细地控制哪些配置保留断言(如 Ship、PGO 构建),所以自己定义宏。作者建议分两级:常规ASSERT()在所有构建中保留;开销大的SLOW_ASSERT()只在 Debug 构建中启用。 - 断言只用来捕获程序 bug,绝不能用来检查用户错误;失败时必须让整个游戏停止,不应允许测试、美术、策划跳过,否则就像“狼来了”,断言失去意义。如果可以继续运行,就改用屏幕提示或刺眼的橙色 3D 图形之类的方式。
编译期断言:例如要保证某个结构体正好 128 字节,运行期断言放不到文件作用域。C++11 起可以用 static_assert(sizeof(T) == 128, "wrong size");。旧标准下可以手写 STATIC_ASSERT:要么用匿名枚举,把值设为 1 / (int)(!!(expr)),表达式为假时就是除以零,产生编译错误;要么只特化 TStaticAssert<true>,对 TStaticAssert<false> 取 sizeof 时报错。后者在 VS2015 中的报错信息更清楚。
语言特性与编码规范(卷 I 3.1.2.2、3.1.3)
Naughty Dog 对新语言特性很保守:运行时代码和离线工具代码各有一份允许使用的 C++ 特性清单,工具代码的清单更宽松。理由有三:
- 编译器未必完全支持新特性(PS5 用的 Clang 完整支持 C++17、大部分 C++20,对 C++23 的支持较少)。
- 切换标准有成本,通常一个项目只在早期切换一次。
- 风险与收益要权衡:
auto只允许用在迭代器、模板等别无选择的场合,或确实能明显提高可读性的地方;复杂的模板元编程在运行时代码中原则上禁止,只按个案批准,理由是任何程序员都应能在不熟悉的代码里迅速上手调试。
作者认为编码规范最重要的几点:接口 (.h) 至上;起好名字;不要污染全局命名空间(宏是纯文本替换,会穿透所有作用域);遵循 Effective C++ 等最佳实践;保持一致(新代码自定规则,改旧代码就遵循已有规则);让错误显眼(引 Joel Spolsky 的观点)。
与 UE 的对应
- Epic 官方文档(Asserts in Unreal Engine(摘录),UE 5.8):UE 提供
check、verify、ensure三族断言宏(定义在Engine/Source/Runtime/Core/Public/Misc/AssertionMacros.h)。check最接近assert,表达式为假即停机,默认只在 Debug 和 Development 构建中生效(Slow变体只在 Debug),USE_CHECKS_IN_SHIPPING=1可在 Test/Shipping 中启用,官方建议发布时保持默认值 0。verify在关闭 check 的构建中仍会求值表达式(只是不停机、不写日志),适合表达式有副作用的场合。ensure是非致命的:表达式为假时通知崩溃报告器并继续运行,默认每个会话只报告一次(ensureAlways每次都报)。这与书中「程序员错误立即停机、断言在发布版剥离、要注意断言表达式的副作用」的思路一致,ensure则对应书中「让错误显眼但允许继续工作」的折中。 - 异常:官方模块属性文档(Module Properties in Unreal Engine(摘录))列出
ModuleRules.bEnableExceptions(「Enable exception handling」),说明 UE 按模块控制是否启用 C++ 异常处理;该文档未写明默认值,本库此前「UE 默认关闭 C++ 异常」的说法缺少官方出处,已删去。 - 断言在各构建配置中保留与否,见 构建配置与编译优化;更系统的验证手段见 自动化测试。
出处
- Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 3 章 3.1.1.6、3.1.2.2、3.1.3、3.2 节
- Asserts in Unreal Engine(摘录) UE 5.8 文档
- Module Properties in Unreal Engine(摘录) UE 5.8 文档