TIndirectArray 深度解析:UE 中的间接数组容器
1. 概述
TIndirectArray 是 Unreal Engine 中一个独特的容器类型,它与标准的 TArray 有着本质的区别。正如源码注释所说:
Same as a TArray, but stores pointers to the elements, to allow resizing the array index without relocating the actual elements.
核心设计理念:存储指向元素的指针,而非元素本身。这使得数组扩容时无需移动实际元素。
2. 生活中的例子:图书馆 vs 书架
在深入技术细节之前,让我们用一个生活中的例子来理解 TIndirectArray 的设计思想。
2.1 传统书架(TArray)
想象你有一个小书架,书直接放在架子上:
┌─────────────────────────────────────────┐
│ 📕 📗 📘 📙 📓 │
│ 书1 书2 书3 书4 书5 │
└─────────────────────────────────────────┘
问题来了:当你的书越来越多,书架放不下了,你需要换一个更大的书架。这时候:
- 你必须把所有的书都搬到新书架上
- 如果你之前告诉朋友"书3在第三格",搬家后这个位置信息就失效了
- 书越多越重,搬家越累
2.2 图书馆索引卡系统(TIndirectArray)
现在想象一个图书馆的管理方式:
索引卡片上写着每本书的实际位置(指针)。
优势:
- 需要扩容时,只需换一个更大的卡片柜,书不用动!
- 卡片很轻(只有8字节),搬起来很快
- 你告诉朋友"书3在C区",无论卡片柜怎么换,书的位置永远不变
- 可以放不同大小的书(多态),因为书不需要挤在一起
2.3 类比总结
| 概念 | 图书馆例子 | TIndirectArray |
|---|---|---|
| 索引卡片 | 记录书的位置 | 指针(void*) |
| 卡片柜 | 存放所有卡片 | 内部的 TArray<void*> |
| 书籍 | 实际的内容 | 堆上的对象 |
| 扩容 | 换更大的卡片柜 | 只移动指针数组 |
| 地址稳定 | 书的位置不变 | 对象地址不变 |
一句话总结:TIndirectArray 就像图书馆的索引卡系统——管理的是"书在哪里"的信息,而不是书本身。
3. 内存布局对比
2.1 TArray 的内存布局
TArray<MyClass>:
┌─────────────────────────────────────────────────────┐
│ [Object0] [Object1] [Object2] [Object3] ... │
│ 连续内存块,对象直接存储 │
└─────────────────────────────────────────────────────┘
当 TArray 扩容时,所有元素需要被移动到新的内存位置。
2.2 TIndirectArray 的内存布局
TIndirectArray<MyClass>:
┌────────────────────────────────────────┐
│ [Ptr0] [Ptr1] [Ptr2] [Ptr3] ... │ ← 指针数组(连续)
└───┬──────┬──────┬──────┬───────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌───┐ ┌───┐ ┌───┐ ┌───┐
│Obj│ │Obj│ │Obj│ │Obj│ ← 对象分散在堆上
└───┘ └───┘ └───┘ └───┘
当 TIndirectArray 扩容时,只需移动指针,实际对象保持原位。
3. 核心实现分析
3.1 类定义与内部存储
cpp
template<typename T, typename Allocator = FDefaultAllocator>
class TIndirectArray
{
public:
typedef T ElementType;
typedef TArray<void*, Allocator> InternalArrayType;
private:
InternalArrayType Array; // 内部使用 TArray<void*> 存储指针
};
关键点:
- 内部使用
TArray<void*>存储指针 - 模板参数
T是实际元素类型 - 支持自定义分配器
3.2 元素访问
cpp
FORCEINLINE T& operator[](int32 Index)
{
return *(T*)Array[Index]; // 解引用指针返回对象引用
}
FORCEINLINE const T& operator[](int32 Index) const
{
return *(T*)Array[Index];
}
operator[] 返回的是对象的引用,而非指针。这使得使用体验与 TArray 一致,对用户透明。
3.3 添加元素
cpp
FORCEINLINE int32 Add(T* Item)
{
return Array.Add(Item);
}
FORCEINLINE void Insert(T* Item, int32 Index)
{
Array.Insert(Item, Index);
}
重要:Add 和 Insert 接收的是指针,调用者需要负责创建对象:
cpp
TIndirectArray<FMyClass> MyArray;
MyArray.Add(new FMyClass()); // 必须传入 new 创建的对象
3.4 拷贝构造与赋值
cpp
拷贝操作会进行深拷贝,为每个元素创建新的堆对象。
3.5 移动语义
cpp
移动操作只需转移指针数组的所有权,非常高效。
3.6 析构与内存管理
cpp
所有权语义:TIndirectArray 拥有其中所有对象的所有权,析构时会 delete 所有元素。
3.7 删除元素
cpp
提供两种删除方式:
RemoveAt:保持顺序,O(n) 复杂度RemoveAtSwap:不保序,O(1) 复杂度
4. 迭代器支持
4.1 Range-based for 循环
cpp
FORCEINLINE TDereferencingIterator<ElementType, ...> begin() {
return TDereferencingIterator<ElementType, ...>(Array.begin());
}
FORCEINLINE TDereferencingIterator<ElementType, ...> end() {
return TDereferencingIterator<ElementType, ...>(Array.end());
}
使用 TDereferencingIterator 包装,使迭代时直接获得对象引用:
cpp
TIndirectArray<FMyClass> MyArray;
// 迭代时 Item 是 FMyClass& 而非 FMyClass*
for (FMyClass& Item : MyArray)
{
Item.DoSomething();
}
4.2 显式迭代器
cpp
typedef TIndexedContainerIterator<TIndirectArray, ElementType, int32> TIterator;
typedef TIndexedContainerIterator<const TIndirectArray, const ElementType, int32> TConstIterator;
TIterator CreateIterator() { return TIterator(*this); }
TConstIterator CreateConstIterator() const { return TConstIterator(*this); }
5. 序列化支持
cpp
5.2 带 Owner 参数的序列化版本
除了标准的 operator<<,还有一个特殊的成员函数版本:
cpp
核心区别:
| 版本 | 调用方式 | 元素要求 |
|---|---|---|
operator<< |
Ar << Element |
元素实现 operator<< |
Serialize(Ar, Owner) |
Element.Serialize(Ar, Owner, Index) |
元素实现 Serialize(Ar, Owner, Index) |
为什么需要 Owner?
源码注释说明了原因:
Special serialize function passing the owning UObject along as required by FUntypedBulkData serialization.
典型场景是 FBulkData(批量数据)序列化。FBulkData 用于存储大块二进制数据(如纹理像素、音频数据),它的特殊之处:
- 数据可能存储在外部文件(如
.ubulk文件) - **需要知道"谁拥有我"**才能正确定位外部文件路径
- 需要 Index 来区分同一 UObject 中的多个 BulkData
生活例子:快递仓库系统
- Owner(张三):告诉系统这个数据属于哪个 UObject
- Index(1号柜、2号柜):区分同一 Owner 下的多个数据块
实际应用示例:
cpp
一句话总结:当元素数据可能存储在外部文件时,需要 Owner 来定位"这个数据属于谁",Index 来区分"是这个对象的第几个数据块"。
6. 使用场景
6.1 适用场景
-
元素地址稳定性要求高
cppTIndirectArray<FComponent> Components; FComponent* Ptr = &Components[0]; Components.Add(new FComponent()); // Ptr 仍然有效! -
多态对象存储
cppTIndirectArray<FBaseClass> Items; Items.Add(new FDerivedClassA()); Items.Add(new FDerivedClassB()); -
大对象数组
- 扩容时只移动指针(8字节),不移动大对象
-
需要外部引用的场景
- 其他系统持有元素指针时,数组扩容不会使指针失效
6.2 不适用场景
-
小对象、频繁访问
- 指针间接访问有额外开销
- 缓存不友好
-
不需要地址稳定性
TArray更简单高效
7. TIndirectArray vs TArray 对比
| 特性 | TArray | TIndirectArray |
|---|---|---|
| 内存布局 | 连续存储对象 | 指针数组 + 分散对象 |
| 扩容开销 | 移动所有对象 | 只移动指针 |
| 元素地址稳定性 | 扩容后失效 | 始终稳定 |
| 缓存友好性 | 优秀 | 较差 |
| 内存碎片 | 低 | 较高 |
| 多态支持 | 不支持(切片问题) | 天然支持 |
| 所有权 | 值语义 | 拥有指针所有权 |
8. 实际使用示例
8.1 基本使用
cpp
8.2 多态使用
cpp
8.3 保持指针稳定
cpp
9. 注意事项
-
必须使用 new 创建对象
cppFMyClass StackObj; MyArray.Add(&StackObj); // 危险!析构时会 delete 栈对象 -
不要手动 delete 数组中的对象
cppdelete &MyArray[0]; // 错误!会导致双重释放 -
Add 之后不要在外部 delete 对象
这是
TIndirectArray使用中最危险的陷阱之一:cppFMyClass* Obj1 = new FMyClass(); TIndirectArray<FMyClass> Array; Array.Add(Obj1); // 所有权转移给容器 // 其他地方的代码... delete Obj1; // 💥 外部释放了! // 之后任何操作都会崩溃: Array[0].DoSomething(); // 💥 访问已释放内存 Array.Empty(); // 💥 double free问题分析:
正确做法:
cpp// 方式1:Add 之后置空外部指针 FMyClass* Obj1 = new FMyClass(); Array.Add(Obj1); Obj1 = nullptr; // 👈 避免误用 // 方式2:直接 new 进去(推荐) Array.Add(new FMyClass()); // 没有外部指针,不会误删如果需要共享访问怎么办?
场景 正确选择 独占所有权,不共享 TIndirectArray<T>共享所有权 TArray<TSharedPtr<T>>一个拥有者 + 多个观察者 拥有者用 TIndirectArray<T>,观察者只读不 deletecpp// 共享所有权示例 TArray<TSharedPtr<FMyClass>> Array; TSharedPtr<FMyClass> Obj1 = MakeShared<FMyClass>(); Array.Add(Obj1); // 外部仍持有 Obj1,可以安全使用 Obj1->DoSomething(); // ✅ 安全 // 当最后一个 TSharedPtr 销毁时,对象才被释放一句话:
TIndirectArray是独占所有权模型。Add 进去就别在外面 delete,否则必崩。 -
拷贝开销大
- 拷贝会深拷贝所有对象,考虑使用移动语义
-
内存碎片
- 每个对象独立分配,可能造成内存碎片
-
不要使用 TIndirectArray<T>*
这是一个常见的误解,模板参数
T应该是对象类型,而不是指针类型。错误理解:既然存储指针,那
TIndirectArray<FMyClass*>应该更明确?正确理解:
TIndirectArray内部已经处理了指针存储,T就是对象类型。cpp// 正确 ✓ TIndirectArray<FMyClass> GoodArray; GoodArray.Add(new FMyClass()); // 错误 ✗ - 多余且有害 TIndirectArray<FMyClass*> BadArray;为什么 TIndirectArray<T> 是错误的?*
回顾
Add函数签名:cppint32 Add(T* Item); // 接收 T* 类型- 当
T = FMyClass时,Add接收FMyClass*✓ - 当
T = FMyClass*时,Add接收FMyClass**(指针的指针)✗
内存布局对比:
析构时的灾难:
cpp// TIndirectArray<FMyClass*> 析构时 delete *Element; // 删除的是 FMyClass*(8字节的指针变量) // FMyClass 对象没有被释放 → 内存泄漏!模板参数 T Add 接收类型 void* 指向 析构时 delete FMyClassFMyClass*FMyClass 对象 FMyClass 对象 ✓ FMyClass*FMyClass**堆上的指针变量 指针变量(8字节)✗ 一句话:
TIndirectArray<T*>是多余的间接层,会导致内存泄漏,永远不要这样用。 - 当
-
不要用 TIndirectArray 存储 UObject
TIndirectArray使用原始指针和new/delete,与 UE 的垃圾回收(GC)系统完全无关。cpp// 错误 ✗ - UObject 不能用 new/delete 管理 TIndirectArray<UMyActor> Actors; // 析构时 delete UObject 会崩溃!TIndirectArray 的本质:
┌─────────────────────────────────────────────────────┐ │ • 使用原始指针(void*)存储 │ │ • 拥有独占所有权(Exclusive Ownership) │ │ • 析构时调用 delete │ │ • 不参与 UE 的 GC 系统 │ │ • 不是"强引用"或"弱引用"的概念 │ └─────────────────────────────────────────────────────┘存储 UObject 的正确方式:
方式 代码 说明 UPROPERTY 标记 UPROPERTY() TArray<UMyObject*>GC 追踪,防止回收(推荐) TStrongObjectPtr TArray<TStrongObjectPtr<UMyObject>>非 UPROPERTY 场景的强引用 TWeakObjectPtr TArray<TWeakObjectPtr<UMyObject>>弱引用,不阻止 GC cpp// 正确方式1:UPROPERTY 标记(推荐) UPROPERTY() TArray<UMyObject*> Objects; // GC 会追踪,防止被回收 // 正确方式2:TStrongObjectPtr(非 UPROPERTY 场景) TArray<TStrongObjectPtr<UMyObject>> Objects; // 强引用,防止 GC // 正确方式3:TWeakObjectPtr(不阻止 GC) TArray<TWeakObjectPtr<UMyObject>> Objects; // 弱引用,对象可能被 GCUE 引用系统对比:
类型 适用对象 引用计数 GC 感知 所有权 TIndirectArray<T>普通 C++ 对象 ❌ ❌ 独占所有权 UPROPERTY() TArray<T*>UObject ❌ ✅ 强引用 GC 管理 TArray<TWeakObjectPtr<T>>UObject ❌ ✅ 弱引用 GC 管理 TArray<TSharedPtr<T>>普通 C++ 对象 ✅ ❌ 共享所有权 TArray<TStrongObjectPtr<T>>UObject ❌ ✅ 强引用 防止 GC 一句话:
TIndirectArray只适用于普通 C++ 对象,UObject 必须使用 UE 的引用系统(UPROPERTY、TWeakObjectPtr 等)。
10. 源码设计亮点分析
10.1 类型擦除与类型安全的平衡
cpp
typedef TArray<void*, Allocator> InternalArrayType;
什么是类型擦除?
类型擦除是一种设计模式:在内部使用通用类型(如 void*),但对外提供类型安全的接口。
TIndirectArray 的实现:
cpp
// 内部存储:void* 数组(类型被"擦除")
TArray<void*, Allocator> Array;
// 对外接口:强类型的 T&
T& operator[](int32 Index)
{
return *(T*)Array[Index]; // 从 void* 转回 T*,再解引用
}
为什么这样设计?
- 减少模板膨胀:无论
T是什么类型,内部都是TArray<void*>,不会为每个T生成新的数组代码 - 保持类型安全:对外接口使用模板参数
T,编译期检查类型错误 - 零运行时开销:类型转换在编译期完成
对比:
cpp
深入理解"生成代码":
C++ 模板不是真正的代码,而是"代码模具"。当你使用模板时,编译器才会根据模具"生成"真正的代码:
cpp
编译后的二进制对比:
为什么 void 能共用?*
因为所有指针在底层都是相同大小(64位系统上都是8字节):
cpp
sizeof(FClassA*) == 8
sizeof(FClassB*) == 8
sizeof(FClassC*) == 8
sizeof(void*) == 8 // 都一样!
所以 TArray<void*> 可以存储任何类型的指针,只是失去了类型信息。TIndirectArray 的外层包装负责恢复类型信息。
| 概念 | 解释 |
|---|---|
| 模板 | 代码的"模具",不是真正的代码 |
| 实例化 | 编译器根据模具生成真正的代码 |
| 模板膨胀 | 每种类型都生成一份代码,导致二进制文件变大 |
| 类型擦除 | 用 void* 存储,所有类型共用一份代码 |
10.2 RAII 与所有权语义
RAII(Resource Acquisition Is Initialization) 是 C++ 的核心设计理念:资源的生命周期与对象绑定。
TIndirectArray 严格遵循 RAII 原则:
cpp
RAII 的好处:
| 场景 | 手动管理 TArray<T*> | RAII 的 TIndirectArray |
|---|---|---|
| 正常退出 | 需要手动遍历 delete | 自动释放 ✓ |
| 异常退出 | 可能泄漏 | 自动释放 ✓ |
| 提前 return | 可能泄漏 | 自动释放 ✓ |
| 代码复杂度 | 高 | 低 ✓ |
cpp
10.3 迭代器的巧妙设计
这是 TIndirectArray 最精妙的设计之一:让用户在遍历时完全感知不到底层是指针存储。
TDereferencingIterator 源码:
cpp
工作原理图解:
用户体验对比:
cpp
设计优势:
- 透明性:用户不需要知道底层是指针存储
- 一致性:与
TArray<T>的遍历体验一致 - 安全性:返回引用而非指针,避免空指针问题
- 零开销:所有转换在编译期完成,无运行时开销
一句话总结:TDereferencingIterator 是一个"翻译官",把底层的 void* 翻译成用户期望的 T&,让指针存储对用户完全透明。
11. 与 TArray<T*> 的关键区别
很多人会问:为什么不直接用 TArray<T*>?
| 特性 | TArray<T*> | TIndirectArray |
|---|---|---|
| 所有权 | 不拥有,需手动管理 | 拥有,自动 delete |
| 访问方式 | 返回 T* |
返回 T& |
| 拷贝语义 | 浅拷贝(危险!) | 深拷贝(安全) |
| 序列化 | 需自己实现 | 内置支持 |
| 使用复杂度 | 需要小心内存管理 | 像值类型一样使用 |
简单说:TIndirectArray = TArray<T*> + 自动内存管理 + 值语义接口
12. 完整对比示例:TArray vs TArray<T*> vs TIndirectArray
下面通过一个完整的例子,展示三种容器在各个方面的区别:
12.1 测试类定义
cpp
12.2 创建与添加元素
cpp
12.3 访问元素
cpp
12.4 遍历
cpp
12.5 地址稳定性测试
cpp
12.6 拷贝行为
cpp
12.7 析构与内存释放
cpp
12.8 为什么 TArray 不支持多态?
在看多态示例之前,我们需要理解一个根本问题:对象切片(Object Slicing)。
核心问题:值语义 vs 引用语义
TArray<T> 直接存储对象值,而不是指针:
cpp
// TArray 内部是连续内存块,每个元素占用 sizeof(T) 字节
TArray<FBase> Arr;
FDerived Obj; // sizeof(FDerived) > sizeof(FBase)
Arr.Add(Obj); // 问题!只复制 sizeof(FBase) 字节
对象切片图解:
FDerived 对象(完整):
┌─────────────────────────────────────┐
│ FBase 部分 │ FDerived 部分 │
│ (基类数据) │ (派生类数据) │
└─────────────────────────────────────┘
存入 TArray<FBase> 后:
┌─────────────────┐
│ FBase 部分 │ ← 只保留这部分,派生类数据被"切掉"了
└─────────────────┘
具体示例:
cpp
为什么会这样?
| 特性 | TArray<T> | TIndirectArray<T> |
|---|---|---|
| 存储方式 | 直接存储对象值 | 存储指针,指向堆上对象 |
| 内存布局 | 连续,每元素 sizeof(T) | 指针数组,对象可任意大小 |
| 复制行为 | 值复制(切片) | 指针复制(保留完整对象) |
| 多态支持 | ❌ 不支持 | ✅ 支持 |
解决方案:
cpp
一句话总结:TArray<T> 是值容器,存储时会按基类大小复制,导致派生类数据被切掉、虚函数失效。需要多态时,必须使用指针/引用语义的容器。
12.9 多态支持示例
cpp
12.10 完整对比总结表
| 特性 | TArray | TArray<T*> | TIndirectArray |
|---|---|---|---|
| 内存布局 | 对象连续存储 | 指针连续,对象分散 | 指针连续,对象分散 |
| 添加方式 | Add(T) / Emplace() |
Add(new T) |
Add(new T) |
| 访问返回 | T& |
T* |
T& |
| 遍历语法 | for(T& x : arr) |
for(T* x : arr) |
for(T& x : arr) |
| 地址稳定 | ❌ 扩容后失效 | ✅ 稳定 | ✅ 稳定 |
| 拷贝行为 | 深拷贝 | ⚠️ 浅拷贝(危险) | 深拷贝 |
| 析构释放 | ✅ 自动 | ❌ 需手动 | ✅ 自动 |
| 多态支持 | ❌ 切片问题 | ✅ | ✅ |
| 缓存友好 | ✅ 优秀 | ❌ 较差 | ❌ 较差 |
| 使用复杂度 | 简单 | 复杂(需管理内存) | 简单 |
12.11 选择建议
13. 性能考量
13.1 时间复杂度
| 操作 | TArray | TIndirectArray |
|---|---|---|
| 随机访问 | O(1) | O(1) + 一次解引用 |
| 尾部添加 | 均摊 O(1) | 均摊 O(1) + new 开销 |
| 扩容 | O(n) 移动对象 | O(n) 移动指针(更快) |
| 遍历 | 缓存友好 | 缓存不友好(指针跳转) |
13.2 空间开销
cpp
SIZE_T GetAllocatedSize() const
{
return Array.Max() * sizeof(T*) + Array.Num() * sizeof(T);
}
额外开销 = 元素数量 × sizeof(指针) = N × 8字节(64位系统)
13.3 何时选择 TIndirectArray
✅ 使用 TIndirectArray:
- 需要地址稳定性(外部持有指针)
- 存储多态对象
- 对象很大(>= 64字节)
- 频繁扩容但很少遍历
❌ 使用 TArray:
- 小对象、频繁遍历
- 不需要地址稳定性
- 性能敏感的热点代码
14. 总结
14.1 回顾图书馆的比喻
还记得我们的图书馆例子吗?
- TArray = 小书架,书直接放上面,搬家要搬所有书
- TIndirectArray = 图书馆索引系统,只管理卡片,书不用动
14.2 核心价值
TIndirectArray 通过存储指针而非对象本身,解决了三大问题:
- 地址稳定性:数组扩容不影响元素地址(书的位置永远不变)
- 多态支持:天然支持存储派生类对象(可以放不同大小的书)
- 大对象优化:扩容只需移动指针(只搬卡片,不搬书)
14.3 设计哲学
TIndirectArray 体现了 UE 容器设计的一贯理念:
在保持易用性的同时,提供对底层行为的精确控制。
它比 TArray<T*> 更安全(自动内存管理),比 TArray<T> 更灵活(地址稳定、多态支持),是 UE 容器工具箱中不可或缺的一员。
14.4 一图总结
理解并正确使用 TIndirectArray,可以让你的 UE 代码更加健壮和高效。

