第 7 章 · 容器:告别 STL

从本章开始,我们进入第三部分——工具箱。前两部分建立了 Unreal C++ 的根基:UObject(统一基类)、反射(运行时类型信息)、GC(自动内存管理)、UInterface(多继承替代)。全景图的中心已经点亮,现在我们把目光移到外围——那些你每天写代码时都会用到、却"不像标准 C++"的日常工具。

第一个要聊的是容器。

如果你是一个标准 C++ 开发者,std::vectorstd::unordered_mapstd::set 大概是你最常用的容器。进入 Unreal 后,你会发现这些东西几乎不出现——取而代之的是 TArrayTMapTSet

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 更偏向简洁的动词,Numsize 少三个字符。返回的是 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 思路一致,命名更直白(如 FindByPredicateContains);迭代器失效规则同 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 共享底层实现(开放寻址哈希)。支持 AddContainsUnion/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 手感

  1. TArray 的 Add 与 RemoveAt。 在任意 Actor 的 BeginPlay 里创建一个 TArray<int32>,依次 Add 若干数,用 RemoveAt(0)RemoveAtSwap(0) 各试一次,在每次操作后打印 Num() 和元素顺序,体会保序移除与 Swap 移除的差异。
  2. TMap 的 FindOrAdd。TMap<FString, int32> 模拟一个"名字 → 出现次数"的统计:循环里对多个 FString 调用 FindOrAdd(Name)++,最后遍历打印,确认 FindOrAdd 能一次完成"没有则插入 0,有则返回引用"。
  3. UPROPERTY 里的 TArray。 在 Actor 上声明 UPROPERTY(EditAnywhere) TArray<FString> Tags;,编译后在编辑器 Details 里编辑该数组并保存资产,理解"容器 + UPROPERTY = 可反射、可序列化"。

一句话总结

Unreal 用 TArray/TMap/TSet 替代 STL 容器,不是因为"不想用",而是因为游戏引擎需要容器与内存分配器、反射、序列化、GC 深度集成——这些是 STL 做不到的。

更多推荐