UE5 C++字符串与容器全解析:FString、FName、FText与TArray、TMap底层原理 做 UE5 C 开发容器和字符串这两块绕不过去。项目做大了每次新建一个类、存一份配置、调一次接口都要跟它们打交道。我最早是从 std 那套东西切过来的一开始真被 UE 的命名搞晕过——明明都是字符串为什么有 FString、FName、FText 三个容器也是TArray 看着像 vector实际用起来却多了不少脾气TMap 的键值对到底存了个什么东西内部继承链是怎么走的查过源码才彻底明白。这篇就把这三个系列的用法和底层逻辑一次性讲透。1. FString、FName、FText三种字符串不是让你随便选的很多新手第一个困惑就是UE5 里字符串怎么这么多我早期也图省事到处用 FString编译能过跑起来也没报错但项目一上规模问题就来了——内存疯涨、查找变慢、本地化文本全部失效。后来认真捋了一遍这三种类型压根是给不同场景准备的。1.1 FString负责“加工”是真正的动态字符串FString 本质就是一个 TArray 包装的 ANSI 或 UTF-16 字符缓冲区支持拼接、替换、查找、切割、格式化几乎所有需要运行时“处理字符串内容”的操作都靠它。它会在堆上动态分配内存字符串越长、操作越多分配和拷贝的开销就越大。在蓝图里看到的所有白底字符串引脚绝大多数都是 FString 或下面要说的 FText而 C 里接受字符串参数的接口也常常是 FString。比如从文件读配置、解析 JSON、拼一个日志输出这些场景非它不可。FString RawData TEXT(namezhangsan;level99); FString LeftPart, RightPart; if (RawData.Split(TEXT(;), LeftPart, RightPart)) { UE_LOG(LogTemp, Log, TEXT(key part: %s), *LeftPart); }注意*LeftPart这个取地址符不能省UE_LOG 的%s需要的是 TCHAR*不是 FString 对象本身。这是最容易被绊倒的地方后面细说。1.2 FName负责“标识”查得快、占得少FName 的设计目标就是“轻量标识符”。它内部是一个不区分大小写的哈希索引指向一张全局字符串表。同一个字符串在程序里出现一千次实际内容只存一份每个 FName 只保存一个静态索引和几个比较用的附加位。所以 FName 的拷贝、比较、哈希都极快几乎是常数时间。但 FName 有两个致命限制一是不建议在运行时拼字符串改内容虽然技术上允许但会产生表项泄漏式的累积而且设计哲学上就不该这么干二是它不区分大小写做用户输入、密文、路径判别时要特别小心。适合它的场景是Actor 的 Tag、Socket 名称、动画曲线名、资产行名、状态枚举名——这些字符串生命周期长、重复率高、主要靠“相等判断”来用。蓝图里选中很多节点时弹出的搜索框、引擎内部各种名字表基本都是 FName。1.3 FText负责“展示”为本地化而生FText 要解决的是“给玩家看什么”。它内部会被标记为“已本地化”并且可以携带源语言文本和命名空间。运行时从文本表里根据当前语言重新查找对应翻译。最典型的创建方式是NSLOCTEXT或LOCTEXT宏蓝图里文本引脚默认就是 FText。注意FText 不能直接拼变量。C 里拼本地化文本要用FText::Format配合FText::FromString把动态内容转进去。这个限制绕不开否则本地化校验会出问题。举例来说模拟项目X里我们要做一个弹窗提示“玩家 xxx 获得了 xx 金币”。如果用 FString 直接拼翻译工具扫描时根本拿不到“玩家{0}获得了{1}金币”这个模板本地化就废了。正确做法是FText PlayerName FText::FromString(SomeNameString); FText CoinText FText::FromString(FString::FromInt(CoinCount)); FText Message FText::Format( LOCTEXT(CoinReward, Player {0} got {1} coins.), PlayerName, CoinText );1.4 三者的转换代价和使用边界转换不是免费的。FString 转 FName 要查一次全局表转 FText 要做一次本地化评估。反过来FName 转 FString 可以拿到真实字符FText 转 FString 则会丢失本地化信息。开发阶段偶尔转换没问题但不要在热路径或每帧调用里反复转。类型底层结构比较方式适用场景不适用场景FString动态字符堆缓冲逐字符运行时拼接、解析、IO高频比较、大量重复标识FName全局表 索引索引比较Tag、Socket、资产名动态文本、大小写敏感存储FText本地化文本封装按本地化结果UI 展示、多语言无本地化需求的纯内部逻辑我在实际项目里总结了一条铁律给玩家看的走 FText给人做标识的走 FName给机器做处理的走 FString。这条规则哪怕不理解细节先照着用也能少踩百分之八十的坑。2. TArray看着像 std::vector底子和脾气都不太一样TArray 是 UE 项目里用得最多的容器接口和 std::vector 相似度极高push_back 对应 Addsize 对应 Num[] 运算符都在。但如果你真把它当 vector 用迟早会踩到几种奇特的坑。2.1 内存模型不是散落节点的链表是一整块可增长堆内存TArray 之所以“快”就是因为它把元素存在一块连续内存里迭代时缓存命中率高随机访问是 O(1)。它内部有三个核心字段指向堆内存的指针Data、元素个数ArrayNum、最大容量ArrayMax。Add 一个新元素时如果容量不够它会按一定规则申请更大内存、把旧元素移过去、释放旧内存。这个过程和 vector 的扩容逻辑没有本质区别都是摊还 O(1)。但 UE 对“移动”这件事要求更严格。如果你的元素类型没有定义移动构造函数扩容时会做深拷贝那代价就大了。因此自定义结构体放进 TArray 之前最好实现移动构造和移动赋值或者至少确保里面嵌套的都是可高效移动的容器。2.2 元素类型到底能不能放不完整类型和引用TArray 里的元素必须是“完整类型”不能放引用因为引用不是 object不能作为容器元素。这个和 std::vector 一样。但 TArray 可以放 TArray、TMap、TSharedPtr、TWeakObjectPtr、UClass* 这类引擎类型只要它们是可拷贝/可移动的。还有一点和 vector 很不一样TArray 允许元素是const成员吗不行。任何const TArrayconst int32这种写法都不建议等于自己把自己锁死。老老实实用const TArrayint32这种引用传递方式。2.3 常用的查找和删除Remove、RemoveAt、RemoveSwap 的区别这是 TArray 比 vector 多出来的实用接口也是新手比较容易搞混的Remove(Elem)把与 Elem 相等的所有元素移除并保持剩余元素相对顺序。RemoveAt(Index)移除指定索引的元素后续元素向前挪。RemoveAtSwap(Index)先把这个位置用最后一个元素补上再缩短数组。速度快但顺序会变。如果你的数组不需要保持顺序尽量用RemoveAtSwap它才是真正的 O(1) 操作。RemoveAt涉及大量元素移动O(n) 级别。TArrayint32 Values {1, 2, 3, 4, 5, 2}; Values.Remove(2); // {1, 3, 4, 5}所有 2 都被移除 Values.RemoveAtSwap(0); // {5, 3, 4}最后一个元素补到 0 位置另外Find系列返回的是指针而不是索引。如果你只是判断存在用Contains需要拿位置修改用IndexOfByKey或直接Find拿指针改。2.4 Empty、Reset、Shrink 是三个完全不同的行为很多人以为 Empty 和 Reset 差不多其实差远了Empty()释放所有元素内存数组回到空状态容量也可能变成 0。Reset()保留容量只把元素析构后续 Add 不用重新分配。Shrink()把容量缩到和元素数一样释放多余内存。在循环里反复创建的临时数组优先用Reset()复用容量能省下大量分配操作。我做过一次简单计时同样一万次循环往 TArray 里 Add 固定数量的 int32用 Empty 的方式比用 Reset 慢了三倍以上原因就是 Empty 每次都要重新走一遍内存分配。2.5 迭代器失效和范围 for 的隐藏翻车点C 里常见的“迭代过程中删除当前元素导致迭代器失效”TArray 同样存在。范围 for 写法在删除元素时尤其危险它内部隐藏了迭代器。正确做法是反向迭代或用索引倒着删// 错误示范范围 for 里 RemoveAt 会翻车 for (int32 Value : MyArray) { if (Value 0) MyArray.RemoveAt(0); } // 正确示范倒序删除 for (int32 i MyArray.Num() - 1; i 0; --i) { if (MyArray[i] 0) MyArray.RemoveAtSwap(i); }如果只是“边遍历边筛选”更推荐用RemoveAll它专门干这个内部高效且不用手写循环MyArray.RemoveAll([](const FString S) { return S.IsEmpty(); });3. TMap 和 TPair键值对的存储真相与继承链TMap 是 UE 的关联容器用键快速定位值。它内部不是红黑树也不是单纯的哈希表而是基于稀疏数组哈希表实现从使用角度更接近 std::unordered_map。但键值对的类型名 TPair 在官网文档里存在感很低不少人只看代码 TMapint32, FString根本不知道里面还套了一层 TPair 结构。3.1 TPair 到底是什么它的继承链怎么走TPair 就是 UE 对“一对键值”的封装类似 std::pair。它的定义核心是TPairK, V内部包含两个成员Key和Value。但早期版本的 UE 为了让 TPair 能放进各种容器做了一层比较绕的继承设计。简单说当 TMap 的模板参数确定后内部会生成一个继承链TPair 本身是一个基础对结构在 TMap 内部使用的其实是TPairK, V的派生形式名字大致是TMapKeyValuePair这类的内部结构这个内部结构用继承方式把TPairK,V作为基类包起来同时重载一些哈希和比较逻辑TMap 真正存储的元素是这个内部派生结构的实例而不是裸的 TPair。这样做的意义是键值对的数据布局在编译期就固定哈希计算、Key 比较可以用“基类指针转成 TPair”的视角去做而继承链让 TMap 内部可以挂自己的元数据或自定义函数不需要动 TPair 本体。我最早查这个继承链是遇到了一个报错模板参数里的 K 和 V 被要求支持GetTypeHash和operator。顺着报错往下翻才看到 TPair 在 TMap 的 ElementType 里被包了一层。如果你只是想写业务代码不理解继承链也能用 TMap但一旦要自定义键类型、做内存分析、或者写泛型工具就必须知道 ElementType 并不是 TPair 本身。3.2 TMap 的哈希Hash 层和索引层TMap 的底层划分成两层一层是哈希桶一层是元素数组。哈希桶里存的是指向元素数组的索引元素数组则是紧凑排列的 TPair 派生对象。Find 一个键时先对键算哈希定位桶然后在那个桶的小数组里做线性查找逐个比较键。因此对自定义键类型的哈希函数质量要求很高——如果所有键的哈希值都落同一个桶TMap 就退化成了链表性能直线下滑。自定义键类型的标准模板uint32 GetTypeHash(const FMyKey MyKey) { return HashCombine(GetTypeHash(MyKey.ID), GetTypeHash(MyKey.Name)); } bool operator(const FMyKey A, const FMyKey B) { return A.ID B.ID A.Name B.Name; }GetTypeHash和operator必须配套。哈希定了桶等号决定桶内哪个键匹配。只做哈希不写等号会出现“同一个键存在两个不同槽位”的诡异现象。3.3 Add、Emplace、FindOrAdd 的差别TMap 的插入接口看着多其实各有用途Add(Key, Value)先拷贝 Key 和 Value再放进 map。如果有同键会覆盖旧值。Emplace(Key, Args...)和 Add 类似但对 Value 是原地构造少一次移动。FindOrAdd(Key)如果键已存在返回引用否则用 Key 的默认值插入一个新项返回引用。FindRef(Key)如果键存在返回值的拷贝不存在则返回 Value 的默认构造值。如果你要判断“到底有没有”不能靠 FindRef因为它区分不了“值是默认值”和“键不存在”。我用FindOrAdd最多的地方是“按需求缓存统计结果”一笔数据算出来后先FindOrAdd拿引用再往 Value 里累加省掉两次查找。TMapFName, int32 ScoreMap; FName Key TEXT(Alice); int32 Score ScoreMap.FindOrAdd(Key); Score 10;这段代码只做了一次哈希查找比先 Contains 再 Emplace 高效许多。3.4 遍历 TMapstruct 解构和 Key/Value 分离C11 之后遍历 TMap 最舒服的写法是for (const TPairFName, int32 Pair : ScoreMap) { UE_LOG(LogTemp, Log, TEXT(%s : %d), *Pair.Key, Pair.Value); }UE5 较新的版也支持结构化绑定for (const auto [Key, Value] : ScoreMap) { // Key 和 Value 可直接用 }如果你的编译环境和引擎版本支持结构化绑定更直观。但要注意TMap 遍历顺序是不确定的不要依赖插入顺序或键顺序。需要保证顺序的场景要么用 TMap 额外维护一个 TArray 缓存键顺序要么换 TSortedMap 这类有序容器。3.5 TMap 和 TArray 组合的常见反模式我做模拟项目X 时遇到过一种写法用一个 TArray 存几十万个结构体再用一个 TMap 记录主键到数组下标的映射用于快速查找。本身这是合理设计但有人为了省事用 TMap 存整个结构体副本再存一份完整数组。数据一同步就是双倍内存而且两个容器的一致性要靠人肉保证一不小心就稀碎。正确姿势数组是权威数据源TMap 只存索引查询时拿索引回数组取数。这样删除元素时索引会失效要做“墓碑标记”或定期重建映射代价低于双写一致性。4. 字符串和容器混用时那些最容易忽略的坑字符串和容器单独用都不难混在一起才是重灾区。这里不是讲基础 API列几个我真实踩过、也见过同事踩的坑。4.1 用 FString 当 TMap 的键查询快但创建时抖一下FString 是可以直接当 TMap 键的因为 UE 已经给 FString 实现了GetTypeHash。问题在于每次向 TMap 里 Add 一个 FString 键都会发生一次字符串内存分配和内容拷贝。如果一个帧内做几百次这种插入GC 压力和堆分配都会上来。如果这个键是“固定枚举性质的标识”比如几组有限的状态名、动画通知名果断换成 FName。FName 键比较开销是索引级别几乎为零。但如果你的键是运行时的动态内容比如玩家输入的用户名那么用 FString 没问题因为动态字符串转 FName 本身就是一次全局表插入不划算。4.2 TMap 里 Value 是 TArray增删子元素要小心引用失效这个坑很隐蔽。你用FindOrAdd拿到的 Value 的引用随后往这个 Value一个 TArray里 Add 元素TArray 内部扩容会把旧内存释放这时你之前存的那个“指向 TArray 的指针”或者迭代器可能全失效。很多人会写TMapint32, TArrayFString Dict GetDict(); TArrayFString List Dict.FindOrAdd(ID); List.Add(TEXT(hello));这段代码安全因为每次都重新调FindOrAdd拿的是新鲜引用。危险的是TArrayFString List Dict.FindOrAdd(ID); // 之后又把别的键值对加进 Dict Dict.Add(AnotherID, AnotherArray); List.Add(TEXT(danger?)); // Dict 哈希层可能重构List 可能悬垂TMap 在插入新键导致哈希桶扩容时会重新分配内部存储之前拿出来的引用就失效。任何语言、任何哈希表都有这个问题C 里不会给你运行时保护只能靠规范规避先完成 map 的增删操作再拿引用去改子容器不要在持有一个 map 元素引用的同时插入其他元素。4.3 FText 和 TMap 的组合别乱来本地化键和显示值要分开有时候你想把界面文案缓存在 TMap 里键用 FText 直接当 Key。这在逻辑上说得通但它引入两个问题一是 FText 的哈希和比较基于本地化状态不同语言环境下的哈希结果可能变动二是每次拿 FText 查找都要先走一遍本地化评估性能不可控。更规范的设计是TMap 用 FName 当键Value 存 FText。FName 描述这条文案的逻辑 IDFText 是真正的显示文本。比如模拟项目X 里的任务描述缓存我都是TMapFName, FText配合表格读入切语言时整体重建 Value不怎么动 Key。4.4 容器默认值带来的“看似存在”心理陷阱int32 Count CountMap.FindRef(Key);如果 Key 不存在上面的Count拿到的是 0而业务上“真正的零”也是 0看起来完全正常。但如果 Key 的值本来就是“未知”状态就不该用 FindRef 当成“查到了”。我见过一个 bug玩家注册奖励判断奖励计数是否已存在用 FindRef 拿到的 0 被当成“已存在但数值为 0”于是跳过补发玩家损失奖励。正确写法是int32* CountPtr CountMap.Find(Key); if (CountPtr) { // 键确实存在 } else { // 键不存在 }Find返回原始指针为空就是不存在没有默认值歧义。这是 TMap 使用里最值得养成习惯的一个细节。5. 选型决策一张表和一串建议让你少走弯路技术点都讲完了最后给一套我常用的选型逻辑。它不是唯一正确答案但能覆盖大多数项目场景。需求推荐容器/类型原因高频率查找键固定TMapFName, T*/TMapFName, int32FName 哈希快键省内存运行时动态拼接如日志FString可读写、API 全面玩家可见文本FText本地化正确防止硬编码需要遍历且要顺序TArray连续内存、缓存友好键值对频繁插入删除且无需顺序TMap摊还 O(1) 操作小规模配置几十个键TMap 或 TArray 线性查找均可量小哈希优势体现不出来多个相同类型元素聚合TArray 优先比 TSet 更适合“携带顺序”一个值对应多组数据TMapK, TArray值和数组嵌套使用字符串选型再看一眼静态标识、比较频繁FName动态内容、要修改内容FString输出到界面、需要多语言FText三者在函数参数间传递时优先考虑用引用传递避免拷贝开销TArray 和 TMap 之外的扩展字符串当 Key 如果非要保留原字符串内容又想要哈希性能可以用TMapFString, T*, FStringSetKeyFuncs这种自定义分配器不是常规需求但存在。6. 算一笔实际账一个缓存模块的改造过程理论讲太多不如结合场景。模拟项目X 里有一个怪物刷新配置表早期用TArrayFMonsterInfo存全部配置查找通过遍历比对 ID。数据量只有几百条开发时觉得无所谓。后面前端要频繁请求怪物详情每帧可能有几十次查找遍历一下子成了热点。第一步把索引关系改成TMapint32, int32键是怪物 ID值是数组下标。查找从 O(n) 变 O(1)。数组仍然保留因为 UI 列表要按配置顺序渲染TMap 无法保证顺序。第二步键类型一开始考虑过 FString 或 FName。怪物 ID 是从配置表读进来的整型直接 int32 就好省到不能再省。这里不要为了“统一用字符串 ID”去转一圈整型键的哈希和比较都最低廉。第三步缓存详情对象。原先每次查找都是重新从结构体拷贝数据改成在 TMap 里缓存一个索引再拿索引去数组里取引用返回。注意返回的是引用还是值要看调用方是否要改数据。只读用const FMonsterInfo避免大结构体频繁拷贝。改造后原来每帧查找热点完全消失。整个过程中最有价值的不是换了容器而是把“哪些是稳定数据、哪些是索引、哪些是缓存”理清楚了。容器的选择永远是跟着数据生命周期走的。7. 一些分散但实用的熟手操作细节有些知识点太小单独开一章显得啰嗦但不写又确实有用凑在一起记录一下。7.1 UE_LOG 输出容器内容直接UE_LOG(LogTemp, Log, TEXT(%s), *MyArray)是不行的TArray 没有直接转字符串的函数。三种常用方案// 1. 手动遍历拼 FString FString Result; for (int32 V : MyArray) Result FString::FromInt(V) TEXT(, ); // 2. 用 FString::Join 处理字符串数组 TArrayFString Names {TEXT(A), TEXT(B), TEXT(C)}; FString Joined FString::Join(Names, TEXT(|)); // 3. 如果元素类型支持 ToString可以封装一层模板工具FString::Join 是个宝藏函数处理 CSV、日志输出比手写循环干净得多。7.2 TArray 的 Reserve 与 Emplace 配合当你知道 TArray 大概会存多少元素时先Reserve一个容量避免多次扩容TArrayFVector Points; Points.Reserve(10000); for (int32 i 0; i 10000; i) { Points.Emplace(X, Y, Z); }Emplace对 FVector 这种类型是原地构造少一次默认构造再赋值的开销。对于大结构体Emplace比Add有肉眼可见的效率优势。7.3 TMap 的 Keys 和 Values 视图有些场景要单独拿所有键。TMap 提供了Keys()和Values()视图返回的是不可修改的轻量视图不要动态分配数组复制一份——除非你真的需要“某一时刻的快照”否则直接用视图遍历for (const int32 K : MyMap.Keys()) { // 遍历所有键 }这么写在语义上更清晰也不会误改 map 结构。7.4 TSortedMap 和 TMap 的取舍TSortedMap内部基于有序数组实现键必须支持operator。它的优势是内存更紧凑、遍历有序劣势是插入和删除是 O(n)。如果容器数据变化不频繁但遍历频繁TSortedMap 可能比 TMap 更合适。不过实际项目里 TMap 确实是绝对主流性能瓶颈往往不在容器本身而在“算哈希”和“比较键”这两个动作上。7.5 自定义结构体做键的常见翻车点有种情况特别坑结构体里有个 FString 成员用来当键的一部分。你的GetTypeHash写的是GetTypeHash(MyStruct.Name)但查询时这个 FString 的大小写和插入时不一样哈希值不同就找不到。FString 的 GetTypeHash 是区分大小写的如果业务上不区分大小写必须先ToLower()再哈希否则一样的内容会变成两个键。写哈希函数时一定要和业务的等值语义对齐。8. 最后的“查漏补缺”从源码角度再看一眼这几种类型想把这几个类型彻底内化建议有空直接翻 UE 的源码。FString 看StringBase.h开头那两百行就能理解它的存储布局FName 看NameTypes.h中GetComparisonIndex的处理理解大小写不敏感的真正含义TMap 看Map.h里Find的完整路径能一眼看出它先算哈希再比较键的执行顺序。这些源码不难读虽然没有注释但结构脉络非常清楚。所以我一般不建议死记 API而是把“数据生命周期”想清楚谁创建、谁持有、谁查询、谁销毁容器类型和字符串类型自然会选对。这一步想清楚了什么 TArray、TMap、TPair 继承链都只是顺手的事。