06 - 数组TArray

本文主要说明了UE5中有关容器类数组TArray的相关概念和常见操作。

TArray

TArray是UE5提供的数组容器类,负责组织一系列同类型元素。

声明与初始化

首先需要对数组进行声明:

TArray<int32> IntArray;

然后是数组的初始化操作,主要有.Init()和括号初始化两种:

// 两者等效
IntArray.Init(10, 5);
IntArray = {10, 10, 10, 10, 10};

此外,还有一种类似std::vector::resize()的操作,就是SetNum()。该函数可直接设置数组元素的数量,如新数量大于当前数量,则使用元素类型的默认构造函数新建元素;如新数量小于当前数量,SetNum() 将移除元素。

添加元素

当新元素添加到数组时,数组的分配器将根据需要分配内存。当前数组大小超出时,默认分配器将添加足够的内存,用于存储多个新元素。

可通过Add()Emplace()在数组末尾添加一个元素,它俩的区别和C++std::vectorpush_back()emplace_back()类似:

  • Add(或 Push)将元素类型的实例复制(或移动)到数组中。
  • Emplace 使用给定参数构建元素类型的新实例。

如果不想在末尾添加一个重复元素,可用AddUnique(),该函数通过运算符==判断两元素是否相等,可根据需要重载该运算符。

如果想要一次性在末尾添加多个元素,可用Append()

// TArray
IntArray.Append({1, 2, 3});

// C风格数组
int32 CTypeArr[] = {1, 2, 3};
IntArray.Append(CTypeArr, UE_ARRAY_COUNT(CTypeArr));

如果想要在数组指定索引处添加单个或多个元素,可使用Insert()

// 单个元素
IntArray.Insert(1, 2);

// 多个元素 - TArray
IntArray.Insert({1, 2}, 4);
// 多个元素 - C风格数组
IntArray.Insert(CTypeArr, UE_ARRAY_COUNT(CTypeArr), 4);

遍历元素

和C++类似,TArray支持基于范围,索引和迭代器的数组元素遍历。

首先是基于范围的遍历:

for (auto& Str : StrArr)
{
    // ...
}

然后是基于索引的遍历,这里使用成员函数Num()查看当前数组的元素个数:

for (int32 Index = 0; Index != StrArr.Num(); ++Index)
{
	// ...
}

最后是通过迭代器的遍历,它通过函数CreateIterator()CreateConstIterator()创建元素的读写或只读访问迭代器:

for (auto It = IntArray.CreateConstIterator(); It; ++It)
{
	// ......
}

需要注意的是,不要在遍历容器的过程中修改这个容器,在多线程环境下可能会出现一些问题。

排序

可使用Sort()(快速排序实现),HeapSort()(堆排序实现)对数组元素进行不稳定排序;也可使用StableSort()(归并排序实现)对数组元素进行稳定排序。

数组元素默认按照所属类型的operator<排序,也可通过lambda表达式进行自定义排序:

StrArr.Sort();
// 字典序: ["!","Brave","Hello","of","Tomorrow","World"]

StrArr.HeapSort([](const FString& A, const FString& B) {
		return A.Len() < B.Len();
	});
// 长度: ["!","of","Hello","Brave","World","Tomorrow"]

查询

TArray还提供了一系列查询函数,方便用户进行相关操作:

  • 询问数组属性: | 函数 | 用途 | | —————————————— | ———————————————————— | | Num() | 获取数组元素个数 | | GetData() | 获取指向数组首个元素的指针,方便C风格API
    需要注意的是,获取指针后如果数组不存在或被更改,指针将会无效 | | GetTypeSize() | 获取元素类型大小 | | IsValidIndex(i) | 询问索引i是否有效 | | CountBytes()
    GetAllocatedSize() | 估算数组当前内存占用量 |

  • 获取数组元素的引用:

函数用途
operator[]按索引获取数组中指定元素的引用
Last(i)返回数组反向索引i的元素,默认i为0
Top()Last(0)
  • 元素查询类:
函数用途
Contains(x)询问数组是否包含特定元素x
ContainsByPredicate(lambda -> bool)询问数组是否包含符合lambda表达式要求的元素
Find(x)询问数组是否出现元素x,是就返回首次索引,否就返回INDEX_NONE
FindLast(x)返回元素x最后出现的索引,否就返回INDEX_NONE
IndexOfByKey(x)Find()将参数类型的转换为数组元素的类型后才进行比较,而该函数直接将参数和数组元素比较,因此需要实现两种类型的operator==
IndexOfByPredicate(lambda -> bool)返回首个符合lambda表达式要求元素的索引
FindByKey(x)IndexOfByKey(x)类似,但返回指向元素的指针,找不到返回nullptr
FindByPredicate(lambda -> bool)IndexOfByPredicate(lambda -> bool)类似,但返回指向元素的指针
FilterByPredicate(lambda -> bool)返回符合lambda表达式的元素数组

移除

TArray也提供了许多用于移除数组元素的函数:

  • Remove系列,移除元素时会将被移除元素的后续元素前移:
函数用途
Remove(x)移除数组中所有值为x的元素
RemoveSingle(x)移除数组中首个值为x的元素
RemoveAt(i)移除指定索引处的元素
RemoveAll(lambda -> bool)移除所有符合lambda表达式的元素
  • RemoveSwap系列,直接用数组末尾元素代替被删除的元素以减小移动开销:
