中EN
概念页

错误处理与断言

Error Handling & Assertions

更新于 2026-10-12 约 8 分钟错误处理断言异常RAII编码规范GEA

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++ 异常」的说法缺少官方出处,已删去。
  • 断言在各构建配置中保留与否,见 构建配置与编译优化;更系统的验证手段见 自动化测试。
  1. Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
  2. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 3 章 3.1.1.6、3.1.2.2、3.1.3、3.2 节
  3. Asserts in Unreal Engine(摘录) UE 5.8 文档
  4. Module Properties in Unreal Engine(摘录) UE 5.8 文档

相关页面

游戏开发构建配置与编译优化Build Configurations & Compiler OptimizationC++ 游戏代码从源文件到可执行文件要经过预处理、编译、链接。游戏项目通常不止 Debug/Release 两种构建配置 (build configuration),而是 Debug、Development、Ship,有时再加混合构建;配置越多测试越难。编译器优化分为局部优化和全局优化(链接时优化 LTO、剖析引导优化 PGO),它们让程序更快,但也让调试器“说谎”。所以要学会调试优化过的构建,并用统计式或插桩式性能分析器 (profiler) 找出最耗时的代码。游戏开发运行时引擎分层架构Runtime Engine ArchitectureGregory 把游戏视为“软实时、交互式、基于代理的计算机模拟”,把游戏引擎定义为数据驱动、可复用来做多款游戏的软件。运行时引擎按层组织:从目标硬件、驱动、操作系统、第三方 SDK、平台无关层、核心系统、资源管理器,到渲染、物理、动画、音频、网络等子系统,再到游戏性基础层和游戏专属子系统;上层依赖下层,避免循环依赖。游戏开发 · UE5自动化测试Automation TestingUE 的自动化系统 (Automation System) 构建在功能测试框架之上,支持单元测试、功能测试、内容压力测试和截图对比,并借助 Unreal Message Bus 提高稳定性。测试按用途分为 Unit、Feature、Smoke(承诺 1 秒内完成)、Content Stress 和 Screenshot Comparison。它依赖引擎系统运行,所以不适合纯粹的单元测试;这种情况用 Low-Level Tests。跨进程的整局会话测试用 Gauntlet。游戏开发无锁编程与内存序Lock-Free Programming & Memory Ordering「无锁 (lock-free)」的真正含义不是不用互斥锁,而是从不让线程阻塞(作者说叫 block-free 更贴切)。数据竞争有三个来源:关键操作被打断、编译器与 CPU 的指令重排、缓存一致性协议优化造成的内存序问题。对应的工具是原子指令(TAS、XCHG、CAS、LL/SC)和内存屏障/栅栏(acquire、release、full fence),C++11 用 std::atomic 与 std::memoryorder 把它们封装成可移植接口。在此基础上可以写出自旋锁、可重入锁、读写锁,以及基于「本地准备 + 一次原子提交」事务的无锁数据结构。游戏开发子系统启动与关闭Subsystem Start-Up & Shut-Down游戏引擎由许多相互依赖的子系统组成,启动时必须按依赖顺序初始化(B 依赖 A,则先启动 A),关闭时通常反序。C++ 全局/静态对象的构造与析构顺序不可预测,「首次调用时构造」的函数静态单例也无法控制析构顺序且时机不可控。GEA 推荐最朴素的方案:单例管理器的构造/析构函数什么也不做,提供显式的 startUp() / shutDown(),在 main() 里按顺序手动调用。游戏开发日志与调试绘制Logging & Debug Drawing几乎所有专业团队都会为引擎打造一套调试与开发工具。本页记录其中最基础的两类:日志与追踪(格式化调试输出、详细级别、通道、镜像到文件、崩溃报告)和调试绘制(在游戏世界或屏幕上画线、形状和文字以可视化数学计算)。(卷 I 第 10 章 引言、10.1–10.2 节)游戏开发游戏对象引用与查询Game Object References & Queries游戏对象需要唯一 id 来区分、查找和充当通信目标;找到对象后还要能引用它,引用的寿命可以从一次函数调用到许多分钟。GEA 比较三种实现:裸指针最快最简单,但有孤儿对象、悬垂指针、非法指针三类问题,且不能配合内存重定位;智能指针可以检测对象已删除、做引用计数,但很难写对,GEA 个人建议远离它,非用不可就用 C++11 标准库或 Boost 的成熟实现;句柄是全局句柄表的整数索引,加上唯一 id 防止槽位复用后指向错误对象,简单而安全。对象查询则不追求通用数据库,而是为最常用的查询建专门的数据结构(GEA4 卷 II 17.5)。

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原