☰
从传统C++到Unreal C++:宏、GC与类型体系的全面改造
2026/9/30 11:37:48 网站建设 项目流程

第一次在Unreal工程里打开一个C++类的源码,我盯着满屏的UCLASS、UPROPERTY、UFUNCTION看了好一会儿,心里冒出来的第一个念头是:这还是我认识的那个C++吗?类文件末尾挂着一个诡异的#include "XXX.generated.h",成员变量清一色的TArray、TMap、FString、TSharedPtr,指针满天飞,却几乎看不到delete,甚至对象的销毁都不是你自己说了算——引擎会在某个合适的时机悄然把它收走。这就是Unreal对C++做的事情:在原生C++之上,覆盖了一套面向游戏引擎和内容生产流程的完整运行时体系。

这篇是这个系列的前言,先不急着抠某个具体宏的细节,而是把整体框架摊开:Unreal到底为什么要改C++、改了哪些关键位置、各自的原理大致是什么。适合刚接触Unreal C++的传统C++开发者,以及想深入理解引擎机制但还没找到门道的读者。把这套"为什么"搞清楚了,后面再读UCLASS实现、GC源码、序列化流程,都会轻松很多。

1. 为什么非要改C++:三个绕不开的引擎刚需

C++本身的能力不差,差的是在"编辑器 + 实时渲染 + 内容流水线"这个三角环境下,原生C++缺了几项关键能力。Unreal的改造不是炫技,而是被需求逼出来的。

1.1 运行时反射:让代码能被编辑器看见

传统C++程序编译完成后,类、变量、函数在二进制里就是地址和偏移量,没有任何"活动目录"存在。但Unreal编辑器要做的事远不止"编译完跑起来":你得在Details面板里看到当前选中Actor的全部属性并即时改值;你要在关卡里把一个资产引用拖进去,序列化成存档文件;关卡蓝图要能直接调用某个C++类里的函数并展示返回值。这些能力统称为反射——程序在运行时能"看见"自己类型的成员结构。

原生C++没有反射,RTTI那点家底也远远不够用。所以Unreal造了一套自己的方案:用UCLASS/UPROPERTY/UFUNCTION宏做标记,再用一个叫UHT(Unreal Header Tool)的工具在编译前扫描头文件,生成一份结构化元数据编译进模块二进制。等于给每个类额外配了一份"成员字典"。这套宏体系是整个Unreal C++的基础设施,后面的GC、序列化、蓝图、网络复制,全都踩着这份字典干活。

1.2 内存管理:游戏循环里的高频生死

游戏和普通业务软件最大的区别之一,是对象创建销毁的频率极高:子弹、特效、怪物、掉落物,一秒钟可能出生几百个对象,过两秒又全死掉。如果全部用裸指针加手动delete,尤其遇到异常分支或异步回调,很容易漏删。漏删一个就是隐形内存泄漏,在长跑服务器上会逐渐变成灾难。

Unreal的选择不是上std::shared_ptr这种引用计数方案,而是做了一套GC,配合UObject对象体系,把绝大多数游戏内对象纳入了"引擎管生管死"的范畴。你不用总惦记着哪一步该delete,只要对象在引用图上不可达,GC会自动把它回收。这个选择背后的逻辑,后面第三章会详细展开,先记住结论:Unreal的UObject世界不是引用计数世界,是GC世界。

1.3 蓝图与C++的"胶水层"

再一个重要原因就是蓝图。蓝图是Unreal给策划、美术用的可视化脚本系统,跟C++完全是两套语言。可蓝图节点要能调用C++函数、读写C++属性、实例化C++类,那就必须有一份两边都能理解的"类型合同"。

这份合同依然是反射元数据:C++类通过UFUNCTION暴露函数,UPROPERTY暴露属性,UHT生成的数据让蓝图系统在运行时能找到对应函数和属性的地址、参数类型、返回值类型,从而生成可视化节点。没有这层胶水,C++写出来的东西就只能自己玩,内容策划根本碰不到。这也是Unreal C++会看起来"不太像C++"的最直接原因:你写的不是传统业务系统,而是一套和编辑器深度绑定的引擎逻辑。

2. 宏和代码生成:UCLASS、UPROPERTY到底做了什么

说到Unreal C++,绕不开的就是那三组宏。我见过不少新人把它们当成装饰品:反正在类上写个UCLASS就能编译,UPROPERTY加上了也不知道它到底起了什么作用。实际上这三组宏是整套系统真正的钢筋。

