ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

UE5 C++ TSet容器完全指南:哈希集合的增删查改与性能实践

UE5 C++ TSet容器完全指南:哈希集合的增删查改与性能实践 做UE5 C项目容器是每天都要打交道的东西而TSet绝对算得上最容易被低估的一等公民。我自己在项目里见过太多类似这样的代码一个TArray每次插入前先Contains扫一遍数据量小的时候没什么感觉一旦涨到几千条或者某个被频繁调用的函数里每帧都在做这种线性查找插入卡顿就在所难免。TSet就是UE专门为无序、唯一、快速查找场景准备的哈希集合容器这篇把它的常用成员函数完整过一遍Add、Emplace、Remove、Empty、Num、Contains、Find、Array()、Sort、赋值、[]、Reserve每个函数都讲清楚返回值和行为边界再补几个我在实际工程里踩过的坑。这篇文章适合正在学UE5 C、搞不清楚TArray/TMap/TSet该选谁的开发者也适合有项目经验但只把TSet当去重版TArray用的人。我会按增—查—删—统计—数组化—内存这条线来讲尽量避开官方文档那种API手册式写法改成实际工程里的使用场景来说。1. 为什么选TSet哈希集合在UE5容器家族里的定位1.1 从一段到处Contains的代码说起先看一段很典型的代码TArrayint32 ActiveIDs; void Activate(int32 NewID) { if (!ActiveIDs.Contains(NewID)) { ActiveIDs.Add(NewID); } }这段逻辑本身没有错但它有两个隐患。第一TArray::Contains是线性查找最坏情况要遍历整个数组数据量上去之后开销是肉眼可见的第二你必须在每次插入前手动维护不重复这个约束漏一次就会出现脏数据。TSet从设计上就解决了这两个问题。它底层是哈希表把查找、插入、删除都做到了平均O(1)的复杂度同时用集合语义保证了元素唯一性你不需要自己写判重逻辑。在UE5 C里当你要面对的是一堆不能重复的元素时优先考虑TSet而不是TArray往往是最省心的选择。1.2 TSet与TArray、TMap的选型对比很多新手会把三个容器放在同一个维度里比较其实它们的定位完全不同。我把它们在典型需求下的差异整理成一张表需求TArrayTSetTMap是否允许重复允许不允许键不允许值允许遍历顺序稳定按添加顺序无序无序查找复杂度O(n)二分需先排序平均O(1)平均O(1)能否按索引访问可以不可以不可以典型使用有序列表、堆栈集合、去重、快速判存键值映射关键要抓住无序不重复这两个限定。TSet不保证遍历顺序今天遍历出来的顺序和昨天可能完全不一样所以如果你需要按照添加顺序处理这种逻辑千万不要用TSet。反过来如果你只关心在不在有没有重复TSet的性能优势是TArray补不上的。还有一个很重要的前提TSet内部依赖元素类型的GetTypeHash函数和operator运算符。前者决定元素被放进哪个哈希桶后者在哈希值相同时判断两个元素是否真的相等。UE内置的整数、浮点、FString、FName等类型都有现成的支持直接就能放自定义结构体需要手动补上这两个能力这个我在后面专门讲。2. 插入Add、Emplace 的正确打开方式2.1 Add 的返回值是双通道信息如果你把TSet当作不需要判断重复的TArray那就亏大了。TSet的Add不是单纯塞进去它会返回两个信息这次插入是否真的发生以及元素最终在集合里的位置。在UE5中Add的返回类型是TTupleFElementType*, bool早期版本是TPair用法是这样TSetFString Tags; Tags.Add(TEXT(Player)); auto [ElemPtr, bInserted] Tags.Add(TEXT(Player)); if (!bInserted) { UE_LOG(LogTemp, Warning, TEXT(元素已存在没有重复插入)); }bInserted为false说明集合里已经有这个字符串。用结构化绑定接住返回值在注册类场景里非常顺手。比如往集合里登记一个物体ID返回false说明之前已经登记过可以直接走重复处理分支。这里要强调TSet没有AddUnique函数因为Add本身就是唯一的。你也不需要像TArray那样在Add前先用Contains检查一遍Add内部会自己判断相等性。if (!Set.Contains(X)) { Set.Add(X); }这种写法在TSet里完全是多余操作多了一次哈希查找不说代码还更啰嗦。2.2 Emplace原地构造减少一次拷贝Emplace的作用是在集合内部直接构造元素而不是先构造一个临时对象再拷贝或移动进去。对int32这种轻量类型你感受不到差别但如果元素是FString、TSharedPtr或者你自己定义的重型结构体Emplace能省掉一次不小的拷贝开销。举个例子struct FPlayerInfo { FString Name; int32 Level; TArrayFString Achievements; FPlayerInfo(const FString InName, int32 InLevel) : Name(InName), Level(InLevel) { } }; TSetFPlayerInfo Players; Players.Emplace(TEXT(Alice), 10); // 直接传构造函数参数 // 对比写法 // FPlayerInfo Temp(TEXT(Alice), 10); // Players.Add(Temp); // 多一次临时对象拷贝Emplace还有一个隐藏行为如果集合里已经有等价的元素Emplace不会真的构造新对象返回的是已存在元素的引用。这一点与Add的唯一性原则一致。需要注意的是如果你的构造函数有副作用那么副作用只有在元素真正插入时才会发生。以我自己的习惯FString、结构体这类有非平凡构造逻辑的元素类型插入时优先用Emplaceint32、枚举这类轻量值Add和Emplace几乎没有差别按代码可读性来选就行。3. 查找与删除Contains、Find、Remove 的行为细节3.1 Contains 与 Find 的分工Contains是TSet里最简单的存在性判断返回bool平均时间O(1)if (MySet.Contains(Player)) { // 处理已存在的情况 }如果只需要知道在不在用Contains就足够了。但如果想知道元素在集合里的具体内容——比如你存的是结构体查询时只有一个相等的临时副本想拿到集合里那份原始数据——就需要Find了。auto Result MySet.Find(TEXT(Player)); if (Result) { // Result 指向集合内部元素 }这里有一个版本差异问题值得提醒。UE5.0之后TSet内部存储改成了TSetElement包裹Find返回的类型在不同小版本里可能表现为FElementType*或TSetElement*直接赋给FString*在部分版本会编译报错。如果你不想为版本差异操心有两个更省心的替代方案FindChecked(Key)找到元素并返回引用找不到直接触发check失败相当于断言崩溃。适合按理说一定存在的场景。FindRef(Key)按值返回元素副本找不到返回默认构造值。适合找不到就当作默认处理的场景。FString Value MySet.FindRef(TEXT(Player)); // 找不到返回空字符串3.2 Remove 的返回值为什么是 int32TSet的Remove函数签名是int32 Remove(const FElementType InElement);返回的是被移除的元素数量。由于集合元素唯一这个值正常情况下只有0或10表示没有这个元素。这样的设计有个好处你可以直接把Remove返回值当bool用比如成功删除了某个ID再执行后续逻辑。if (MySet.Remove(DeadID) 0) { // 说明 DeadID 确实在集合中并且已经被移除 }删除同样走哈希查找所以Remove本身也是O(1)。删除元素只是让元素失效并从集合计数中扣除底层哈希数组占用的内存不一定立即归还这一点和第4部分要讲的Empty(bShrink)属于同一类内存管理问题后面会展开。3.3 迭代器失效与 RemoveIf 批量删除遍历TSet时删除元素是最容易翻车的操作之一。直接写for (const FString S : MySet) { MySet.Remove(S); // BAD迭代器失效可能导致崩溃或漏删 }集合在移除元素后内部桶和稀疏数组的布局会变化当前迭代器访问到失效位置就是未定义行为。正确做法之一是把要删的元素先收集起来遍历结束后统一删TArrayFString ToRemove; for (const FString S : MySet) { if (S.StartsWith(TEXT(Temp))) { ToRemove.Add(S); } } for (const FString S : ToRemove) { MySet.Remove(S); }更省事的是直接上RemoveIf它在实现上已经处理了遍历稳定性int32 RemovedCount MySet.RemoveIf([](const FString S) { return S.StartsWith(TEXT(Temp)); });这个函数返回移除数量谓词返回true就删。我自己的习惯是删除条件能写成一句话优先用RemoveIf要处理复杂的跨容器逻辑再退回收集删除两步走。4. Num 与 Empty统计和清空的隐藏知识点4.1 Num 在遍历中会骗人Num返回当前集合元素个数O(1)消耗没有任何遍历成本。新手在遍历时容易写出这种代码for (int32 i 0; i MySet.Num(); i) { // 试图按 i 访问集合 —— 这本身就有问题 }这里有两层问题。第一TSet不能按索引访问第5部分会详细说第二如果循环体内有Add或RemoveNum的值会动态变化循环次数和预期就对不上了。老老实实用范围for或迭代器遍历不要在循环条件里依赖Num。Num的常见用途是配合Reserve做内存预留先确定大概要放多少元素MySet.Reserve(ExpectedCount);之后插入时集合的扩容频率会平滑很多。另外一个使用场景是UI统计比如做成就进度条时频繁读取Num刷新界面TSet的O(1)统计在这种场景下非常舒服。4.2 Empty(bShrink)清空不等于释放内存TSet的Empty函数签名是void Empty(int32 ExpectedNumElements 0);调用MySet.Empty()会移除全部元素集合变为空。如果传入期望元素数量它会顺便把内部哈希桶调整到适合该数量的容量为接下来的复用做准备。这里必须强调Empty之后集合占用的内存不一定全部还给系统。哈希表为了下次插入更快经常保留底层缓冲区。如果你在关卡切换这种内存敏感时机清空一个大集合又希望把内存真正释放掉就要主动传0或较小的值或者干脆让集合在局部作用域里析构。反过来如果这个集合马上要重新填充几百个元素用Empty(500)预留容量能避免反复插入带来的rehash开销。说到清空还要区分两个函数Empty()是动手清空IsEmpty()是状态判断。IsEmpty()等价于Num() 0但语义更清晰。标题里的empty我理解是这两个都要讲清楚一个负责做一个负责问。4.3 关卡切换时的内存治理经验我自己的一个真实经历项目里用TSet保存当前场景所有可交互物体的ID切关卡前调用Empty()结果切换后内存占用没有明显下降反而是之后新场景加载时出现了短暂卡顿。排查后才发现Empty只是清空了逻辑元素底层哈希桶还是按旧数据量级占着内存。后来我在关卡卸载流程里改成Set.Empty(0)加上Set.Shrink()如果确定不再复用就直接让集合出作用域析构内存才真正降下来。如果你在内存分析器里看到TSet占用虚高先别急着怀疑泄漏多半是哈希表缓冲区没归还。5. Array() 与 Sort集合数组化的正确姿势5.1 Array()一份带开销的复制TSet是无序的但很多场景需要把集合内容交给一个要求TArray参数的接口或者需要按下标访问。这时可以调用Array()TSetint32 LevelIDs {1001, 1002, 1003}; TArrayint32 IDArray LevelIDs.Array();需要明确的是Array()在UE5中返回的是TArrayFElementType按值返回内部会遍历所有有效元素做一次拷贝或移动。这意味着时间复杂度是O(N)还有一次内存分配返回的是副本修改这个TArray不会影响原集合不要每帧调用也不要写MySet.Array().Sort()这种连招——它排序的是临时副本排完就没了。如果业务逻辑需要频繁在集合和数组之间转换建议在集合变化后用一次Array()生成缓存后续直接操作缓存数组而不是每次用到时现转。5.2 TSet 没有 Sort排序得靠 TArray这是很多从STL转过来的开发者容易踩的坑。你可能会习惯性地写MySet.Sort(...)但TSet根本没有Sort成员函数。原因其实很好理解集合底层是哈希桶元素分布由哈希值决定集合自身就不维护任何顺序概念提供Sort在语义上是自相矛盾的。真要排序标准做法是先转TArray再排TArrayint32 SortedIDs LevelIDs.Array(); SortedIDs.Sort();对于结构体数组可以在Sort里传Lambda自定义排序规则。排序结果只作用在TArray副本上不会反过来影响TSet因为TSet本来也没有顺序可以被影响。如果确实需要既快速判重又保持插入顺序我一般会并行维护一个TArray和一个TSetTSet负责判重和查找TArray负责顺序遍历这是目前最实用的组合方案。5.3 为什么TSet不支持 [] 下标访问标题里列了[]这里必须把话说清楚TSet和TMap一样没有operator[]。哈希表里的下标概念和数组完全不同——桶位置由哈希值决定元素在稀疏数组中的存储位置是内部实现细节两者都不是稳定的线性序号。所以MySet[0]这种写法根本无法编译。看到这里你可能会想那TMap不也有[]吗TMap的operator[]是用键取值本质上是一次哈希查找和数组下标是两回事。而TSet里没有键值分离的概念元素本身就是键不存在根据键取对应值的操作所以不需要提供[]。如果你真想按某个顺序拿元素思路只有两个用Array()转数组后按下标访问或者维护TArray与TSet并行。6. 赋值 与 Reserve拷贝生命周期与容量预留6.1 operator 的深拷贝与移动语义TSet支持operator赋值。默认的等号赋值会执行深拷贝也就是把右值的所有元素复制到左值集合源集合保持不变TSetint32 SetA {1, 2, 3}; TSetint32 SetB; SetB SetA; // 深拷贝SetA 不变如果显式用MoveTemp做移动赋值SetB MoveTemp(SetA);SetA的元素会被转移给SetBSetA通常被置为空或处于未指定状态不要再依赖它。移动赋值的优势是省去一次批量拷贝适合在元素较多的集合之间转移归属的场景。这里有一个容易被忽略的点等号赋值会清空SetB原有内容并重新调整内部哈希桶大小。如果你频繁用一个大集合反复赋值给另一个集合拷贝开销不可忽视。如果两个集合的内容经常需要保持同步更好的做法是让它们共享同一份数据用引用或指针持有同一个TSet或者用批量Add/Remove做差量更新而不是整表赋值。6.2 Reserve减少rehash卡顿Reserve的作用是在插入大量元素前预先给哈希表分配足够的桶。哈希集合内部有两个数组存储元素的稀疏数组和记录桶位置的哈希桶数组。当元素数量增长到超过负载因子时哈希表需要扩容并rehash所有已有元素都要重新计算桶位置这是一次O(N)的重操作。如果发生在游戏运行的关键帧可能造成明显卡顿。TSetint32 BigSet; BigSet.Reserve(10000); // 预计放10000个 for (int32 i 0; i 10000; i) { BigSet.Emplace(i); }这段代码如果去掉Reserve在扩容边界处会触发若干次rehash每次都要搬运已有元素加上Reserve后直接一次分配到位。我在创建集合时都会先评估数据量级只要不是特别小基本都会习惯性写Reserve。要注意的是Reserve只是一个容量提示不代表集合最多只能放这么多。超出后哈希表依然会自动扩容只是性能会暂时下降。另外Reserve对元素类型没有要求只影响容量。6.3 自定义结构体进TSet补上哈希和等号最后补充一个实操性很强的知识点。想让自定义结构体放进TSet下面两样缺一不可operator和GetTypeHash。USTRUCT() struct FItemTag { GENERATED_BODY() UPROPERTY() FName Type; UPROPERTY() int32 Level 0; bool operator(const FItemTag Other) const { return Type Other.Type Level Other.Level; } }; FORCEINLINE uint32 GetTypeHash(const FItemTag Tag) { return HashCombine(GetTypeHash(Tag.Type), static_castuint32(Tag.Level)); }GetTypeHash要放在全局作用域让编译器通过参数依赖查找ADL找到它。哈希值决定元素进入哪个桶operator处理哈希冲突后的精确比较。如果只定义operator而忘记GetTypeHash代码会编译失败如果哈希函数写得太粗糙比如所有对象都返回同一个值集合会退化成链表式查找O(1)变成O(n)性能优势全部丢光。HashCombine是UE提供的经典哈希合并工具推荐优先使用。关于TSet的成员函数我想到的大概就是这些。最后分享两个小经验一是用TSet时把判重这件事交给容器本身不要在业务代码里到处写ContainsAdd的组合二是涉及大批量插入或删除时多想一步Reserve和RemoveIf它们往往比手写循环更稳、更快。如果你在项目里遇到集合明明用了Contains却还是卡这类问题回头检查一下自定义类型的GetTypeHash是不是写得太弱或者数据量是不是早该Reserve了。
返回列表