跳到主要内容 Unreal 引擎对 C++ 的改造:初识非标准 C++ 特性 | 极客日志
C++
Unreal 引擎对 C++ 的改造:初识非标准 C++ 特性 通过逐行分析 Unreal Engine 的 C++ 头文件,揭示了其与标准 C++ 的差异。内容包括宏系统(UCLASS, UPROPERTY)、代码生成工具(UHT)、构建系统(UBT)、命名规范及内存管理等核心机制。文章列出了 14 个关键问题,旨在帮助熟悉标准 C++ 的开发者理解 Unreal 引擎的底层设计哲学与工程实践。
灵魂摆渡 发布于 2026/3/22 更新于 2026/4/16 24K 浏览第 1 章 · 这不是你认识的 C++
假设你是一个写了几年 C++ 的程序员。你用过 STL,写过智能指针,被模板元编程折磨过,也许还读过 Scott Meyers 或者 Herb Sutter 的书。有一天你决定学 Unreal Engine,打开编辑器,创建了一个 C++ 类。
然后你看到了这样一份头文件:
#pragma once
#include "CoreMinimal.h"
#
(Blueprintable)
AMyCharacter : ACharacter {
()
:
();
(EditAnywhere, BlueprintReadWrite, Category = )
MaxHealth = ;
(VisibleAnywhere, BlueprintReadOnly, Category = )
CurrentHealth;
(Replicated) int32 Score;
(BlueprintCallable, Category = )
;
(Server, Reliable)
;
:
;
;
};
include
"GameFramework/Character.h"
#include "MyCharacter.generated.h"
UCLASS
class
MYGAME_API
public
GENERATED_BODY
public
AMyCharacter
UPROPERTY
"Combat"
float
100.0f
UPROPERTY
"Combat"
float
UPROPERTY
UFUNCTION
"Combat"
void TakeDamage (float DamageAmount)
UFUNCTION
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++。' 带着这个事实感,翻开下一章。
微信扫一扫,关注极客日志 微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具 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