第一次把std::vector改成TArray的时候,我其实是很不情愿的。明明std::vector用得好好的,为什么 Unreal 非要自己造一套容器?后来当我在项目里开始处理存满UObject*的数组、要给网络同步模块做序列化、又要在编辑器里让容器内容能被反射系统看到时,这个答案就自己浮出来了。Unreal 抛弃 STL 容器,不是为了赶时髦,而是为了接管 C++ 里标准库管不上的东西:对象生命周期、内存分配策略、序列化和反射边界。这篇文章把我的理解整理成五个部分:为什么 UE 要自研容器、STL 到 UE 容器的映射关系、实际使用中容易翻车的边界行为、什么时候可以继续用 STL,以及一次从std::unordered_map迁到TMap的完整实操记录。
如果你在做 Unreal 项目,又一直纠结“要不要把项目里的 STL 容器全换掉”,这篇文章应该能给你一个比较明确的判断标准。
1. UE 自研容器的核心逻辑:为了接管对象生命周期,而不是为了炫技
1.1 GC 和反射系统根本不认识 std::vector
这是最根本的原因。Unreal 的整个对象模型建立在反射和垃圾回收之上。你在 Actor 里写一个成员变量:
UPROPERTY() TArray<UStaticMeshComponent*> MeshComponents;引擎的编辑器面板能看到这个数组,蓝图可以遍历它,存档系统能把里面引用的资产序列化进磁盘,网络复制也能追踪到它。这一整套能力靠的是UPROPERTY标记和 Unreal 的属性系统。
但如果你写成:
std::vector<UStaticMeshComponent*> MeshComponents;那 Unreal 的反射系统完全看不到它,编辑器里不显示,存档时不会被自动保存,GC 也不知道这个容器里保存了哪些对象的引用。结果就是这个数组里的UObject*可能在某次 GC 之后悄悄变成悬垂指针,游戏跑着跑着就崩了。
有人会反驳说,可以用AddReferencedObjects手动告诉 GC。确实可以,但这是把标准库容器的管理责任全部压到开发者身上。一个项目里几十个容器,哪怕漏掉一个,就是一颗运行时炸弹。Unreal 选择让容器本身和属性系统绑定,属于从制度上消灭这类问题。
所以第一条结论是:只要容器内容涉及 UObject、需要序列化或需要编辑器可见,STL 容器就根本不具备参赛资格。
1.2 自定义分配器在游戏引擎里比 STL 更好落地
标准库当然也支持自定义分配器,但实际用起来非常别扭。C++17 之前的标准分配器是有状态的分配器很难写,很多平台上的std::allocator无法携带额外的上下文,比如你想让某个容器专门从一块线程局部的内存池里分配,标准库的实现会给你添不少麻烦。
Unreal 的容器把分配器做成了模板参数,比如TArray<T, FDefaultAllocator>。你完全可以换成自己的分配器,而且分配器的生命周期和容器绑定在一起,控制起来很直接。对游戏引擎来说,这意味着两个好处:
- 可以向指定内存池分配,配合帧分配器、线程临时内存池做优化。
- 可以通过统一分配器统计当前容器占用的内存,方便做性能分析和内存泄漏排查。
配合上 Unreal 的内存对齐要求,比如某些容器里的数据结构需要 16 字节对齐以配合 SIMD,直接通过模板分配器指定即可。STL 的标准分配器接口也能做到,但大多数团队根本没那个精力去改造和维护。
1.3 索引优先的容器设计,让元素搬家时更从容
还有一点很容易被忽略:Unreal 的很多容器在设计上倾向于用索引和句柄来定位元素,而不是裸指针。最典型的是TMap和TSet,它们底层使用稀疏数组管理元素,元素数据的地址可能会在哈希表扩容时发生移动,但索引关系尽量保持稳定。
这意味着你在外部保存一个元素的索引,即使容器内部发生了搬迁,仍然可以相对安全地定位到数据;但如果你保存的是指向元素地址的裸指针,内存一变,指针就悬了。这种设计思路来自游戏引擎的运行时特点:容器扩容、内存池重组、GC 压缩都可能触发内存搬移,索引比指针更抗造。
当然这不代表索引就是万能安全垫,下面我会专门讲边界情况。但这个设计倾向确实反映了 Unreal 容器“为不断搬家的内存模型服务”的本质。
2. 你真正需要记住的 STL 到 UE 容器映射表
2.1 一张粗暴但很实用的对照表
先说结论,我见过太多初学者把std::map换成TMap之后,还期望迭代顺序和std::map一样是升序,结果一头雾水。这里先给出基本对应关系:
| STL 容器 | Unreal 容器 | 主要差异 |
|---|---|---|
std::vector | TArray | 索引类型是int32,默认分配器由引擎管控 |
std::deque | TQueue/TCircularBuffer | 游戏场景里std::deque用得少,多数直接换TArray |
std::list | TDoubleLinkedList | 很少需要,链表在游戏里往往不是最优解 |
std::map/std::unordered_map | TMap | 无序,哈希桶实现,迭代顺序不保证 |
std::set/std::unordered_set | TSet | 同上,无序 |
std::string | FString/FStringView | FString基于TArray<TCHAR>,重量级 |
std::unique_ptr | TUniquePtr | 语义接近 |
std::shared_ptr | TSharedPtr | 语义接近,但不适合持有UObject* |
std::weak_ptr | TWeakPtr/TWeakObjectPtr | 根据对象类型选择 |
std::function | TFunction | 用法几乎一样 |
std::optional | TOptional | 语义接近 |
std::variant/std::tuple | TVariant/TTuple | 用法接近 |
2.2 TArray 与 std::vector 的用法差异速记
TArray和std::vector最像,但有几个地方千万别搞混:
size()变成Num(),clear()变成Empty()或Reset()。push_back变成Add或Emplace。Emplace是在数组末尾直接构造,减少一次临时对象拷贝,性能敏感的位置优先用。reserve对应Reserve,但resize对应SetNum。SetNum会直接改变元素数量,新元素会调用默认构造,这在某些类型上有额外开销。- 索引类型是
int32,不是size_t。所以循环变量别写for (size_t i = 0; ...),否则会有无符号和有符号比较的告警,严重时会爆内存。
实际编码里我最常用的一套组合是:
TArray<FVector> Points; Points.Reserve(128); for (int32 Index = 0; Index < 128; ++Index) { Points.Emplace(FVector::ZeroVector); }先用Reserve预留容量,再用Emplace添加元素,可以避免扩容带来的多次内存重分配。
2.3 TMap/TSet 的哈希与顺序问题
TMap实际上更像std::unordered_map,底层是哈希桶,不是红黑树。这意味着:
- 遍历
TMap时,顺序不保证,而且不同引擎版本之间可能有差异。 - 不要指望像
std::map那样按键升序遍历,如果需要有序遍历,先取出来排序。 Find返回的是指针,找不到返回nullptr;FindRef返回引用,但找不到时返回的是默认构造的值,有歧义风险;FindOrAdd才是最常用的“找到就返回,找不到就插入”的方式。
int32& Value = MyMap.FindOrAdd(PlayerId); Value += 1;自定义类型放进TMap或TSet时,必须实现GetTypeHash和operator==,否则编译不过。
uint32 GetTypeHash(const FMyKey& Key) { return HashCombine(GetTypeHash(Key.A), Key.B); } bool operator==(const FMyKey& L, const FMyKey& R) { return L.A == R.A && L.B == R.B; }2.4 字符串容器要特别处理
FString不是std::string。虽然两者都表示可变字符串,但FString内部是TArray<TCHAR>,平台上TCHAR宽度可能不同。更重要的是,Unreal 的工具链对FString有特殊处理:它可以在控制台、日志、调试、编辑器界面里无缝显示,这是std::string做不到的。
近几年我强烈建议在只读场景下改用FStringView,它类似std::string_view,不持有所有权、不拷贝缓冲区。做字符串解析或者传参时,用FStringView能避免大量无意义的内存分配。
std::string并不是完全不可以用,但如果它要跨过 Unreal 的 API 边界,比如传给Printf、Log、Cast系统,就会有编码转换和拷贝成本。能用FString就用FString,省下来的不仅是代码量,还有传输时的隐性内存动作。
3. 用 UE 容器过程中最容易翻车的五个边界情况
3.1 重分配导致裸指针失效
很多人刚迁到 TArray 时,保留着用指针指向数组元素的习惯。比如:
TArray<FCharacterData> DataList; FCharacterData* Data = &DataList[0]; DataList.Add(FCharacterData()); // 此时 Data 可能已经悬垂TArray扩容时会重新分配整块连续内存,原地址可能直接失效。STL 的std::vector同样有这个特性,换了TArray不代表这个坑消失了。
解决思路很简单:
- 长期持有的引用用索引
DataList[Index]而不是&DataList[Index]。 - 知道后续会大量添加元素时,提前
Reserve。 - 需要真正稳定的指针语义时,用
TSharedPtr<T>或者单独堆分配后保存指针。
这里我要特别提醒一句:索引也不是无条件安全的。如果你做RemoveAt,后面的元素会往前移动,索引语义就变了。索引只有在你不会增删元素、或删除时主动更新索引的前提下才是稳定的。
3.2 遍历 TMap 时改结构引发的诡异问题
在遍历TMap或TSet的过程中直接添加或删除元素,是 UE 容器坑里出现频率最高的一种。你可能会看到崩溃,也可能会看到一个元素被遍历两次,甚至直接死循环。
for (const TPair<int32, FSomeData>& Pair : DataMap) { if (Pair.Value.ShouldExpand()) { DataMap.Add(Pair.Key + 1, FSomeData()); // 不推荐:地图结构在遍历过程中改变了 } }正确做法是先把需要新增或删除的键收集到一个临时数组,遍历结束之后再统一处理。这个教训我在初学阶段踩过不止一次,后来形成了肌肉记忆:容器遍历过程中绝不修改容器本身的结构。
3.3 RemoveAt、RemoveAll、RemoveAtSwap 的选择
这三个函数看名字相似,语义差别很大:
RemoveAt(Index)按索引移除,后面的元素向前移动,保持原来相对顺序,代价是大量元素赋值。RemoveAll(Predicate)按条件移除,同样保持剩余元素相对顺序。RemoveAtSwap(Index)把最后一个元素填进被移除的位置,速度快很多,但元素顺序会被打乱。
如果你只关心集合是否包含某个元素,而不关心遍历顺序,RemoveAtSwap是性能更友好的选择。但如果数组的顺序有业务意义,比如 UI 列表、排行榜、技能队列,用RemoveAt或RemoveAll更安全。
常见误区是反着遍历时用RemoveAt,正向遍历时用删除后下标自减,这两种写法都能工作,但很容易写错。我更推荐的做法是用RemoveAll配合条件表达式,意图清晰,也不容易越界:
DataList.RemoveAll([](const FItem& Item) { return Item.IsExpired(); });3.4 自定义键类型没写哈希函数
把普通结构体放进TMap,如果不是原生支持的字符串或整数类型,就必须补GetTypeHash和operator==。很多编译错误看起来莫名其妙,实际原因就是这个。
还有一种更容易忽略的情况:键对象里的字段一旦被修改,哈希值就变了,但它在 map 里的桶位置还是旧的,Find 就找不到了。这和std::unordered_map的行为一致,但部署到网络同步或者玩家数据较多时,后果会更严重。反正记住:放进 TMap/TSet 的键,设计成不可变或极少变动,才是安全的使用方式。
3.5 UObject 放在普通容器里,却忘了 GC 引用
这是一个很隐蔽的问题。如果你写:
TArray<UObject*> Objects;这个容器本身不是UPROPERTY,GC 根本不知道它持有了哪些对象。即使你的容器里保存着某个 Actor 的指针,GC 依然可能认为这个 Actor 没有外部引用而将其回收。
解决方式分几种:
- 声明为
UPROPERTY() TArray<TObjectPtr<UObject>>,让属性系统参与 GC 追踪。 - 使用
TArray<TWeakObjectPtr<UObject>>,弱引用不阻止 GC,但会在对象销毁时自动失效。 - 如果确实要在非 UPROPERTY 容器里强引用,必须手动在对象的
AddReferencedObjects里上报。
很多人说 UE 容器比 STL 难用,其实最深层次的原因就在这里:容器需要理解 Unreal 的对象生命周期,这不是标准库容器的设计目标。
4. 什么情况我仍然坚持用 STL:选型边界与实践建议
4.1 一套简单的选择标准
四类场景我倾向直接用 STL:
- 纯算法或纯数据处理模块,完全不涉及 UObject、序列化、反射。
- 第三方库桥接层,对方接口只认
std::vector或std::string。 - 工具链脚本或离线处理,不跑在游戏主线程,也不需要热重载。
- 需要严格有序映射的业务,比如按时间戳升序遍历,
std::map天然满足,换TMap反而要额外排序。
其余涉及引擎对象、网络同步、存档、编辑器交互的场景,第一选择都是 UE 容器。
4.2 给第三方库做桥接层
最麻烦的是第三方库只认 STL。我的做法是:
- 在库的边界处放一个适配层,库内部完全使用 STL,不碰 UE 容器。
- 边界处用纯数据
struct做转换,例如把std::vector<FVector>转成TArray<FVector>,或反向输出。 - 不要在第三方库的接口里直接传 UE 容器,也不要在 UE 系统函数里直接传 STL 容器。
这类桥接层虽然有一点拷贝成本,但能换来边界清晰。它是值得的。
TArray<FVector> ToUnrealArray(const std::vector<FVector>& InVec) { TArray<FVector> Result; Result.Reserve(static_cast<int32>(InVec.size())); for (const FVector& V : InVec) { Result.Add(V); } return Result; }4.3 不要为了“统一”而全网迁移
我见过有人因为团队规定“禁止 STL”,然后硬生生把底层物理引擎接口里的std::vector全部改成TArray,结果中间穿了一层引用边界,性能反而下降。容器选型不是道德选择,是成本选择。UE 容器和 STL 容器可以共存,关键是让它们的边界有明确规则:引擎相关代码偏 UE 容器,纯算法和第三方隔离层偏 STL。
5. 一次典型迁移:技能系统里的 unordered_map 换成 TMap,遇到什么又解决了什么
5.1 改造前后的最小代码
我拿一个模拟的技能系统举例。原来的数据结构是:
std::unordered_map<int32, FSkillState> SkillMap; std::vector<int32> ActiveSkillIds;迁移后的版本:
UPROPERTY() TMap<int32, FSkillState> SkillMap; UPROPERTY() TArray<int32> ActiveSkillIds;变量声明先改掉。然后处理所有用到这些容器的地方:
// 前 auto It = SkillMap.find(SkillId); if (It != SkillMap.end()) { It->second.CooldownRemain = 0.0f; } // 后 if (FSkillState* State = SkillMap.Find(SkillId)) { State->CooldownRemain = 0.0f; }5.2 迁移过程中的编译错误和逻辑问题
最容易出错的不是语法,而是语义假设。我之前用std::map时,偶尔依赖了键的排序关系来做“技能按 ID 排序显示”。迁到TMap之后,这个顺序完全不可控了,UI 列表开始乱序。最后我把排序逻辑从容器结构里拆出来,在 UI 层单独对ActiveSkillIds排序,问题就解决了。
另一个问题是RemoveAll的谓词写法。原来的 STL 代码用的是erase + remove_if组合,语义差不多,但换成RemoveAll之后很容易漏掉const引用。写谓词时一定要用const,否则有些类型会让编译器报出让人摸不着头脑的错误。
5.3 验证与回归经验
迁移之后至少要在三种环境里验证:
- 编辑器里打开关卡再保存,确认
UPROPERTY标记的容器没有序列化问题。 - 开启网络同步模拟,让两个客户端跑同一套数据,确认新增的
TMap能被复制。 - 用性能分析工具跑一遍技能释放循环,看看
TMap的增删和Find是否成为热点。
我在这次迁移里最意外的一点是:原本以为std::unordered_map会比TMap快,结果在同规模数据下两者差异并不明显,反而TMap在遍历和内存局部性上略好,因为它底层用的是稀疏数组,缓存命中率更高。
5.4 迁移时我在意三个点
第一,大结构体尽量用Emplace而不是Add传临时值,减少拷贝。第二,把容器替换和生命周期边界替换一起做,比如顺手补上UPROPERTY,别留半吊子状态。第三,遇到和第三方库交错的部分,保留 STL 适配层,不要强行拉通,否则后续维护成本会成倍上涨。
最后再说一个很多人都忽略的观点:有声音说 UE 容器性能差,我实测下来往往不是容器本身的问题,而是没有选对容器类型和分配器。该用TArray的地方用了TSet,该Reserve的地方没Reserve,该用索引的地方用了裸指针,这些才是性能问题的真正来源。容器只是工具,真正的问题永远在使用工具的方式。