【C++基础】第六十七课:[动态内存]动态数组

new和数组,allocator类

Posted by x-jeff on March 29, 2023

【C++基础】系列博客为参考《C++ Primer中文版(第5版)》C++11标准)一书,自己所做的读书笔记。
本文为原创文章,未经本人允许,禁止转载。转载请注明出处。

1.动态数组

new和delete运算符一次分配/释放一个对象,但某些应用需要一次为很多对象分配内存的功能。例如,vector和string都是在连续内存中保存它们的元素,因此,当容器需要重新分配内存时,必须一次性为很多元素分配内存。

为了支持这种需求,C++语言和标准库提供了两种一次分配一个对象数组的方法。C++语言定义了另一种new表达式语法,可以分配并初始化一个对象数组。标准库中包含一个名为allocator的类,允许我们将分配和初始化分离。使用allocator通常会提供更好的性能和更灵活的内存管理能力。

大多数应用应该使用标准库容器而不是动态分配的数组。使用容器更为简单、更不容易出现内存管理错误并且可能有更好的性能。

使用容器的类可以使用默认版本的拷贝、赋值和析构操作。分配动态数组的类则必须定义自己版本的操作,在拷贝、复制以及销毁对象时管理所关联的内存。

2.new和数组

为了让new分配一个对象数组,我们要在类型名之后跟一对方括号,在其中指明要分配的对象的数目。在下例中,new分配要求数量的对象并(假定分配成功后)返回指向第一个对象的指针:

1
2
//调用get_size确定分配多少个int
int *pia = new int[get_size()];//pia指向第一个int

方括号中的大小必须是整型,但不必是常量。

也可以用一个表示数组类型的类型别名来分配一个数组,这样,new表达式中就不需要方括号了:

1
2
typedef int arrT[42];//arrT表示42个int的数组类型
int *p = new arrT;//分配一个42个int的数组;p指向第一个int

2.1.分配一个数组会得到一个元素类型的指针

虽然我们通常称new T[]分配的内存为“动态数组”,但这种叫法某种程度上有些误导。当用new分配一个数组时,我们并未得到一个数组类型的对象,而是得到一个数组元素类型的指针。即使我们使用类型别名定义了一个数组类型,new也不会分配一个数组类型的对象。在上例中,我们正在分配一个数组的事实甚至都是不可见的——连[num]都没有。new返回的是一个元素类型的指针。

由于分配的内存并不是一个数组类型,因此不能对动态数组调用begin或end。这些函数使用数组维度(维度是数组类型的一部分)来返回指向首元素和尾后元素的指针。出于相同的原因,也不能用范围for语句来处理(所谓的)动态数组中的元素。

2.2.初始化动态分配对象的数组

默认情况下,new分配的对象,不管是单个分配的还是数组中的,都是默认初始化的。可以对数组中的元素进行值初始化,方法是在大小之后跟一对空括号。

1
2
3
4
int *pia = new int[10];//10个未初始化的int
int *pia2 = new int[10]();//10个值初始化为0的int
string *psa = new string[10];//10个空string
string *psa2 = new string[10]();//10个空string

在新标准中,我们还可以提供一个元素初始化器的花括号列表:

1
2
3
4
//10个int分别用列表中对应的初始化器初始化
int *pia3 = new int[10]{0,1,2,3,4,5,6,7,8,9};
//10个string,前4个用给定的初始化器初始化,剩余的进行值初始化
string *psa3 = new string[10]{"a","an","the",string(3,'x')};

与内置数组对象的列表初始化一样,初始化器会用来初始化动态数组中开始部分的元素。如果初始化器数目小于元素数目,剩余元素将进行值初始化。如果初始化器数目大于元素数目,则new表达式失败,不会分配任何内存。在本例中,new会抛出一个类型为bad_array_new_length的异常。类似bad_alloc,此类型定义在头文件new中。

虽然我们用空括号对数组中元素进行值初始化,但不能在括号中给出初始化器,这意味着不能用auto分配数组。

2.3.动态分配一个空数组是合法的

可以用任意表达式来确定要分配的对象的数目:

1
2
3
4
size_t n = get_size();//get_size返回需要的元素的数目
int* p = new int[n];//分配数组保存元素
for(int *q = p; q != p+n; ++q)
	/*处理数组*/

这产生了一个有意思的问题:如果get_size返回0,会发生什么?答案是代码仍能正常工作。虽然我们不能创建一个大小为0的静态数组对象,但当n等于0时,调用new[n]是合法的:

1
2
char arr[0];//错误:不能定义长度为0的数组
char *cp = new char[0];//正确:但cp不能解引用

当我们用new分配一个大小为0的数组时,new返回一个合法的非空指针。此指针保证与new返回的其他任何指针都不相同。对于零长度的数组来说,此指针就像尾后指针一样,我们可以像使用尾后指针一样使用这个指针。可以用此指针进行比较操作,就像上面循环代码中那样。可以向此指针加上(或从此指针减去)0,也可以从此指针减去自身从而得到0。但此指针不能解引用——毕竟它不指向任何元素。

