TSet vs TArray:UE游戏开发哈希容器原理与性能实战指南
1. 从 TArray 到 TSet为什么游戏开发里需要一套哈希容器先聊聊我自己的经历。早年写 UI 背包系统用 TArray 存了几千个道具 ID每次判断“这个物品是否已拥有”都调一次 Contains。数据量小的时候没啥感觉等道具总数过万、每次刷新都要全量检查时Profiler 里那一大坨红彤彤的 O(n) 查找直接把我看傻了。后来换了 TSet同样的逻辑一个 Contains 下去哈希查找基本是常数级那个视觉冲击力相当直观。TSet 是 UE 对哈希集合的封装底层本质是一张哈希表。它跟标准库里的 unordered_set 有点像但 UE 对它做了不少定制保持了插入顺序、针对游戏对象做了内存和迭代器优化、可以快速转成 TArray、也能用结构体的成员做哈希重载。这套东西在 C 里写起来风格上比标准库亲切不少也更贴合 UE 的调试工具链——你在 Visual Studio 里直接就能看到 TSet 的内部哈希桶分布标准库容器反而没这么方便。个人观点TSet 的核心价值就两个字判重。你不需要排序、不需要按下标访问、不需要频繁增删中间元素唯一的诉求就是“这个元素在不在集合里”那就 TSet。反过来如果你需要顺序访问、需要二分查找、需要下标定位老老实实用 TArray类型选错了后面再换容器成本一定比一开始想清楚要高。这篇内容适合正在学 UE C 的开发者也适合做过几个项目但一直在 TArray 里打转、想补充容器选型经验的朋友。我会把 TSet 的成员函数一个个拆开讲结合游戏逻辑里的真实场景尽量把每个 API 背后的机制和坑都说明白。2. TSet 的设计思路与选型逻辑2.1 哈希表到底是个啥桶、键和哈希函数的三角关系要把 TSet 用明白你至少要理解一个底层概念哈希表。别被这个词吓到我用一个生活化的例子讲透。想象一个快递驿站货架上有一排排格子每个格子编号 0、1、2、3……现在来了一个包裹收件人叫“张三”。你用不着把整个货架扫一遍找“张三”只需要用一个公式快速算出张三应该放在哪个格子——这就是哈希函数。算出来的格子编号就叫桶索引。你直接走到那个格子前面如果格子里正好是张三的包裹瞬间就能取到。但有个问题万一张三和李四算出来的格子编号一样怎么办这就是哈希冲突。UE 的 TSet 用了链地址法冲突的元素会挂在一个桶的链表里查找的时候只需要在这个小链表里遍历。如果哈希函数分布均匀这个链表通常很短查找仍然非常快。这就是为什么 TSet 的 Contains 和 Find 平均复杂度是 O(1)最坏情况才是 O(n)。前提是哈希函数质量过关而且元素分布别太极端。再看 UE 的具体实现。TSet 内部主要包含三块哈希桶数组、元素存储数组、空闲索引列表。元素存储是连续内存哈希桶只存索引这份间接设计让 TSet 在遍历时能保持缓存友好——你迭代 TSet 时实际上是在遍历一个元素数组而不是跳着访问哈希桶。很多不熟悉的开发者以为 TSet 迭代顺序是乱的实际上它按插入顺序走只是在删除元素后顺序可能出现空洞这点后面我会专门讲。2.2 为什么游戏逻辑里 TSet 比 TArray 更合适游戏开发里最常见的两个场景一是判定“这个对象有没有被记录过”二是维护一个“无重复集合”。这两个需求如果硬用 TArray逻辑没错但性能会随数据量膨胀而急剧恶化。举个具象的例子。射击游戏里玩家拾取了 200 个不同的收集品每次拾取都要判断是不是已经拥有过。用 TArray最后一次判断要最多比对 200 次如果每帧做多次这样的判断开销累积非常明显。用 TSet每个元素的哈希值一旦算出来后续查找都是常数时间200 个元素和 20000 个元素Contains 的时间几乎没区别。再比如 AI 感知系统要把“当前感知到的敌人 ID”存进集合。同一帧敌人可能被多个感知源重复上报你需要去重。TSet 天然就是干这个的。还有一类场景你需要频繁做两个集合的交集、并集、差集。TSet 提供了 Intersect、Union、Difference 这类集合运算方法TArray 想实现同样功能得自己写嵌套循环复杂度直接翻倍。只要涉及集合运算TSet 基本就是首选。2.3 什么时候别用 TSet容器没有银弹。TSet 也有它的短板而且这几个短板在游戏项目里特别容易踩到第一TSet 无法按下标访问。它是哈希结构元素位置不具备稳定语义。你需要arr[3]这种操作直接放弃 TSet。第二元素顺序不稳定。虽然 TSet 在大部分情况下维持插入顺序但删除操作会复用空闲槽位遍历时可能遇到空洞。如果你需要严格按添加顺序处理数据TArray 或者 TMap 更稳。第三哈希计算有开销。如果你存的是一个超大的 FString每次插入和查找都要计算整段字符串的哈希这个成本反而比 TArray 的顺序比较要高。小数据量场景下TArray 可能比 TSet 更快。第四TSet 默认要求元素类型支持operator和GetTypeHash。UE 的基础类型基本都支持但你自己写的 USTRUCT 不一定需要手动实现哈希重载这块有额外学习成本。我的经验是元素个数少于几十个而且操作频率不高时TArray 足够数据量上来了、判重逻辑成热点果断换 TSet。选型不是看谁高级而是看谁的复杂度模型贴合你的访问模式。3. TSet 核心成员函数逐个拆解3.1 Add 与 Emplace插入元素的两个入口到底怎么选先看 Add 的基本用法TSetint32 EnemyIDs; EnemyIDs.Add(1001); EnemyIDs.Add(1002); EnemyIDs.Add(1001); // 无效操作1001 已存在这段代码执行完后集合里只有两个元素。Add 遇到重复键不会报错也不会覆盖原数据它内部先做一次查找如果元素已存在就直接返回。这是 TSet 与 TArray 最大的行为差异之一TArray 可以存重复值TSet 永远保持唯一。再讲 emplace。有些朋友从 C 标准库转过来习惯用 emplace 避免临时对象拷贝但 UE 的 TSet::Emplace 作用和 Add 几乎等价。看源码你会发现 Emplace 内部也是先查找再插入对那些构造开销较大的元素类型会有一些优化但实测下来差别很小。我用过的场景里TSet 存的基本是 ID、枚举、指针这类轻量元素Emplace 的收益有限不像 TMap 里存 FString 那样明显。我的建议轻量元素一律用 Add语义清晰元素是复杂结构体且构造参数较多时可以用 Emplace 少写一个临时对象。别在这上面过度纠结两者只是入口区别底层插入路径基本重合。3.2 Remove、Empty 与 Num删除、清空与计数Remove 的用法TSetint32 OwnedItems; OwnedItems.Add(5); OwnedItems.Add(8); OwnedItems.Remove(5); // 成功删除 5 bool bAfter OwnedItems.Num(); // 1有几个值得注意的点Remove 传一个不存在的值什么都不发生也不会报错。这是 TSet 与 TMap 不同的地方TMap 的 Remove 返回删除了几个键值对而 TSet 的 Remove 默认情况下返回 void只有传第二个参数bool bAllowShrinking时才有收缩内存的行为。Remove 之后该元素占用的空间会被标记为空闲槽位。如果后续继续 Add新的元素可能复用这个槽位这会导致迭代顺序出现空洞。举个直观的例子TSetint32 T; T.Add(1); T.Add(2); T.Add(3); T.Remove(2); T.Add(4); for (int32 Val : T) { // 输出可能是 1 2 4也可能是 1 3 4 }所以你在做“先遍历再删除”的操作时千万小心。下面的代码是经典的崩溃陷阱for (int32 Val : MySet) { if (Val Target) { MySet.Remove(Val); // 迭代器已经失效可能崩溃 } }正确做法是先把要删的元素收集到 TArray遍历结束后统一 Remove或者用 RemoveAndCopyValue 这类安全一点的接口。UE 还有专门为这种情况设计的TDestructiveIterator或者直接用RemoveIf但在 C 侧最稳妥的还是收集后统一删除。Empty 不用多说清空所有元素。它有两个可选参数ExpectedNumElements和bAllowShrinking。第一个参数告诉容器你预计接下来要装多少元素可以避免反复扩容第二个参数决定是否真正释放底层内存块。如果只调用MySet.Empty()而不传参默认会保留一定的容量方便后续复用。如果你确定这个集合之后不再使用希望把内存彻底还给系统调用MySet.Empty(0, true)就能强制收缩。这个细节在内存敏感的平台——比如主机或移动端——比较有用。Num 就是返回当前集合里的元素数量。操作复杂度 O(1)内部维护一个计数变量不会遍历统计。注意区分 Num 和容量容量是底层开辟的存储空间通常大于等于 Num。想查容量可以调用MySet.GetMaxIndex()获取当前分配的槽位数。3.3 Contains 与 Find查找 API到底该用哪个Contains 的语义非常直接if (MySet.Contains(EnemyID)) { // 这个敌人已经被记录过 }Contains 的返回值是 bool只告诉你存在与否。如果你还要取回集合里的元素那就得用 Find。int32* FoundPtr MySet.Find(1002); if (FoundPtr ! nullptr) { // 找到了FoundPtr 指向集合内的元素 }这里有个非常重要的细节Find 返回的是指针。这个指针直接指向 TSet 内部的元素存储区。你可以通过这个指针修改元素的值但修改前必须想清楚哈希值是否变了。举例TSetFName Tags; Tags.Add(TEXT(Boss)); FName* TagPtr Tags.Find(TEXT(Boss)); *TagPtr TEXT(Elite); // 危险FName 的哈希值变了但哈希桶的位置没有更新结果就是集合内部出现了“哈希值对应不上的元素”下次 Contains 可能找不到它遍历时它又存在集合的完整性彻底坏了。所以 Find 返回的指针建议只读不要直接改值。真要改值先 Remove 再重新 Add。再强调一个细节Find 在找不到元素时返回 nullptr不是返回默认值。很多新手会把 Find 当 TArray 的下标访问用顺手解引用然后崩溃排查半天才发现是空指针。我对新的开发者通常建议只判断在不在用 Contains既要判断又要拿数据用 Find拿到指针后优先考虑只读操作。这个习惯能省掉不少诡异 bug。3.4 Array()TSet 与 TArray 的桥梁但引用有坑TSet 为了方便与 TArray 交互提供了 Array() 方法TSetint32 LevelUnlocks; LevelUnlocks.Add(1); LevelUnlocks.Add(2); LevelUnlocks.Add(3); TArrayint32 UnlockArr LevelUnlocks.Array(); // 或者用引用 TArrayint32 UnlockRef LevelUnlocks.Array();注意Array() 返回的是 TSet 内部元素存储区打包成的临时 TArray这个 TArray 的排序不保证与插入顺序一致尤其经历过多次删除和插入后顺序可能和预期有出入。还有一个很隐蔽的坑Array() 返回的引用在下次修改 TSet 后可能失效。你先拿到了引用然后 Add 或 Remove 一个元素之前那个引用指向的内存内容可能已经变了。所以最佳实践是把 Array() 的结果立即拷贝出来用于后续处理不要长期持有引用。如果你只是想把 TSet 的数据传给一个接受 TArray 参数的函数直接用 Array() 拷贝一次成本不高但思路清晰。数据量大的时候再考虑用TSet::TSet(TArray)的构造函数做批量初始化。3.5 SortTSet 也能排序但本质是个拷贝操作TSet 本身是无序的但 UE 提供了 Sort 方法让你得到一个“逻辑上有序”的集合MySet.Sort([](const int32 A, const int32 B) { return A B; });这个 Sort 做了什么源码上是把元素拷贝到一个临时数组排序后再重新构建整个 TSet。等于说你在 TSet 上做 Sort底层实际是“Array()、排序、重建集合”三步。所以它的开销比 TArray 的 Sort 大不少。数据量大时这个操作的性能衰减很可观。如果你只是要一个有序的数组建议直接TArrayint32 SortedArr MySet.Array(); SortedArr.Sort();这个思路更直接也不会破坏 TSet 的内部状态。排序这个操作放在 TArray 上做永远比放在 TSet 上做顺手。3.6 运算符 赋值与 [] 访问的真实情况先说赋值。TSet 的默认做的是深拷贝也就是把源集合的所有元素复制一份目标集合与源集合完全独立。TSetint32 Source; Source.Add(1); Source.Add(2); TSetint32 Target; Target Source; // 深拷贝 Target.Add(3); // Source 仍然是 {1, 2}Target 是 {1, 2, 3}这块和 TArray 的行为一致。如果你不想拷贝只想共享数据就存 TSet 的引用或指针。很多新手在类成员里直接复制 TSet 导致性能问题值类型拷贝的开销可能非常大尤其是集合里装的是复杂对象时。我的建议类的成员变量用引用传递或指针传递只有明确需要快照时才做值拷贝。再说[]。这是最容易让人误会的点TSet 不支持 operator[]。你写MySet[0]会直接编译报错。原因前面提过哈希集合的元素位置不是稳定的数组下标没有语义可言。如果标题里看到了[]我的理解是大多数人想确认 TSet 能否像 TArray 那样按下标取值。答案是不能。你要想通过“下标”访问 TSet对不起请转 TArray。顺便提一句TMap 里[]的行为也特殊它是按 Key 查找并返回 Value 的引用Key 不存在时会插入默认值。这是 TMap 的用法不是 TSet 的内容。别把这两个容器搞混。3.7 Reserve预分配容量性能优化的杀手锏TSet 的 Reserve 用于预分配哈希桶和存储空间TSetint32 SpawnedActors; SpawnedActors.Reserve(1000);这行代码提前告诉容器接下来你大概要容纳 1000 个元素。容器会一次性分配足够的桶和存储空间避免后续 Add 时反复扩容。哈希表扩容不是简单的内存复制而是对所有已有元素重新计算哈希、重新放入新桶这是个相对昂贵的操作。什么时候该调 Reserve两种典型场景你明确知道数据量上限比如“所有玩家 ID”“关卡内所有可交互对象”。你要在循环里批量插入大量元素提前 Reserve 能显著减少扩容次数。要注意Reserve 过大会浪费内存。假设你 Reserve(100000) 最后只塞了 10 个元素那底层就多了一大块闲置内存。移动端上内存是硬指标别拍脑袋写一个大数值先估算再预分配。另外有个冷知识TSet 的扩容阈值不是满员才扩而是当负载因子达到一定比例时提前扩容。UE 内部大概在确定的比例下触发 rehash所以即便你 Reserve 的数量刚好等于元素数量也可能因为负载因子触发新一次的容量调整。预留时留点余量会更好。4. 实操场景一个 AI 感知去重系统的完整实现4.1 需求分析与数据结构设计假设你要做一个 AI 感知系统。AI 每帧从不同感知源视觉、听觉、伤害来源收到“我感知到了某个目标”的事件。同一帧内同一个敌人可能被多个感知源上报你不能因此触发 3 次相同的反应逻辑。我们需要一个数据结构在当前帧内收集所有感知到的敌人 ID帧末统一处理。这个需求用 TArray 也能做但每帧都要做 Contains 去重随着场上敌人数量增长性能压力越来越大。换成 TSet 后去重是天然的每个 ID 插入时自动过滤重复值。定义一个结构体存放感知结果USTRUCT() struct FPerceptionRecord { GENERATED_BODY() UPROPERTY() int32 TargetID; UPROPERTY() float LastSeenTime; UPROPERTY() FVector LastKnownLocation; };然后在 AI 控制器里维护一个 TSetUPROPERTY() TSetint32 PerceivedTargetsThisFrame;4.2 帧内收集与去重敌人报到的时候void AAIController::ReportTargetSeen(int32 TargetID, const FVector Location) { // 先检查是否已经是本帧感知过的目标 if (!PerceivedTargetsThisFrame.Contains(TargetID)) { // 插入集合自动去重 PerceivedTargetsThisFrame.Add(TargetID); // 在这里更新感知记录表 FPerceptionRecord Record PerceptionRecords.FindOrAdd(TargetID); Record.TargetID TargetID; Record.LastSeenTime GetWorld()-GetTimeSeconds(); Record.LastKnownLocation Location; } }Contains Add 的组合看起来是两次查找第一次判断第二次插入但 TSet 的 Add 内部本身会先查找一次所以这里可以优化成只用一次查找void AAIController::ReportTargetSeen(int32 TargetID, const FVector Location) { int32* ExistingID PerceivedTargetsThisFrame.Find(TargetID); if (ExistingID nullptr) { PerceivedTargetsThisFrame.Add(TargetID); // 更新记录 } }Find 返回 nullptr 表示不在集合中后续 Add 必然成功。这个写法少一次哈希计算热点逻辑里这点优化也值得养成习惯。帧末处理void AAIController::Tick(float DeltaSeconds) { Super::Tick(DeltaSeconds); // 处理本帧所有感知目标 for (int32 TargetID : PerceivedTargetsThisFrame) { HandlePerceivedTarget(TargetID); } // 清空集合为下一帧做准备 PerceivedTargetsThisFrame.Empty(); }这里注意 Empty() 没有传参数下一次 Tick 时集合保留了之前分配的内存后续 Add 不需要重新开辟空间这个细节能在高频循环里省下不少分配开销。4.3 批量插入与 Reserve 配合假设一帧内感知事件的上限大约是 50 个在 AI 控制器初始化时PerceivedTargetsThisFrame.Reserve(64);多留 14 个余量避免负载因子触发额外扩容。你说这优化重要吗单次操作不明显但 AI 系统往往有几十上百个 AI 同时运行每帧都做集合操作积少成多还是能看出差距的。4.4 排序与转数组如果要按“最先看到的顺序”处理目标TSet 本身不能保证这个顺序你需要在帧末把集合转成数组再排序TArrayint32 SortedTargets PerceivedTargetsThisFrame.Array(); SortedTargets.Sort([](const int32 A, const int32 B) { // 按感知记录里的时间排序 const FPerceptionRecord* RecA PerceptionRecords.Find(A); const FPerceptionRecord* RecB PerceptionRecords.Find(B); if (RecA RecB) { return RecA-LastSeenTime RecB-LastSeenTime; } return false; });转数组时用拷贝因为后续每帧都要清空原始集合如果你用了引用还正好把它存下来下一秒 Empty 之后引用指向的内容就完全变了。这个坑我踩过一次调试时发现数据莫名其妙丢失最后定位到是引用的生命周期问题。5. 常见问题与实操避坑5.1 迭代器失效的那点事TSet 在遍历过程中删除当前元素大多数情况下不会立刻崩溃——因为它的内部存储是数组式的迭代器可能指向一个空闲槽。但这属于“碰巧没崩”不是“安全”。删除元素后继续迭代后续遍历顺序会变得不可预测甚至可能重复访问某些元素。正确做法TArrayint32 ToRemove; for (int32 Val : MySet) { if (ShouldRemove(Val)) { ToRemove.Add(Val); } } for (int32 Val : ToRemove) { MySet.Remove(Val); }5.2 自定义类型的哈希支持碰到下面的编译错误No matching function for call to GetTypeHash说明你的自定义结构体没有实现哈希函数。UE 为大多数内置类型和常用类型提供了默认实现但 USTRUCT 不是自动的。正确做法USTRUCT() struct FInventoryItemID { GENERATED_BODY() UPROPERTY() int32 ItemID; UPROPERTY() int32 InstanceID; bool operator(const FInventoryItemID Other) const { return ItemID Other.ItemID InstanceID Other.InstanceID; } }; FORCEINLINE uint32 GetTypeHash(const FInventoryItemID InKey) { return HashCombine(GetTypeHash(InKey.ItemID), GetTypeHash(InKey.InstanceID)); }HashCombine 是 UE 提供的哈希组合函数返回的哈希值质量不错分布也均匀。网上有些实现用移位异或组合能跑但碰撞率偏高哈希质量差会让 TSet 退化成链表性能急剧劣化。能用 HashCombine 就尽量别自己发明轮子。5.3 空指针问题Find 返回空指针时要先判空再解引用。我见过太多人写出这种代码MySet.Find(EnemyID)-SomeValue 1; // EnemyID 不存在时崩溃建议写代码时有意识检查返回值不要图省事。5.4 序列化与跨版本兼容TSet 支持 UPROPERTY 标记能通过 UE 的序列化系统存盘。但要注意TSet 的序列化顺序不是哈希桶顺序而是元素存储顺序。这意味着不同版本之间如果改了哈希函数或添加了删除操作序列化结果可能会有差异。做存档系统时最好把 TSet 转成 TArray 再存确保数据格式稳定。5.5 关于内存收缩频繁 Add 再 Remove 会让 TSet 的底层数组留下很多空洞内存不会自动释放。如果你确定集合之后要长期保持较小规模调用Shrink()整理空间。不过 Shrink 也不是免费的它会触发内存重排耗时与元素数量成正比别在高频 Tick 里调用。UE 容器全家桶的文档没有把这个 API 讲透我看到过不少项目因为在 Update 函数里调 Shrink 导致卡顿真遇到了先查这里。6. 我对 TSet 的整体体会用了一段时间的 TSet 之后我给它的定位是“判重场景的首选容器”。不需要为它写复杂的排序或遍历逻辑把它当成一个高效的“无重复集合”工具配合 TArray 处理顺序需求就非常顺手。如果让我只给一个建议从今天开始凡是你写的 TArray 里如果每次插入前都要先 Contains 检查一遍请把它换成 TSet。这一条改动可能比你看十篇容器性能文章都更管用。回到开头那个背包例子我后来又把系统扩展成了“已解锁成就”“已读消息”“拥有过的装备”等多个集合全部用 TSet 存 ID判重逻辑通畅多了。UE 提供这套容器不是让你炫技而是让你的代码复杂度模型和数据规模匹配起来。理解了哈希表、理解了每个 API 的语义边界你就能在 TArray、TSet、TMap 之间做出合理选择而不是永远只用最熟悉的那一个。最后分享一个小技巧调试 TSet 内容时Visual Studio 的调试器里可以直接展开 Intersection 和迭代状态也可以在 UE 的控制台用Obj List观察对象引用。但如果在运行时想快速打印集合内容写一个短小的转数组函数比自己盯着调试窗口方便得多。等你真正用到 TSet 做一些高频逻辑时一定会感谢自己提前理解了这些底层行为。