函数用途
RemoveSwap(x)Remove(x)
RemoveAtSwap(i)RemoveAt(i)
RemoveAllSwap(lambda -> bool)RemoveAll(lambda -> bool)
  • Empty(x):清空数组所有元素,并为数组预留x个位置,默认为0。

运算符

TArray也提供了一些运算符方便用户进行常用操作:

  • operator+=:同Append(arr),进行数组的串联;
  • operator==operator!=:用于判断两数组是否相同(元素顺序和数量均相同);
  • MoveTemp(arr):移动语义,将arr移动赋值给目标数组,然后清空自身;
  • BulkSerialize()operator<<:用于序列化数组;
  • Swap()SwapMemory():用于交换两个索引上的元素值,Swap()更安全;

堆化

TArray还支持二叉堆操作,堆的根节点位于索引0,索引N节点的左右子节点索引为2N+12N+2

  • Heapify():将当前数组转换为二叉堆,如果没有用lambda表达式实现排序逻辑(此后该堆的所有操作均需要此表达式),默认使用operator<
  • HeapPush(x):向堆中添加元素x
  • HeapPop():移除并返回堆顶元素;
  • HeapPopDiscard():只移除堆顶元素;
  • HeapRemoveAt(i):移除索引i处的元素,并维护堆;
  • HeapTop():获取堆顶元素;

内存管理

TArray在添加元素时会进行内存扩充,接下来看看它内存管理的主要内容:

Slack

为避免每次添加元素时重新分配内存,分配器提供的内存通常会超过必要内存,使之后调用 Add() 时不会因重新分配内存而降低性能。同样,删除元素通常不会释放内存。此操作会使数组拥有Slack,也就是当前未使用的有效预分配元素个数,可通过GetSlack()获取。

数组中内存分配的元素个数Max与数组中实际存储的元素个数Num间的差值即为数组中的Slack

TArray<int32> SlackArray;
// SlackArray.GetSlack() == 0
// SlackArray.Num()      == 0
// SlackArray.Max()      == 0

SlackArray.Add(1);
// SlackArray.GetSlack() == 3
// SlackArray.Num()      == 1
// SlackArray.Max()      == 4

SlackArray.Add(2);
SlackArray.Add(3);
SlackArray.Add(4);
SlackArray.Add(5);
// SlackArray.GetSlack() == 17
// SlackArray.Num()      == 5
// SlackArray.Max()      == 22

元素扩充时的内存分配个数遵循此公式(非节省内存模式下): \[ \nonumber \mathrm{初次扩容:FirstGrow=4} \\ \mathrm{后续扩容:Grow=Num()+Num() \times \frac{3}{8}+16} \]

在第一次扩充时(.Add(1)时),内存分配个数为4;在第二次扩充时(.Add(5)时),内存分配的元素数为5 + 5*3/8 + 16 = 22。计算完内存分配的元素个数后需要按内存对齐的规则修改个数,这里22 * 4B = 88B = 16B * 5.5已经对齐了。

如果要搞性能优化,管理Slack就成了必修课。TArray提供如下函数让开发者管理Slack

  • Empty(x):释放内存,并请求x个元素的Slack

    SlackArray.Empty();
    // SlackArray.GetSlack() == 0
    // SlackArray.Num()      == 0
    // SlackArray.Max()      == 0
    SlackArray.Empty(3);
    // SlackArray.GetSlack() == 3
    // SlackArray.Num()      == 0
    // SlackArray.Max()      == 3
    SlackArray.Add(1);
    SlackArray.Add(2);
    SlackArray.Add(3);
    // SlackArray.GetSlack() == 0
    // SlackArray.Num()      == 3
    // SlackArray.Max()      == 3
  • Reset(x):如果Slack已满足要求,不释放内存;否则分配更多内存。

    SlackArray.Reset(0);
    // SlackArray.GetSlack() == 3
    // SlackArray.Num()      == 0
    // SlackArray.Max()      == 3
    SlackArray.Reset(10);
    // SlackArray.GetSlack() == 10
    // SlackArray.Num()      == 0
    // SlackArray.Max()      == 10
  • Shrink():移除所有Slack,把内存分配调整为保存当前元素所需的最小内存。

    SlackArray.Add(5);
    SlackArray.Add(10);
    SlackArray.Add(15);
    SlackArray.Add(20);
    // SlackArray.GetSlack() == 6
    // SlackArray.Num()      == 4
    // SlackArray.Max()      == 10
    SlackArray.Shrink();
    // SlackArray.GetSlack() == 0
    // SlackArray.Num()      == 4
    // SlackArray.Max()      == 4
  • Reserve(x):希望数组能存x个元素,如果不够存就单向扩容内存。

原始内存操作

一些高级开发常常要直接操作内存,TArray提供如下函数方便开发者进行相关操作:

  • Add/InsertUninitialized()

参考资料

  • UE5 虚幻引擎UEC++从基础到进阶_哔哩哔哩_bilibili

  • Array Containers in Unreal Engine | 虚幻引擎 5.6 文档 | Epic Developer Community