Unreal对C++做了什么 · Part3工具箱 · 第 13 章 · 日志、断言与“不用异常“
第 13 章 · 日志、断言与不用异常标准 C 的错误处理有一个核心机制异常Exception。try/catch/throw——教科书里教的面试时考的现实中用的。Unreal 不用它。整个引擎的编译选项中通常会加上-fno-exceptionsGCC/Clang或/EHs-MSVC直接在编译器层面禁用异常。如果你在 Unreal 代码中写了throw编译器会报错。这不是疏忽是刻意的设计决策。Unreal 用自己的三件套替代了异常UE_LOG日志、check/ensure/verify断言、以及一种贯穿整个引擎的Fail-Fast 哲学。13.1 为什么不用异常性能异常的代价不在throw那一刻——而在于编译器为了支持异常展开Stack Unwinding所做的准备工作。即使你从来不throw只要异常被启用编译器就需要为每个函数生成额外的展开信息表unwind table增加二进制体积并可能影响优化。在游戏引擎中每一帧 16.6ms 的预算里任何不必要的开销都不可接受。控制流可预测性异常让控制流变得不透明。一个函数调用可能正常返回也可能在任何一行抛出异常跳过后续的所有代码直接到达某个 catch 块。在一个多线程、帧驱动的游戏引擎中这种不可预测的控制流跳转会让调试变成噩梦。跨平台某些目标平台历史上的某些主机 SDK对 C 异常的支持不完整或有额外开销。Unreal 需要在所有支持的平台上保持一致的行为。结论Unreal 的立场是错误不应该被处理和恢复——错误应该被发现和修复。如果一个不应该发生的情况发生了正确的反应是在开发期尽早崩溃、报告堆栈让程序员发现并修复 bug。而不是 catch 住异常假装什么都没发生。13.2 UE_LOG日志体系UE_LOG 是 Unreal 中最常用的调试工具。它比std::cout或printf强大得多——有分类、有级别、有多个输出目标。基本用法UE_LOG(LogTemp,Warning,TEXT(Player health: %f),Health);三个参数LogTemp日志分类Log CategoryWarning详细级别VerbosityTEXT(“…”)格式化消息日志分类// 在 .h 中声明DECLARE_LOG_CATEGORY_EXTERN(LogCombat,Log,All);// 在 .cpp 中定义DEFINE_LOG_CATEGORY(LogCombat);// 使用UE_LOG(LogCombat,Warning,TEXT(Damage dealt: %f),Damage);自定义日志分类让你可以按模块过滤日志。在控制台输入Log LogCombat Verbose可以动态调整LogCombat分类的输出级别——不用重新编译。LogTemp是引擎提供的临时分类适合快速调试。正式代码应该使用自定义分类。详细级别从最严重到最详细级别含义使用场景Fatal致命错误打印后崩溃不可恢复的错误Error错误但不崩溃可能影响功能的问题Warning警告可能不正确但不致命的情况Display显示级信息重要的运行时信息Log一般日志常规操作记录Verbose详细日志调试时需要的额外信息VeryVerbose极度详细逐帧/逐操作的追踪默认情况下Verbose和VeryVerbose级别的日志不会输出需要通过控制台命令或配置文件启用。输出目标UE_LOG 的输出会同时发送到Output Log 窗口编辑器中日志文件Saved/Logs/目录下控制台如果打开了IDE 的输出窗口调试时屏幕打印// 直接在游戏画面上打印GEngine-AddOnScreenDebugMessage(-1,5.0f,FColor::Yellow,FString::Printf(TEXT(Health: %f),Health));// UE5 的简化版if(GEngine){GEngine-AddOnScreenDebugMessage(INDEX_NONE,2.0f,FColor::Green,TEXT(Hello));}屏幕打印适合快速的运行时调试不需要切换到日志窗口。13.3 断言的语义阶梯Unreal 提供了一组断言宏每个有不同的严重程度和构建行为check()check(Weapon!nullptr);check(Health0.0f);checkf(IndexArray.Num(),TEXT(Index %d out of bounds (Num%d)),Index,Array.Num());语义这个条件必须为真。如果为假说明程序逻辑有 bug。行为Debug / Development 构建条件为假时打印 callstack 并崩溃Shipping 构建整个表达式被移除——既不检查也不执行check对标 C 的assert()但更强——它会打印完整的调用栈并且在某些配置下会触发断点让你直接在调试器中检查现场。verify()verify(InitializeSystem());语义条件必须为真而且表达式本身有副作用不能被移除。与check的唯一区别在 Shipping 构建中verify的表达式仍然执行——只是不检查返回值。这解决了check的一个陷阱// 危险Shipping 构建中整个 Initialize() 调用都会被移除check(Initialize());// 安全Shipping 构建中Initialize() 仍然执行verify(Initialize());ensure()if(ensure(Weapon!nullptr)){Weapon-Fire();}ensureMsgf(Health0.0f,TEXT(Health should be positive, got %f),Health);语义条件应该为真但如果为假程序可以继续运行。行为条件为假时打印 callstack 到日志带完整的堆栈信息不崩溃——程序继续执行同一个ensure位置只报告一次避免日志洪水ensure是 Unreal 独有的创新。它填补了check太暴力——直接崩溃和什么都不做之间的空白。你知道某个条件不应该为假但即使为假了世界不会毁灭——记录一下继续跑。对比总结宏失败行为Shipping 构建适用场景check(X)崩溃表达式被移除永远不应该发生的 bugcheckf(X, ...)崩溃 消息表达式被移除同上附加诊断信息verify(X)崩溃表达式执行但不检查有副作用的断言ensure(X)报告但不崩溃表达式执行并检查不应该但可以容忍ensureMsgf(X, ...)报告 消息同上同上附加诊断信息13.4 Fail-Fast 哲学Unreal 的错误处理哲学可以用一句话概括宁可在开发期崩溃一千次也不要在玩家手里静默错误一次。这是Fail-Fast快速失败理念的极端实践契约式编程每个函数对输入有预期前置条件对输出有承诺后置条件。如果前置条件不满足不是优雅降级——是check崩溃因为调用方违反了契约。voidUInventory::AddItem(UItem*Item){check(Item!nullptr);// 前置条件不接受空指针check(!Items.Contains(Item));// 前置条件不接受重复添加Items.Add(Item);check(Items.Contains(Item));// 后置条件添加成功}这与防御性编程“收到空指针就返回不崩溃”相反。防御性编程隐藏了 bug——调用方传了空指针函数默默返回bug 在很远的地方才暴露为更难理解的症状。Fail-Fast 让 bug 在产生的瞬间就被发现。开发期 vs 发行版check在 Shipping 构建中被移除——这意味着发行版不会因为断言崩溃。但这不是说发行版没有错误处理。思路是开发期用check密集地检查所有不变量让 bug 无处藏身QA 阶段用ensure捕获那些不应该但可能发生的边缘情况发行版check被移除性能ensure保留监控致命错误由崩溃报告系统收集这对你的代码意味着什么不需要写try/catch前置条件用check不确定但应该成立的假设用ensure返回值有副作用的检查用verify运行时信息用UE_LOG面对错误数据快速失败比静默容忍好13.5 与标准 C 错误处理的对比维度标准 CUnreal主要机制异常 (try/catch/throw)断言 (check/ensure/verify)控制流非线性跳转到 catch线性崩溃或继续性能开销有展开表、可能的堆分配无断言在 Shipping 中可移除可恢复性设计上可恢复设计上不恢复Fail-Fast日志自行搭建UE_LOG 内置分类级别多目标运行时诊断有限ensure报告 callstack 但不崩溃两种哲学没有绝对的对错。异常适合需要优雅降级的系统比如 Web 服务器一个请求失败不应该影响其他请求。Fail-Fast 适合游戏引擎——一个不应该发生的错误如果发生了比起优雅地处理它更好的做法是立刻发现并修复它。实验check / ensure / verify 的行为差异ensure 不崩溃。在 BeginPlay 里写ensure(false);以 Development Editor 模式运行。观察 Output Log会出现红色 callstack 信息但程序继续运行不崩溃。这就是 ensure 的「报告但不终止」语义。check 会崩溃。把上面改成check(false);再次运行。程序直接断下调试器或崩溃。确认 check 是不应该发生发生了就停的硬断言。verify 始终执行表达式。写int32 Count 0; verify(Count 0);分别在 Development 和 Shipping 下编译运行。Development 下有断言检查且 Count 为 1Shipping 下断言被剥离但Count仍然执行Count 仍为 1。对比 check若写check(Count 0)在 Shipping 下Count不会执行check 的表达式整体被移除Count 仍为 0。一句话总结Unreal 禁用了 C 异常用 UE_LOG分级日志 check/ensure/verify语义阶梯断言替代贯彻 Fail-Fast 哲学——开发期让 bug 无处藏身发行版用崩溃报告兜底。第三部分到此完结。从容器到字符串从智能指针到类型扩展从委托到多线程从日志到错误哲学——这七章覆盖了你日常编写 Unreal C 时最常接触的工具。加上前两部分的根基知识UObject、反射、GC、UInterface你现在已经具备了自信编写 Unreal C 代码的全部基础。翻到第四部分我们进入幕后——看看代码到达编译器之前UBT 和 UHT 到底做了什么。