Unreal对C++做了什么 · Part3工具箱 · 第 7 章 · 容器:告别 STL
第 7 章 · 容器:告别 STL
从本章开始,我们进入第三部分——工具箱。前两部分建立了 Unreal C++ 的根基:UObject(统一基类)、反射(运行时类型信息)、GC(自动内存管理)、UInterface(多继承替代)。全景图的中心已经点亮,现在我们把目光移到外围——那些你每天写代码时都会用到、却"不像标准 C++"的日常工具。
第一个要聊的是容器。
如果你是一个标准 C++ 开发者,std::vector、std::unordered_map、std::set 大概是你最常用的容器。进入 Unreal 后,你会发现这些东西几乎不出现——取而代之的是 TArray、TMap、TSet。
Unreal 的代码规范甚至明确建议:不要在引擎代码中使用 STL 容器。
这不是"Not Invented Here"综合征。它背后有真实的工程理由。
7.1 为什么 Unreal 不用 STL
跨平台一致性
STL 是一个标准接口,但不是一个标准实现。MSVC 的 std::vector、libstdc++ 的 std::vector、libc++ 的 std::vector 内部实现各不相同——内存增长策略不同、调试迭代器行为不同、甚至 sizeof(std::vector) 都可能不一样。
对于一个需要在 Windows、Linux、macOS、PlayStation、Xbox、Switch、iOS、Android 上保持一致行为的引擎来说,这种不可控的差异是不可接受的。
内存分配器控制
Unreal 有自己的内存分配器体系(FMalloc 层),负责内存跟踪、泄漏检测、性能分析。STL 容器默认使用 std::allocator,虽然可以替换,但自定义 allocator 在 C++ 中使用体验很差(类型会变、容器间不兼容等)。
Unreal 的容器天然使用引擎的分配器,不需要任何额外配置。
序列化友好
第 18 章会详细讲序列化,但这里先提一点:Unreal 的容器配合 UPROPERTY 可以被自动序列化。
UPROPERTY(SaveGame)
TArray<FString> InventoryItems;
这个数组会被引擎的序列化系统自动保存和加载。如果你用 std::vector<std::string>,序列化系统根本不认识它——反射系统只为 Unreal 的容器类型生成元数据。
调试友好
Unreal 为自己的容器提供了 Natvis 文件(Visual Studio 的调试可视化配置),在调试器中展开一个 TArray 可以直接看到所有元素。STL 容器虽然也有调试支持,但在交叉编译和远程调试场景下(比如调试 PlayStation 上的代码),Unreal 的自定义可视化更可控。
7.2 TArray:Unreal 的 std::vector
TArray 是你在 Unreal 中最常用的容器——动态数组,连续内存,随机访问。它对标 std::vector,但 API 风格和一些细节决策不同。
基本用法对照
// std::vector // TArray
std::vector<int> Vec; TArray<int32> Arr;
Vec.push_back(10); Arr.Add(10);
Vec.emplace_back(20); Arr.Emplace(20);
Vec[0]; Arr[0];
Vec.size(); Arr.Num();
Vec.empty(); Arr.IsEmpty(); // UE5; UE4 用 Num() == 0
Vec.clear(); Arr.Empty();
Vec.reserve(100); Arr.Reserve(100);
Vec.erase(Vec.begin() + 2); Arr.RemoveAt(2);
几个值得注意的差异:
Num() 而不是 size()。 Unreal 的 API 更偏向简洁的动词,Num 比 size 少三个字符。返回的是 int32 而不是 size_t——Unreal 有意避免无符号整数用于容器大小,因为无符号整数在循环和比较中容易引发 bug。
Add() vs Emplace()。 语义与 STL 一致:Add 拷贝或移动一个已有对象进数组,Emplace 原地构造。但 Unreal 还有 Push()——它是 Add 的别名,纯粹为了让从 STL 迁移的程序员习惯。
Remove vs RemoveSwap
这是 TArray 最有意思的设计决策之一。
TArray<int32> Arr = {10, 20, 30, 40, 50};
// RemoveAt(1) — 保序删除
// 删除 20,后面的元素依次前移:{10, 30, 40, 50}
// 时间复杂度:O(N)
Arr.RemoveAt(1);
// RemoveAtSwap(1) — 交换删除
// 把最后一个元素移到被删位置:{10, 50, 40} (如果从上面的结果继续)
// 时间复杂度:O(1)
Arr.RemoveAtSwap(1);
RemoveSwap 是 STL 中没有的 API。它不保持元素顺序,但只有 O(1) 的开销——在游戏开发中,很多场景不关心顺序(比如碰撞体列表、待处理的事件队列),用 RemoveSwap 可以显著提升性能。
同样的模式也出现在 Remove / RemoveSwap(按值删除)和 RemoveSingle / RemoveSingleSwap(只删第一个匹配)上。
其余 API(查找、过滤、排序、迭代)与 vector 或 STL algorithm 思路一致,命名更直白(如 FindByPredicate、Contains);迭代器失效规则同 vector。具体查引擎文档即可。
7.3 TMap:Unreal 的哈希表
TMap 是无序的键值对容器,对标 std::unordered_map。
TMap<FString, int32> ScoreBoard;
ScoreBoard.Add(TEXT("Alice"), 100);
ScoreBoard.Add(TEXT("Bob"), 85);
// 查找
int32* Score = ScoreBoard.Find(TEXT("Alice"));
if (Score)
{
UE_LOG(LogTemp, Warning, TEXT("Alice: %d"), *Score);
}
// FindOrAdd——不存在则插入默认值并返回引用
int32& CharlieScore = ScoreBoard.FindOrAdd(TEXT("Charlie"));
CharlieScore = 90;
// 检查是否包含
bool bHasBob = ScoreBoard.Contains(TEXT("Bob"));
// 遍历
for (auto& Pair : ScoreBoard)
{
UE_LOG(LogTemp, Warning, TEXT("%s: %d"), *Pair.Key, Pair.Value);
}
// 获取所有 Key / Value
TArray<FString> Names;
ScoreBoard.GetKeys(Names);
与 std::unordered_map 的改造点:Find 返回指针(ValueType* / nullptr)而非迭代器;FindOrAdd 不存在则插入默认值并返回引用,语义单一;Add/Emplace 遇重复键直接覆盖。有序需求用 TSortedMap,用得较少。
7.4 TSet:Unreal 的哈希集合
TSet 对标 std::unordered_set,与 TMap 共享底层实现(开放寻址哈希)。支持 Add、Contains、Union/Intersect/Difference 等集合运算。API 风格与 TMap 一致。
7.5 其他容器速览
Unreal 还提供了一些特化容器,使用频率不如前三个高,但在特定场景下很有价值:
| 容器 | 用途 | 对标 |
|---|---|---|
TSortedMap<K,V> | 按键有序的键值对 | std::map |
TMultiMap<K,V> | 一个键对应多个值 | std::unordered_multimap |
TQueue<T> | 线程安全的无锁队列 | moodycamel::ConcurrentQueue 等 |
TCircularBuffer<T> | 固定大小的环形缓冲区 | Boost.CircularBuffer |
TStaticArray<T,N> | 编译期固定大小数组 | std::array<T,N> |
TBitArray | 位数组 | std::vector<bool>(但更好) |
TSparseArray<T> | 稀疏数组(TMap/TSet 的底层) | 无直接对标 |
跨线程通信常用 TQueue(无锁队列);线程角色见第 12 章。
7.6 UPROPERTY 与容器
容器可以被 UPROPERTY 标记,从而获得完整的引擎集成:
// 编辑器中可编辑的数组
UPROPERTY(EditAnywhere, Category = "Inventory")
TArray<FString> ItemNames;
// GC 安全的 UObject 指针数组
UPROPERTY()
TArray<UActorComponent*> TrackedComponents;
// 可序列化的 Map
UPROPERTY(SaveGame)
TMap<FString, int32> PlayerScores;
回忆第 5 章(GC):UPROPERTY 标记的 TArray<UObject*> 中的每个元素都会被 GC 追踪。你不需要逐个标记——UPROPERTY 整个容器就够了。反射系统知道如何遍历 TArray、TMap、TSet 的内部元素。
限制:UPROPERTY 不支持嵌套容器。以下代码不能编译:
// 错误:UHT 不支持
UPROPERTY()
TArray<TArray<int32>> NestedArray;
// 替代方案:包装成结构体
USTRUCT()
struct FIntArray
{
GENERATED_BODY()
UPROPERTY()
TArray<int32> Values;
};
UPROPERTY()
TArray<FIntArray> NestedArray; // 合法
这是 UHT 的解析限制——它不能处理模板嵌套模板的反射数据生成。包装成 USTRUCT 是标准的绕行方案。
7.7 设计哲学对比
把视角从具体 API 拉远一步,看看 Unreal 容器与 STL 容器在设计哲学上的差异:
| 维度 | STL 容器 | Unreal 容器 |
|---|---|---|
| 目标 | 通用、零开销抽象 | 游戏引擎场景优化 |
| API 风格 | 迭代器中心(begin/end) | 直接操作(Find 返回指针) |
| 大小类型 | size_t(无符号) | int32(有符号) |
| 分配器 | std::allocator(可替换但麻烦) | 引擎分配器(内置) |
| 序列化 | 不支持 | 通过 UPROPERTY 自动支持 |
| 反射 | 不支持 | 通过 UPROPERTY 完全集成 |
| 移除策略 | 只有保序移除 | 保序 + Swap 移除可选 |
| 嵌套 | 自由嵌套 | UPROPERTY 不支持嵌套容器 |
两者没有绝对的优劣——STL 更通用更灵活,Unreal 容器在引擎生态中更好用。但在 Unreal 项目中,选择 Unreal 容器几乎总是正确的,因为你需要反射和序列化集成。
实验:TArray / TMap 手感
- TArray 的 Add 与 RemoveAt。 在任意 Actor 的 BeginPlay 里创建一个
TArray<int32>,依次 Add 若干数,用RemoveAt(0)和RemoveAtSwap(0)各试一次,在每次操作后打印 Num() 和元素顺序,体会保序移除与 Swap 移除的差异。 - TMap 的 FindOrAdd。 用
TMap<FString, int32>模拟一个"名字 → 出现次数"的统计:循环里对多个 FString 调用FindOrAdd(Name)++,最后遍历打印,确认 FindOrAdd 能一次完成"没有则插入 0,有则返回引用"。 - UPROPERTY 里的 TArray。 在 Actor 上声明
UPROPERTY(EditAnywhere) TArray<FString> Tags;,编译后在编辑器 Details 里编辑该数组并保存资产,理解"容器 + UPROPERTY = 可反射、可序列化"。
一句话总结
Unreal 用 TArray/TMap/TSet 替代 STL 容器,不是因为"不想用",而是因为游戏引擎需要容器与内存分配器、反射、序列化、GC 深度集成——这些是 STL 做不到的。
更多推荐
所有评论(0)