2.1 三组宏的定位

先明确各自职责:UCLASS标记类,告诉UHT"这个类要进入Unreal的反射体系";UPROPERTY标记成员变量,告诉UHT"这个属性需要参与序列化、GC、编辑器显示";UFUNCTION标记成员函数,告诉UHT"这个函数要暴露给蓝图、网络或序列化系统"。

它们本身是空宏,编译期展开后几乎不产生什么代码,真正的用途是给UHT当输入。UHT读取这些标记之后,会生成对应的反射数据,合成到编译产物里,让运行时的引擎能枚举类的属性、找到属性偏移、知道类型名。一个简单的例子:

UCLASS() class MYGAME_API AMyActor : public AActor { GENERATED_BODY() public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Score") int32 Score; UFUNCTION(BlueprintCallable, Category = "Score") void SetScore(int32 NewScore); };

UCLASS括号里可以放说明符,比如Blueprintable、NotBlueprintable;UPROPERTY括号里EditAnywhere表示编辑器里能改,BlueprintReadWrite表示蓝图能读写;UFUNCTION括号里BlueprintCallable表示蓝图能调用。这些说明符最终都会变成元数据里的标志位,被编辑器、序列化、网络系统分别读取。

2.2 UHT的工作流程:编译之前还有一道工序

具体流程是这样:你写完头文件,UHT在你真正编译C++代码之前先扫描所有带宏标记的头文件,生成一堆名为"YouClass.generated.h"和"YouClass.gen.cpp"的附加文件。这些文件里包含类的静态反射表、属性元数据、函数桥接代码。随后正常的C++编译器再把你写的代码和这些生成代码一起编进去。

所以UCLASS、UPROPERTY本质上不是C++语言特性,而是"宏 + 外部代码生成器协议"。这也就解释了为什么引擎强制要求#include "XXX.generated.h"必须放在所有include的最末尾——UHT生成的代码里引用了类定义的全部上下文,放前面直接编译不过。

这里有一个很自然的追问:为什么Unreal不用现成的第三方反射库?核心原因是它需要的反射信息面太广:需要蓝图节点生成、需要编辑器属性面板、需要序列化/网络复制、需要动态类型创建,还要在庞大的既有引擎代码库上保持一致性。自己控住UHT这条生成链路,等于把整条反射管线都捏在自己手里,改动起来不受外部库限制。这是典型的工程取舍:牺牲一部分C++直觉,换取更深度的工具链集成。

2.3 反射数据带来的实际能力

有了反射表之后,能做到的事情就多了。编辑器属性面板能自动列出所有UPROPERTY并实时改值;存档系统能在不知道具体类型的情况下按名字遍历每个属性、序列化成二进制;蓝图直接生成对应节点;命令行工具可以做动态类型枚举。最典型的现象是:你在C++里加一个UPROPERTY(EditAnywhere) int32 Score;,什么都不用额外写,编辑器的Details面板里立刻多出一个Score输入框,保存关卡时Score会被自动写进存档文件。

打个比方:反射表是一面照妖镜。普通C++对象在编译后是一团内存,而反射表知道这块内存的每个槽位叫什么、是什么类型、怎么读写。编辑器、序列化、蓝图、GC,等于人手一面这样的照妖镜,所以Unreal操作对象时才显得特别"智能"。

3. 指针和内存管理:为什么放着std::shared_ptr不用

很多从传统C++转过来的朋友第一个反应是:既然要解决内存问题,为什么不用智能指针?std::shared_ptr不香吗?香,但在Unreal生态里它有一个根本矛盾:Unreal需要的是"引擎级生命周期管理",而不是"每个对象各自记账"。

3.1 引用计数在游戏引擎里的局限

std::shared_ptr的引用计数解决的核心问题是"谁最后不用谁释放",它天然适合所有权分散、生命周期边界模糊的场景。但游戏引擎里,大量对象的所有权其实是集中且确定的:关卡卸载时场景里所有Actor都该消失;玩家离开时所有掉落物都该清理。如果每个对象都靠引用计数维持生命,谁持有引用、谁释放引用就会变成一场全局官司——尤其跨模块、跨线程、跨DLC加载卸载时,所有权链会错综复杂,一个循环引用就可能让资源永久泄漏。

GC换了个思路:由引擎维护一个"根集合",从根出发能到达的对象就是活的,到不了的就回收。所有对象共享同一套生死规则,不需要每个对象自发记账。这就是为什么UObject体系里你看不到shared_ptr:它是GC类型,不是引用计数类型。

3.2 Unreal自己的智能指针:TSharedPtr、TWeakPtr、TUniquePtr怎么选

但Unreal也不是彻底不用智能指针:对于非UObject对象——比如编辑器里的数据结构、网络缓冲区、普通工具类——引擎照样提供了一套自研智能指针:TSharedPtr、TWeakPtr、TUniquePtr。语义跟std版本基本一致,但内部实现完全自控。TSharedPtr默认线程安全版本带引用计数原子锁,共享所有权;TWeakPtr不持有引用,只是观察者;TUniquePtr表示独占所有权。

使用原则可以浓缩成一张速查表:

场景推荐类型理由
UObject/Actor被属性持有UPROPERTY裸指针可被GC追踪,自动序列化
UObject被临时引用TWeakObjectPtr或原生指针不扩大引用面,注意线程约束
UObject需要异步加载才可用TSoftObjectPtr延迟加载,CDO序列化友好
非UObject共享数据TSharedPtr引用计数,线程安全可选
非UObject独占资源TUniquePtr所有权清晰,开销最小
观察非UObject但不持有TWeakPtr避免延长生命周期

这里有个微妙点:UObject体系内的"UPROPERTY裸指针"是"被引擎GC追踪的强引用",TWeakObjectPtr则是"弱引用";TStrongObjectPtr虽然存在,本质是用引用计数强行给UObject保活,属于少数场景下的特例,日常不建议滥用。

3.3 为什么UObject不能随便new和delete

这是我见过新手最容易翻车的地方。UObject体系里对象真正的创建路径只有几条:普通UObject用NewObject<T>(),Actor用SpawnActor<T>(),构造函数内创建子对象用CreateDefaultSubobject<T>()。直接new一个UObject,虽然一时能跑,但引擎根本不认识它:它没进对象注册表、没进GC根集、反射系统对它一无所知;直接delete一个UObject更是灾难,可能让引擎里某个还持有引用的系统踩到悬垂指针。

正确的销毁方式是走引擎的生命周期接口(Actor调用Destroy,UObject移除引用等GC回收)。一句总结:在Unreal里,造对象和毁对象都别想绕过引擎自己来,这套规矩是为了让编辑器、运行时、存档、网络四套系统始终保持认知一致。

4. 容器和字符串都被定制了:TArray、TMap、FString、FName、FText

除了对象模型,Unreal连基础数据结构都按自己的需求重做了一遍。我第一次把std::vector改成TArray的时候很不习惯,但用久了才明白这些"伪C++容器"背后都是实打实的工程决策。

4.1 为什么在Unreal里我劝你少用std::vector

先说TArray。它跟std::vector一样是连续内存的动态数组,按索引访问O(1),尾部插入摊还O(1)。但TArray不只是容器,它跟Unreal的类型系统深度耦合:TArray里的元素如果是反射支持的类型,数组本身可以直接被序列化进存档;编辑器里能看到数组内容并动态增删;网络复制系统能按元素精确同步。std::vector做不到这些,因为引擎根本不知道std::vector的存在,它没有反射元数据。

TArray的API设计也更贴合游戏代码习惯。拿几个高频操作对比:

需求std::vectorTArray
尾部添加push_backAdd
构造添加emplace_backEmplace
按索引删除erase(iter)RemoveAt(Index)
按值删除erase + remove_ifRemove
不搬移删除自己写RemoveAtSwap
查找元素find + 判断Find / Contains

其中RemoveAtSwap是我最喜欢的一个API:删除指定索引时用最后一个元素填充空位,避免大面积搬移。这在需要维护频繁增删的运行时列表时非常有用,代价是元素顺序会变,适合不关心顺序的集合场景。实践中我很少在Unreal模块里写std::vector,除非是纯C++工具层、完全不碰UObject反射的地方。

4.2 TMap、TSet:哈希表的"Unreal姿势"

TMap和TSet本质是哈希容器,跟std::unordered_map、std::unordered_set一个路数。区别在于:Unreal的哈希容器默认是桶式加链表的结构,删除节点时不会把位置让给别人,所以遍历时删除是安全的;键和值都要求值语义,配合UPROPERTY也能被反射系统感知并序列化。

API风格更直白:Find返回指针而FindChecked直接断言存在,Contains比find(...) != end()读起来舒服太多。实际体验中,TMap在游戏循环里做字典查询的性能和std::unordered_map基本相当,关键是它的删除、遍历、调试器可视化、序列化集成度好太多。唯一要注意的是:不要在蓝图或UPROPERTY标记的属性里存太复杂的TMap嵌套,嵌套太深序列化性能会很差,GC遍历也受影响。

4.3 三种String,别搞混

FString、FName、FText是Unreal里"三个都是字符串却各管一摊"的类型,新手区分它们能少踩一半编译报错。

FString就是传统动态字符串,底层是TCHAR数组,适合拼接、修改、存路径和临时文本。FName则是不可变且大小写不敏感的哈希化字符串,Unreal内部大量用它做资产名、骨骼名、Tag名,原因是FName比较是O(1)的哈希比较,而FString比较要逐字符扫。FText是为本地化而生的文本类型,它带着文化上下文(语言、翻译源标记),适合所有要显示在UI里的文字。

选择标准很粗暴:

  • 要拼、要改、要做IO路径:用FString
  • 要做键、要快速比较、要作为标识:用FName
  • 要显示给玩家看、需要本地化:用FText

注意一个坑:FName内部有一张全局字符串表,如果运行时疯狂动态创建几千上万个不重复的FName,表会膨胀甚至触发引擎警告。所以FName适合标识类用途,不适合随意当拼接串来用。三者之间经常互转:FName(*FStringParam)、FText::FromString(FString)、FName::ToString(),这些API都是高频操作,用熟之后自然就顺了。

5. GC怎么知道谁还活着:一套基于反射的标记清扫

前面提到Unreal用GC代替了手动delete,但这里有个所有新手都会追问的问题:垃圾回收器凭什么知道内存里的哪些对象还活着?答案就六个字——看UPROPERTY标签。

5.1 根集与引用遍历:GC的"从哪儿开始"和"往哪走"

Unreal GC用的是一种标记-清扫模型。它先有一个根集合,里面包含引擎启动时注册的核心对象、当前加载的UObject容器、各模块的静态对象、以及关卡和运行时中显式标记为根的引用。然后从根集出发,沿着每个对象上所有被UPROPERTY标记的成员变量所指向的其他UObject进行遍历,一路上给活对象打标记。遍历结束,没被打标记的对象就是不可达的,会被放入回收队列。

这个机制有一个非常反直觉的结论:GC根本不管你是否还有某个C++智能指针指向它,它只认UPROPERTY链路。你如果在一个成员变量上写UObject* Other;但忘了加UPROPERTY,那GC对这条引用就是盲区;万一Other对象再也无法从根集通过其他路径到达,它就会在你眼皮底下被回收,留下一个悬垂指针。这是新手最容易踩的隐形炸弹。

5.2 它跟Java/C#的GC不太一样

很多人拿Java的GC来理解Unreal,这里有个关键区别:Java/C#的GC通常会搬移对象(压缩内存碎片),你拿到的引用是被虚拟层包装过的"句柄"。Unreal的GC不会移动对象地址——毕竟外面全是原生C++指针,挪了位置就崩了。所以Unreal的GC是非压缩式的,对象地址只要对象本身没被回收就一直有效。代价是内存碎片需要靠系统级分配器缓解,以及GC回收时机不一定马上发生。

另外Unreal GC不是分代的,它更近似于增量式的标记清扫:引擎会在合适的帧点分批做标记和清理,尽量摊薄开销。大场景里一堆Actor瞬间失效时,你还能看到明显的"GC风暴"。优化手法通常是避免在短时间内频繁创建海量短期UObject,能用普通C++容器解决的临时数据就别硬塞UObject对象。

5.3 在GC模型下写代码的正确姿势

放到实践中,写Unreal C++要养成几个习惯:

  1. 成员变量凡是引用UObject/Actor的,一律加UPROPERTY,除非你清楚知道这条引用没有GC价值(比如生命周期被其他系统锁定的临时指针)。
  2. 异步任务里若要持有对象,用TWeakObjectPtr,任务执行前检查IsValid。
  3. 不要手动调ForceGarbageCollection,除非做专门的性能测试;引擎的默认节奏已经考虑过整体帧负载。
  4. 对Actor,它的生命周期包含显式的BeginPlay、EndPlay、Destroy流程,GC在多数情况下不会"悄无声息"销毁一个还挂在关卡树里的Actor——关卡卸载另说。
  5. 大对象(Mesh、贴图、音频等)有更复杂的加载和卸载策略,GC只管UObject本体,资源本体另有引用计数系统管理,两套体系分工不同。

理解GC的触发机制之后,很多"为什么这个对象忽然没了"的诡异问题,其实都是反射链路断了造成的。

6. 传统C++转Unreal最容易踩的五个坑

最后写一些具体的踩坑记录,都是自己或身边同事真实翻过车的例子,给准备写Unreal C++的朋友做参考。

6.1 构造函数里乱用对象创建接口

构造函数里创建子对象要分清两条路。在Actor的构造函数里,组件和默认子对象的创建必须用CreateDefaultSubobject<T>(),这是引擎在CDO(Class Default Object)构造阶段专门设计的路径,它会正确挂接到组件系统、编辑器预览、序列化默认值;要是在构造函数里随手NewObject<T>(),你会发现这个对象既不在组件面板里,存档还原时也找不到它。反过来,运行时动态创建临时UObject用NewObject<T>()才合适。我见过有人在构造函数里写NewObject<USceneComponent>(),然后Actor跑起来完全没有对应组件,排查半天才发现是创建路径错了。

6.2 忘了UPROPERTY,悬垂指针在你脚下爆炸

这个坑前面反复提过,但值得单独拎出来。假设你在某个Actor里存了一个指向另一个Actor的指针AActor* Target;用于追击逻辑,没写UPROPERTY。如果Target恰好是一个临时生成又被场景逻辑移除的Actor,等它被GC回收后,你这个Target就成了悬垂指针,调用任何方法都是未定义行为,运气好直接崩,运气差跑一阵子才悄悄出错。

解决办法就是加UPROPERTY声明,或者改用TWeakObjectPtr<AActor>。我在项目里给团队定的规矩是:头文件里看到裸UObject指针而没有UPROPERTY,代码评审直接打回。

6.3 跨线程访问UObject

UObject体系在设计上默认大部分生命周期属于GameThread(游戏线程)。你如果在一个异步任务里直接访问某个UObject的成员,可能在对象被销毁的瞬间撞上竞态。稳妥做法是用TWeakObjectPtr捕获,进Lambda后先IsValid()检查,再通过AsyncTask或主线程调度回到GameThread执行实际操作。渲染线程上的资源访问另有一套规则(多依赖FRenderCommandFence等机制),新手阶段最安全的习惯就是:别在异步线程里直接碰UObject。

6.4 用标准库容器存UObject指针

这个坑覆盖面很大:你把一堆UObject*塞进std::vector或std::map,然后指望GC去管理生命周期。GC看都不看一眼std::vector,它只扫描反射系统注册的属性。于是你会遇到两种情况:一是这些对象因为根集不可达被错误回收,拿着std容器里的指针去用直接崩;二是对象已经存活但你的std容器没把它报告给GC,等对象在别的路径被销毁时,残留指针变成悬垂。正确做法是把含有UObject指针的成员换成TArray<TObjectPtr<...>>、TArray<TWeakObjectPtr<...>>或UPROPERTY包装的容器,确保引擎能看到每一条引用。

6.5 对UObject对象用了std::make_shared

别笑,这个真的有人干过。有些朋友觉得shared_ptr总不会错,结果对UObject用了std::make_shared,直接绕过了引擎的对象注册流程。后面GC不知道有这号对象,编辑器里看不到它,存档里没有它,而shared_ptr自己计数归零时走的又不是引擎期望的销毁流程,双重混乱。UObject的生死权从来不在引用计数手里,它属于GC。非UObject数据对象你爱用make_shared就用,但UObject请老实走NewObject、SpawnActor和GC。

写到这里,"Unreal对C++做了什么"的全景图基本立住了。我个人学习这套体系最大的体会是:别急着用标准C++的直觉去批判它。它确实不是教科书C++,但它是一套极其务实的工程方案——用宏加代码生成换反射,用GC换手动内存管理,用和编辑器深度绑定的容器和字符串体系换取一整套内容生产流水线。理解每个设计背后的动机,会比死记硬背API更快进入状态。这个系列后续我会逐个拆解UCLASS的生成细节、UPROPERTY的序列化机制、以及GC内部实现和实际项目中的性能调优案例。老规矩,有写得不对或不全的地方,欢迎评论区拍砖,下一篇再见。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询