2.4.释放动态数组

为了释放动态数组,我们使用一种特殊形式的delete——在指针前加上一个空方括号对:

1
2
delete p;//p必须指向一个动态分配的对象或为空
delete [] pa;//pa必须指向一个动态分配的数组或为空

第二条语句销毁pa指向的数组中的元素,并释放对应的内存。数组中的元素按逆序销毁,即,最后一个元素首先被销毁,然后是倒数第二个,依此类推。

当我们释放一个指向数组的指针时,空方括号对是必需的:它指示编译器此指针指向一个对象数组的第一个元素。如果我们在delete一个指向数组的指针时忽略了方括号(或者在delete一个指向单一对象的指针时使用了方括号),其行为是未定义的(编译器很可能不会给出警告,我们的程序可能在执行过程中在没有任何警告的情况下行为异常)。

回忆一下,当我们使用一个类型别名来定义一个数组类型时,在new表达式中不使用[]。即使是这样,在释放一个数组指针时也必须使用方括号:

1
2
3
typedef int arrT[42];//arrT是42个int的数组的类型别名
int *p = new arrT;//分配一个42个int的数组;p指向第一个元素
delete [] p;//方括号是必需的,因为我们当初分配的是一个数组

2.5.智能指针和动态数组

标准库提供了一个可以管理new分配的数组的unique_ptr版本。为了用一个unique_ptr管理动态数组,我们必须在对象类型后面跟一对空方括号:

1
2
3
//up指向一个包含10个未初始化int的数组
unique_ptr<int[]> up(new int[10]);
up.release();//自动用delete[]销毁其指针

类型说明符中的方括号(<int[]>)指出up指向一个int数组而不是一个int。由于up指向一个数组,当up销毁它管理的指针时,会自动使用delete[]。

指向数组的unique_ptr提供的操作与这里使用的那些操作有一些不同,我们在表12.6中描述了这些操作。当一个unique_ptr指向一个数组时,我们不能使用点和箭头成员运算符。毕竟unique_ptr指向的是一个数组而不是单个对象,因此这些运算符是无意义的。另一方面,当一个unique_ptr指向一个数组时,我们可以使用下标运算符来访问数组中的元素:

1
2
for(size_t i=0; i!=10; ++i)
	up[i]=i;//为每个元素赋予一个新值

测试了下,发现unique_ptr也可以用这些成员运算符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <new>
#include <iostream>
using namespace std;
struct pnt
{
    int x = 0;
    int y = 0;
};
int main()
{
    pnt *pia = new pnt[2]{ {2,3},{4,5} };
    unique_ptr<pnt[]> up(new pnt[2]{ {6,7},{8,9} });
    cout << pia[0].x << endl;//2
    cout << up[1].x << endl;//8
}

与unique_ptr不同,shared_ptr不直接支持管理动态数组。如果希望使用shared_ptr管理一个动态数组,必须提供自己定义的删除器

1
2
3
//为了使用shared_ptr,必须提供一个删除器
shared_ptr<int> sp(new int[10], [](int *p) { delete[] p; });
sp.reset();//使用我们提供的lambda释放数组,它使用delete[]

reset操作见:其他shared_ptr操作

本例中我们传递给shared_ptr一个lambda作为删除器,它使用delete[]释放数组。

如果未提供删除器,这段代码将是未定义的。默认情况下,shared_ptr使用delete销毁它指向的对象。如果此对象是一个动态数组,对其使用delete所产生的问题与释放一个动态数组指针时忘记[]产生的问题一样。

shared_ptr不直接支持动态数组管理这一特性会影响我们如何访问数组中的元素:

1
2
3
//shared_ptr未定义下标运算符,并且不支持指针的算术运算
for(size_t i=0; i!=10; ++i)
	*(sp.get()+i)=i;//使用get获取一个内置指针

shared_ptr未定义下标运算符,而且智能指针类型不支持指针算术运算。因此,为了访问数组中的元素,必须用get获取一个内置指针,然后用它来访问数组元素。

3.allocator类

new有一些灵活性上的局限,其中一方面表现在它将内存分配和对象构造组合在了一起。类似的,delete将对象析构和内存释放组合在了一起。我们分配单个对象时,通常希望将内存分配和对象初始化组合在一起。因为在这种情况下,我们几乎肯定知道对象应有什么值。

当分配一大块内存时,我们通常计划在这块内存上按需构造对象。在此情况下,我们希望将内存分配和对象构造分离。这意味着我们可以分配大块内存,但只在真正需要时才真正执行对象创建操作(同时付出一定开销)。

一般情况下,将内存分配和对象构造组合在一起可能会导致不必要的浪费。例如:

1
2
3
4
5
6
7
8
string *const p = new string[n];//构造n个空string
string s;
string *q = p;//q指向第一个string
while (cin >> s && q != p+n)
	*q++ = s;//赋予*q一个新值
const size_t size = q-p;//记住我们读取了多少个string
//使用数组
delete[] p;//p指向一个数组;记得用delete[]来释放

