跳到主要内容
Unreal 引擎对 C++ 的改造:初识非标准 C++ 特性 | 极客日志
C++
Unreal 引擎对 C++ 的改造:初识非标准 C++ 特性 通过逐行分析 Unreal Engine 的 C++ 头文件,揭示了其与标准 C++ 的差异。内容包括宏系统(UCLASS, UPROPERTY)、代码生成工具(UHT)、构建系统(UBT)、命名规范及内存管理等核心机制。文章列出了 14 个关键问题,旨在帮助熟悉标准 C++ 的开发者理解 Unreal 引擎的底层设计哲学与工程实践。
灵魂摆渡 发布于 2026/3/22 更新于 2026/7/21 24K 浏览第 1 章 · 这不是你认识的 C++
假设你是一个写了几年 C++ 的程序员。你用过 STL,写过智能指针,被模板元编程折磨过,也许还读过 Scott Meyers 或者 Herb Sutter 的书。有一天你决定学 Unreal Engine,打开编辑器,创建了一个 C++ 类。
然后你看到了这样一份头文件:
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "MyCharacter.generated.h"
UCLASS (Blueprintable)
class MYGAME_API AMyCharacter : public ACharacter {
GENERATED_BODY ()
public :
AMyCharacter ();
UPROPERTY (EditAnywhere, BlueprintReadWrite, Category = "Combat" )
float MaxHealth = 100.0f ;
UPROPERTY (VisibleAnywhere, BlueprintReadOnly, Category = "Combat" )
float CurrentHealth;
UPROPERTY (Replicated) int32 Score;
UFUNCTION (BlueprintCallable, Category = "Combat" )
void TakeDamage (float DamageAmount) ;
UFUNCTION (Server, Reliable)
void ServerAddScore (int32 Amount) ;
protected :
virtual void BeginPlay () override ;
;
};
virtual void GetLifetimeReplicatedProps (TArray<FLifetimeProperty>& OutLifetimeProps) const override
UCLASS、UPROPERTY、UFUNCTION——这些全大写的东西显然是宏,可它们到底展开成了什么?GENERATED_BODY() 出现在类体的最前面,看起来什么都没做,又似乎什么都做了。文件末尾 #include 了一个 MyCharacter.generated.h,可你在整个项目目录里找不到这个文件——它是从哪里冒出来的?
类名前面有一个 A,而变量的类型是 int32 而不是 int。MYGAME_API 是什么?Replicated 又是什么意思——难道加一个标签就能实现网络同步?
如果你找到 main() 函数想看看程序从哪里开始执行,你会失望——整个项目里没有 main()。如果你找 CMakeLists.txt 想看看它怎么构建,你也会失望——没有 CMake,取而代之的是一个 .Build.cs 文件,里面竟然是 C# 代码。
这些困惑不是因为你 C++ 学得不好。恰恰相反,正是因为你对标准 C++ 足够熟悉,才会敏锐地察觉到:这里有太多东西不属于 C++ 语言本身。 你看到的是一门在 C++ 之上构建的"方言"——Unreal 用宏、代码生成工具和一整套工程约定,对 C++ 进行了深度改造。
本章的任务是:把这份困惑转化为一张清晰的问题清单。我们逐行审视这份头文件,标记出每一处"不属于标准 C++ 的东西",然后在本章结尾把它们汇总成一张表——全书剩下的 22 章,就是在逐一回答这张表上的问题。
1.1 一份 Unreal 头文件的逐行审视
#include "CoreMinimal.h" 标准 C++ 项目中,你会 #include <vector>、#include <string>,按需包含标准库头文件。在 Unreal 中,几乎每个文件的第一行都是 #include "CoreMinimal.h"。
这不是一个普通的头文件。它是 Unreal 的"最小公共头"——把你最常用的引擎基础类型(FString、FName、TArray、FVector……)一次性拉进来。它的存在与 Unreal 的编译加速策略(预编译头 PCH 和 Include What You Use 策略)密切相关。
悬念标记 :为什么 Unreal 不像标准 C++ 那样让你按需包含?→ 第 16 章
#include "MyCharacter.generated.h" 这是整个文件中最诡异的一行。你在项目源文件目录里找不到这个文件——因为它不是你写的,而是由一个叫 UHT(Unreal Header Tool) 的工具在编译前自动生成的。
UHT 会扫描你的头文件,识别 UCLASS、UPROPERTY、UFUNCTION 等宏标记,然后生成一份 .generated.h 和一份 .gen.cpp,里面包含了让反射、GC、序列化、蓝图桥接等功能运转所需的全部注册代码。
这行 #include 必须是文件中的 最后一个 #include 。顺序不对,UHT 会报错。
悬念标记 :UHT 生成了什么?为什么需要它?→ 第 4 章(速写)、第 15 章(完整拆解)
UCLASS(Blueprintable) 在标准 C++ 中,声明一个类只需要 class MyClass { ... };。Unreal 在 class 前面加了一个 UCLASS() 宏,还带着参数 Blueprintable。
UCLASS() 本身在预处理器阶段几乎展开为空——它真正的作用是 给 UHT 留下标记 。UHT 并不是 C++ 预处理器,它是一个独立的源码扫描工具。它扫描你的 .h 文件,看到 UCLASS(...) 就知道"这个类需要生成反射数据"。
括号里的 Blueprintable 是一个 说明符(Specifier) 。它告诉引擎:这个类允许在蓝图编辑器中被继承。类似的说明符还有几十个,每一个都控制着这个类在反射系统、蓝图系统、编辑器中的行为。
MYGAME_API class MYGAME_API AMyCharacter 中的 MYGAME_API 是一个模块导出宏,等价于 __declspec(dllexport) 或 __declspec(dllimport)。每个 Unreal 模块都会自动生成一个这样的宏。
在标准 C++ 中,你可能手写 DLL 导出宏或者干脆不写(静态链接)。在 Unreal 中,模块化是强制的——每个模块是一个独立的动态链接库,跨模块访问类必须通过这个宏导出符号。
悬念标记 :Unreal 的模块系统是什么?→ 第 14 章
AMyCharacter —— 为什么有一个 A 前缀? 这不是匈牙利命名法的复活。在 Unreal 中,类名的首字母前缀是一个 语义契约 :
前缀 含义 例子 F值类型 / 普通结构体,不受 GC 管理 FVector、FString、FNameU继承自 UObject 的类 UActorComponent、UTextureA继承自 AActor 的类(AActor 本身继承自 UObject) ACharacter、APlayerControllerT模板类 TArray、TMap、TSharedPtrE枚举类型 ECollisionChannel、ENetRoleI接口类 IAbilitySystemInterfaceb布尔变量(小写 b,用于成员变量) bIsAlive、bCanJump
你可能会想:这只是代码风格约定吧?不,它比代码风格严格得多。UHT 会检查你的前缀。 如果你声明了一个继承自 AActor 的类却没有用 A 前缀,UHT 会拒绝生成代码。如果你把一个继承自 UObject 的类命名为 FMyThing,GC 系统不会正确追踪它。
前缀不是建议,是契约。它告诉引擎(和其他程序员)这个类型在内存管理、序列化、反射等维度上属于哪个阵营。
悬念标记 :为什么前缀与 GC、反射深度耦合?→ 第 3 章(UObject)、第 5 章(GC)
GENERATED_BODY() 这个宏出现在类声明的开头。在标准 C++ 中,这个位置通常什么都没有,或者放 public: / private:。
GENERATED_BODY() 展开后会插入一大段声明——包括拷贝构造函数删除、类型元数据访问函数、序列化辅助函数等。它是 UHT 生成的代码与你手写的代码之间的 衔接点 :.generated.h 中定义了一系列宏,而 GENERATED_BODY() 把它们拉进你的类声明。
如果你删掉 GENERATED_BODY(),不仅 UHT 会报错,编译器也会报一堆缺少声明的错误——因为引擎代码期望每个 UObject 子类都有那些被注入的成员。
悬念标记 :GENERATED_BODY() 到底注入了什么?→ 第 15 章
UPROPERTY(…) UPROPERTY (EditAnywhere, BlueprintReadWrite, Category = "Combat" )
float MaxHealth = 100.0f ;
在标准 C++ 中,成员变量就是成员变量,没有附加的元数据。Unreal 用 UPROPERTY() 宏在变量上方标记元信息:
EditAnywhere:这个变量可以在编辑器的属性面板中编辑
BlueprintReadWrite:蓝图可以读写这个变量
Category = "Combat":在属性面板中归入 "Combat" 分组
这些说明符不是给 C++ 编译器看的——C++ 编译器处理完预处理后,UPROPERTY(...) 展开为空(或接近于空)。说明符是给 UHT 看的:UHT 读取这些标记,把它们编码进生成的反射数据中。运行时,编辑器通过反射查询到"这个属性有 EditAnywhere 标记",于是为它生成一个可编辑的输入框。
更关键的是 UPROPERTY() 的一个看不见的作用:它告诉 GC 系统"这个指针指向的 UObject 正在被引用"。如果你持有一个 UObject* 指针但没加 UPROPERTY(),GC 可能会在你不知情的情况下回收那个对象——留给你一个悬空指针。
悬念标记 :UPROPERTY 如何驱动反射?→ 第 4 章。UPROPERTY 如何保护 GC 引用?→ 第 5 章
UFUNCTION(…) UFUNCTION (BlueprintCallable, Category = "Combat" )
void TakeDamage (float DamageAmount) ;
UFUNCTION (Server, Reliable)
void ServerAddScore (int32 Amount) ;
与 UPROPERTY 类似,UFUNCTION 为函数附加元数据。BlueprintCallable 让蓝图可以调用这个 C++ 函数。
但第二个 UFUNCTION 更令人震惊:Server, Reliable 的意思是——这个函数由客户端调用,但在服务器上执行,并且是可靠传输的。换句话说,一个宏标记把一个普通的 C++ 成员函数变成了一个远程过程调用(RPC) 。
在标准 C++ 的世界里,实现一个 RPC 你需要:定义协议、序列化参数、通过网络发送、在远端反序列化、找到目标对象、调用方法。在 Unreal 中,你加两个关键词就行了。
这不是魔法。这是反射系统 + 序列化系统 + 网络系统三方联合运作的结果——UHT 为这个函数生成 thunk(转换层)代码,反射系统让网络层知道这个函数的签名,序列化系统负责打包参数。但站在写代码的程序员面前,这一切被压缩成了两个单词。
int32 而不是 int 你可能注意到了 int32 Score 而不是 int Score。在标准 C++ 中,int 的大小取决于平台——大多数现代平台上是 32 位,但标准不保证。
Unreal 定义了一组跨平台的固定宽度整数类型:int8、int16、int32、int64、uint8、uint16、uint32、uint64。这比标准 C++ 的 <cstdint>(int32_t 等)更简洁,也确保了在不同平台上的行为一致——尤其在网络同步场景下,一个变量在服务器和客户端上必须有相同的位宽。
TArray 而不是 std::vector 在函数签名中出现的 TArray<FLifetimeProperty> 是 Unreal 的动态数组,对标 std::vector。Unreal 几乎不使用 STL 容器——它有自己的一整套:TArray、TMap、TSet、TQueue……
为什么要重造轮子?因为 STL 的实现在不同编译器上行为不一致,而且 STL 容器不知道 Unreal 的内存分配器、不配合 GC、不支持引擎的序列化管线。
悬念标记 :Unreal 的容器设计了什么不同?→ 第 7 章
没有 main() 如果你从头到尾翻遍一个 Unreal 项目的源代码,你找不到 main() 函数。main() 存在——但它在引擎源码深处,由 Unreal 提供。你的游戏代码作为模块被引擎加载和调用,而不是作为程序的入口点存在。
这意味着你写的不是一个"程序",而是一个"插件"——引擎掌控整个生命周期,你的代码在引擎规定的时机被调用。这也是为什么你会看到 BeginPlay()、Tick() 这样的虚函数覆盖,而不是在 main() 里写主循环。
没有 CMakeLists.txt 项目根目录没有 CMake 文件,取而代之的是 .Build.cs 和 .Target.cs——用 C# 写的构建配置脚本。
Unreal 有自己的构建系统 UBT(Unreal Build Tool) 。UBT 用 C# 实现,读取 .Build.cs 中的模块定义和依赖关系,然后为目标平台生成编译命令。它替代了 CMake / Meson / Premake 的角色。
为什么不用 CMake?因为 Unreal 的构建流程不仅仅是"编译 C++"——它还包括 UHT 代码生成、Shader 编译、资产烘焙、多平台交叉编译等。这些需求超出了通用构建工具的能力范围。
1.2 问题清单 让我们把上面发现的所有"不属于标准 C++ 的东西"汇总成一张清单。每个问题都标注了它将在哪一章得到解答——这张表就是你的全书路线图。
# 发现的"异常" 核心问题 解答章节 1 CoreMinimal.h 统一头文件为什么不按需 #include? 第 16 章 2 .generated.h 从天而降谁生成的?里面是什么? 第 4、15 章 3 UCLASS() 宏它给 UHT 传递了什么信息? 第 4、15 章 4 说明符(Blueprintable、EditAnywhere……) 这些元数据如何被收集、存储、查询? 第 4 章 5 MYGAME_API 导出宏Unreal 的模块化是怎么回事? 第 14 章 6 A/U/F/T/E/I/b 前缀 为什么前缀是强制的,不只是风格? 第 3、5 章 7 GENERATED_BODY()它注入了什么代码? 第 15 章 8 UPROPERTY()如何驱动反射、GC、编辑器? 第 4、5 章 9 UFUNCTION()如何让普通函数变成 RPC? 第 17、19 章 10 int32 代替 int为什么需要固定宽度类型? 第 1 章(见 1.1 节) 11 TArray 代替 std::vector为什么重造 STL? 第 7 章 12 没有 main() 程序入口在哪? 第 14 章 13 没有 CMake,而是 .Build.cs 构建系统如何工作? 第 14 章 14 Replicated 标记一个标记如何实现网络同步? 第 19 章
数一数:14 个问题,横跨构建系统、代码生成、反射、GC、容器、序列化、网络、蓝图。一份 30 行的头文件,浓缩了 Unreal 对 C++ 的几乎全部改造。
这就是这本书要做的事:从这 14 个问号出发,一个一个把它们变成句号。
一句话总结 Unreal C++ 不是标准 C++ 加了点宏——它是一套完整的语言改造,涉及构建、代码生成、类型系统、内存管理、通信、序列化等每一个维度。
实验:亲眼看看
在 Unreal Editor 中创建一个新的 C++ 类(继承自 ACharacter),然后在 IDE 中打开生成的 .h 文件。
尝试找到项目的 Intermediate/Build 目录,在里面搜索 .generated.h 文件,打开看看——即使现在看不懂也没关系,你只需要确认它的存在。
打开项目的 .Build.cs 文件,看看里面有什么。
在整个项目中搜索 main(——确认你找不到它。
这四个动作不需要理解原理,只需要亲眼确认:'这些东西确实存在,但它们不是标准 C++。' 带着这个事实感,翻开下一章。
相关免费在线工具 Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
JSON美化和格式化 将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online