new表达式分配并初始化了n个string。但是,我们可能不需要n个string,少量string可能就足够了。这样,我们就可能创建了一些永远也用不到的对象。而且,对于那些确实要使用的对象,我们也在初始化之后立即赋予了它们新值。每个使用到的元素都被赋值了两次:第一次是在默认初始化时,随后是在赋值时。

更重要的是,那么没有默认构造函数的类就不能动态分配数组了。

3.1.allocator类

标准库allocator类定义在头文件memory中,它帮助我们将内存分配和对象构造分离开来。它提供一种类型感知的内存分配方法,它分配的内存是原始的、未构造的。表12.7概述了allocator支持的操作。

类似vector,allocator是一个模板。为了定义一个allocator对象,我们必须指明这个allocator可以分配的对象类型。当一个allocator对象分配内存时,它会根据给定的对象类型来确定恰当的内存大小和对齐位置:

1
2
allocator<string> alloc;//可以分配string的allocator对象
auto const p = alloc.allocate(n);//分配n个未初始化的string

3.2.allocator分配未构造的内存

allocator分配的内存是未构造的(unconstructed)。我们按需要在此内存中构造对象。在新标准库中,construct成员函数接受一个指针和零个或多个额外参数,在给定位置构造一个元素。额外参数用来初始化构造的对象。类似make_shared的参数,这些额外参数必须是与构造的对象的类型相匹配的合法的初始化器:

1
2
3
4
auto q = p;//q指向最后构造的元素之后的位置
alloc.construct(q++);//*p为空字符串
alloc.construct(q++, 10, 'c');//*p为cccccccccc
alloc.construct(q++, "hi");//*p为hi

在早期版本的标准库中,construct只接受两个参数:指向创建对象位置的指针和一个元素类型的值。因此,我们只能将一个元素拷贝到未构造空间中,而不能用元素类型的任何其他构造函数来构造一个元素。

还未构造对象的情况下就使用原始内存是错误的:

1
2
cout << *p << endl;//正确:使用string的输出运算符
cout << *q << endl;//灾难:q指向未构造的内存!

再举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <new>
#include <iostream>
#include <memory>
using namespace std;
int main()
{
    allocator<string> alloc;
    auto const p = alloc.allocate(10);
    auto q = p;
    alloc.construct(q++, "hi");//即*p为hi
    cout << *p << endl; //输出"hi"
    //因为q++的缘故,q的新位置对应的其实是未构造的对象
}

为了使用allocate返回的内存,我们必须用construct构造对象。使用未构造的内存,其行为是未定义的。

当我们用完对象后,必须对每个构造的元素调用destroy来销毁它们。函数destroy接受一个指针,对指向的对象执行析构函数:

1
2
while (q != p)
	alloc.destroy(--q);//释放我们真正构造的string

在循环开始处,q指向最后构造的元素之后的位置。我们在调用destroy之前对q进行了递减操作。因此,第一次调用destroy时,q指向最后一个构造的元素。最后一步循环中我们destroy了第一个构造的元素,随后q将与p相等,循环结束。

我们只能对真正构造了的元素进行destroy操作。

一旦元素被销毁后,就可以重新使用这部分内存来保存其他string,也可以将其归还给系统。释放内存通过调用deallocate来完成:

1
alloc.deallocate(p, n);

我们传递给deallocate的指针不能为空,它必须指向由allocate分配的内存。而且,传递给deallocate的大小参数必须与调用allocate分配内存时提供的大小参数具有一样的值。

3.3.拷贝和填充未初始化内存的算法

标准库还为allocator类定义了两个伴随算法,可以在未初始化内存中创建对象。表12.8描述了这些函数,它们都定义在头文件memory中。

作为一个例子,假定有一个int的vector,希望将其内容拷贝到动态内存中。我们将分配一块比vector中元素所占用空间大一倍的动态内存,然后将原vector中的元素拷贝到前一半空间,对后一半空间用一个给定值进行填充:

1
2
3
4
5
6
//分配比vi中元素所占用空间大一倍的动态内存
auto p = alloc.allocate(vi.size()*2);
//通过拷贝vi中的元素来构造从p开始的元素
auto q = uninitialized_copy(vi.begin(), vi.end(), p);
//将剩余元素初始化为42
uninitialized_fill_n(q, vi.size(), 42);

uninitialized_copy接受三个迭代器参数。前两个表示输入序列,第三个表示这些元素将要拷贝到的目的空间。传递给uninitialized_copy的目的位置迭代器必须指向未构造的内存。uninitialized_copy在给定目的位置构造元素。

uninitialized_copy返回(递增后的)目的位置迭代器。因此,一次uninitialized_copy调用会返回一个指针,指向最后一个构造的元素之后的位置。在本例中,我们将此指针保存在q中,然后将q传递给uninitialized_fill_n,其接受一个指向目的位置的指针、一个计数和一个值。它会在目的位置指针指向的内存中创建给定数目个对象,用给定值对它们进行初始化。