C++ Primer CH12 动态内存

新版本的 C++ 最重要的更新之一就是提供了更为强大的智能指针(smart pointer),智能指针是模拟指针的抽象数据结构,提供了额外的功能包括内存管理(memory management)或者界限检查(bounds checking)。这些特性在保留性能的情况下,减少了因为指针滥用导致的难以查找的 bug。智能指针常用于跟踪其指向的内存,亦可用于管理其它资源,比如:网络连接和文件句柄。

智能指针能够自动回收内存,对象自动析构。但拥有对象的最后一个智能指针销毁时(本地变量离开其作用域),其会自动析构对象并回收内存。其中 shared_ptr 通过引用计数来实现,而 unique_ptr 则完全拥有其指向的对象。

有自动垃圾回收机制的语言不需要智能指针用于内存管理,但依然可以用于缓存管理和其它资源管理(如:文件句柄和网络)。

前面章节介绍的对象使用都没有动态内存。全局对象在程序启动时分配,并在程序结束时销毁。本地自动对象在指令流经过其定义位置时创建,在离开其创建块时销毁。本地静态对象则在第一次使用时创建,在程序结束时销毁。

动态分配对象(dynamically allocated object)的生命周期独立于其创建的位置,除非是显式销毁,否则将持续到程序结束。如何正确释放动态对象非常容易产生 bug。为了安全使用动态对象,C++ 提供了两种智能指针来管理动态分配对象。智能指针保证会在没有任何地方引用了此对象时,释放其内存。

之前的程序只使用了静态(static)和堆栈(stack)内存。静态内存用于本地静态变量、类静态成员和定义在任何函数之外的变量。堆栈则用于函数内定义的自动对象。在静态和堆栈内存中分配的对象,其创建和销毁都由编译器管理。除此之外,每个程序还有一个内存池,这种内存被称为自由内存(free store)或堆(heap)。程序使用堆来分配给动态对象,即在运行时分配内存给对象。这种对象的生命周期由程序进行管理,代码中必须显式销毁不再使用的对象。

除非是必要的情况下,不应该直接管理动态内存,因为,这是非常容易出错的。

12.1 动态内存和智能指针

在 C++ 中使用 new 操作来分配和初始化动态对象,并返回一个指向对象的指针。delete 操作符则以此指针为操作数,销毁其指向的对象,并释放其内存。

动态内存容易出错的原因在于:很难在正确的时机释放内存。我们可能忘记释放并造成内存泄漏(memory leak)或者在指针依然在使用时释放其内存,这种时候指针指向的内存是无效的。

为了使得动态内存易于使用并且更加安全,新标准中提供了两个智能指针来管理动态对象。shared_ptr 允许多个指针指向同一个对象,unique_ptr 则拥有其指向的对象,因而是排外的。标准库还定义了 weak_ptr 表示对 shared_ptr 管理的对象的弱引用。所有这三个类都定义在 memory 头文件中。

12.1.1 shared_ptr

指针指针是模板类,创建智能指针需要提供指向的对象类型作为模板参数。如:

shared_ptr<string> p1;
shared_ptr<list<int>> p2;

默认初始化的智能指针表示空指针。

使用智能指针的方式于常规指针是一样的。解引用智能指针返回其指向的对象引用。当将智能指针用于条件语句中,效果相当于测试其是否是空指针。

以下是 shared_ptrunique_ptr 共有的操作:

  • shared_ptr<T> sp unique_ptr<T> up 指向 T 类型的对象的空指针;
  • p 将 p 用于条件中,如果其指向一个对象将返回 true;
  • *p 解引用 p 从而得到其指向的对象,如果没有 p 是空的,结果未定义;
  • p->mem 等同于 (*p).mem
  • p.get() 返回 p 中保存的对象指针。使用时需要当心:返回的指针所指向的对象可能被智能指针删除;
  • swap(p, q) p.swap(q) 交换 p 和 q 中的指针;

以下是 shared_ptr 特有的操作:

  • make_shared<T>(args) 返回一个类型为 T 的动态对象的智能指针,使用 args 进行初始化对象;
  • shared_ptr<T>p(q) p 是 shared_ptr q 的拷贝,将增加 q 的引用计数,q 中指针必须可以转为 T*
  • p = q p 和 q 是指向可转换指针的智能指针 shared_ptr。减少 p 的引用计数,并增加 q 的引用计数,当 p 的引用计数为 0 时,删除其所指向的动态对象的内存;
  • p.unique() 当 p 的引用计数是 1 时,返回 true,否则返回 false;
  • p.use_count() 返回 p 所指向对象的引用计数,可能是一个很慢的操作,主要用于调试目的;

make_shared函数

最安全的分配和使用动态内存的方式就是调用库函数 make_shared。这个函数分配并初始化动态对象,然后返回一个指向它的 shared_ptr 智能指针。make_shared 被定义在 memory 头文件中,它是一个模板函数,调用时需要提供需要创建的对象类型。如:

shared_ptr<int> p3 = make_shared<int>(42);
shared_ptr<string> p4 = make_shared<string>(10, '9');
shared_ptr<int> p5 = make_shared<int>();  //此时 p5 指向的对象将被值初始化
auto p6 = make_shared<vector<string>>(); //分配一个空的 vector<string> 对象

make_shared 使用其参数构建一个给定类型的对象,创建类对象时传的参数必须匹配其任一构造函数的原型,创建内置类型对象则直接传递其值。如果没有传递任何参数,则对象是值初始化的。

拷贝和赋值shared_ptr

当拷贝或赋值 shared_ptr 时,会相应更新各自对动态对象的引用计数。当拷贝 shared_ptr 时,计数增加,例如:当用于初始化另一个 shared_ptr 或者在赋值表达式中处于等号右边,或传递给函数、从函数中返回都会增加其引用计数。而当给 shared_ptr 赋予新值时,或者 shared_ptr 对象本身被销毁时,引用计数就会减少。

一旦引用计数变为 0 之后,shared_ptr 就会自动释放其指向的对象的内存。

auto r = make_shared<int>(42);
r = q;

赋值给 r,将增加 q 所指向的对象的引用,而减少 r 原本所指向对象的引用,最终结果将导致那个对象被销毁。

实现上是否使用计数器或者别的数据结构来跟踪到底有多少个计数器指向同一个对象。关键点在于 shared_ptr 类本身去跟踪有多少个智能指针指向同一个对象并且在合适的时机自动释放。

shared_ptr自动释放其指向的对象…

当最后一个指向对象的 shared_ptr 被销毁时,其指向的对象将自动销毁。销毁对象使用的成员函数是析构函数(destructor)。类似于构造函数,每个类都有析构函数。构造函数用于控制初始化,析构函数控制当对象销毁时发生什么。

shared_ptr 的析构函数递减其引用计数,当引用计数变为 0 时,shared_ptr 的析构函数将销毁其指向的对象,并释放其内存。

并自动释放与其相关动态对象的内存

shared_ptr 可以自动释放动态对象使得使用动态内存相当简单。

shared_ptr<Foo> factory(T arg)
{
    return make_shared<Foo>(arg);
}
void use_factory(T arg)
{
    shared_ptr<Foo> p = factory(arg);
}

当 p 被销毁时,其引用计数将递减。由于 p 是指向 factory 分配的动态内存的唯一指针,当 p 被销毁时,它会自动销毁其指向的对象,并且内存将被释放。而如果有任何其它 shared_ptr 指向这个对象,那么它就不会被释放内存。

由于内存只有到了最后一个 shared_ptr 销毁后才会释放,所以重要的是确保当不再需要动态对象时,shared_ptr 对象不会一直存在。一种可能保存不必要的 shared_ptr 是在将其放在容器中,之后又调整了容器使得不再需要所有元素,应当将不需要的元素擦除。

类与具有动态生命周期的资源

程序在以下三种情况下会使用动态内存:

  1. 不知道需要多少对象;
  2. 不知道需要的对象的精确类型;
  3. 在多个对象之间共享数据;

定义 StrBlob 类

代码见:StrBlob.cc

最好的实现新集合类型的方式是使用容器库来管理元素,这样可以让容器库来管理元素的内存。然而,在 StrBlob 中不能直接存储 vector ,原因在于 vector 需要在与 StrBlobPtr 共享。为了共享元素可以在其中一个销毁时依然存在,需要将 vector 放在动态内存中,并由 shared_ptr 进行管理。

值得注意的是 StrBlob 有一个以 initializer_list<string> 为参数的构造函数,这是新标准中给列初始化专门设计的。

12.1.2 直接管理内存

参考代码:new_delete.cpp

语言本身定义了两个操作符来分配和释放内存。new 用于分配,delete 用于释放。使用这两个操作符进行内存管理比之智能指针是更为易错的方式。并且,自己管理内存的类不能依赖于默认定义的拷贝、赋值和析构函数。因而,使用智能指针的程序将更加容易书写和调试。

只有当学会了如何定义拷贝、赋值和析构函数时,才能够直接管理内存,此刻就只用智能指针进行管理。

使用 new 来动态分配和初始化对象

在堆上分配的对象是不具名的(unnamed),new 没有任何方式可以给其分配的对象取名。相反,new 返回一个指向其分配的对象的指针。如:

//pi 指向动态分配的不具名的,未初始化的 int 值
int *pi = new int;
//ps 指向空字符串(调用默认构造函数得到)
string *ps = new string;

默认情况下,动态分配的对象是默认初始化的,这意味着内置类型或符合类型的对象是未定义值。类类型对象将执行默认构造函数进行初始化。

初始化动态分配对象可以使用直接初始化形式,可以使用 C++11 之前的括号形式,亦可以使用新标准下的列初始化形式(括弧形式)。如:

int *pi = new int(1024);
string *ps = new string(10, '9');
vector<int> *pv = new vector<int>{0,1,2,3,4,5,6,7,8,9};

如果在动态分配的对象后跟随一对空括号或空括弧,对象将是值初始化的。对于内置类型则为 0,对于类类型对象则调用其默认构造函数。如:

string *ps1 = new string; //默认初始化为空字符串
string *ps = new string(); //值初始化为空字符串
int *pi1 = new int; //*pi1 的值是未初始化的
int *pi2 = new int(); //*pi2 的值是 0

总是初始化动态分配的对象是一个好的习惯。

有时候可以在等号的右边使用 auto ,当使用 new 操作符时,而且括号中的只有一个参数时,编译器使用括号中的值类型去推断生成的动态对象的类型。此时将使用传入的参数去初始化动态对象,它们具有一样的值和类型。如:

//p 指向与 obj 同类型的对象,并且以 obj 为初始值
auto p1 = new auto(obj);
//以下写法是错误的!!!
auto p2 = new auto{a,b,c};

动态分配 const 对象

const int *pci = new const int(1024);
const int *pcs = new const string;

所有的常量,包括动态分配的常量都必须进行初始化。对于定义了默认构造函数的类类型的动态对象可以调用其默认构造函数进行隐式初始化,所以可以不用提供初始值。而任何其它类型,特别是内置类型以及没有默认构造函数的类类型都必须进行显式初始化。由于分配的对象是 const 的,所以返回的指针亦是指向 const 的对象。

内存耗尽

当内存耗尽时,new 操作符会抛出 bad_alloc 异常。可以通过使用另一种形式的 new 来禁止 new 抛出异常,这种新形式的 new 称为定位 new(placement new),这种新的表达式可以传递额外的参数给 new。如:

int *p1 = new int; //失败时抛出 std::bad_alloc
int *p2 = new (nothrow) int; //失败时返回空指针

这里传递给 new 一个标准库中的对象 nothrow,这个对象定义在 new 头文件中。nothrow 对象告诉 new 一定不要抛出任何异常,如果无法分配内存时就返回空指针。

释放动态内存

C++ 使用 delete 操作符来释放不再使用的动态内存,其操作数是一个指向需要释放的动态对象的指针。如:

delete p;

delete 表达式做了两件事:析构指针所指向的对象,释放其内存。

指针和 delete

传递给 delete 的指针必须指向动态分配内存或者是空指针。删除一个不是由 new 分配的内存指针,或者删除一个指针两次,结果将是未定义的。

int i, *pi1 = &i, *pi2 = nullptr;
double *pd = new double(33), *pd2 = pd;
delete pi1; //未定义行为,pi1 指向本地变量
delete pd;  //ok
delete pd2; //未定义行为,删除了两次
delete pdi2; //ok,删除空指针是可以的

通常编译并不知道一个指针指向的是静态的还是动态分配的对象。编译器也无法知道指针指向的对象是否已经被释放过了。几乎所有编译器都认为删除指针是合法的,即便它们本身的确是错误的。

尽管动态分配的 const 对象本身不能改变,对象却是可以被销毁的。通过在 const 动态对象的指针上调用 delete 将回收其内存。

动态分配的对象只能通过delete进行释放

通过 shared_ptr 分配的动态对象会在最后一个 shared_ptr 销毁时自动回收。但是直接通过 new 分配的动态内存是不会自动回收的。由内置指针管理的动态对象只有显式删除才会回收内存。直接返回动态内存指针的函数将释放的内存的责任交给了调用者,调用者必须记得回收这一部分内存。但是经常会有调用者忘记这个事。

与类类型不同的事,当内置类型对象销毁时不会发生任何事情。特别是,当一个指针离开作用域,不会在其指向的对象上发生任何事情。如果指针指向了动态内存,这块内存不会自动释放。

管理动态内存是易错的

用 new 和 delete 来管理内存有三个易错的地方:

  1. 忘记 delete 内存,从而造成内存泄漏;测试内存泄漏是十分困难的,几乎只有在运行足够长并且内存被耗尽时才能发现;
  2. 在动态对象被删除之后依然还在使用;这种错误可以通过在释放内存后将指针设为空来解决;
  3. 删除同一块内存两次;这在同时有两个指针指向同一块动态内存时可能会发生。如果其中一个指针已经被删除了,然而其它的指针并不知道这块内存已经被删除了。这种错误通常很容易发生,但是很难定位;

通过在任何情况下都使用智能指针可以避免以上的错误,智能指针只有在没有其它智能指针仍然在指向这块动态内存时,才会销毁这块内存。

在删除指针所指向的对象后,重置指针

当删除一个指针时,指针将变得无效。尽管指针已经无效了,在很多机器上指针依然保有那个已经被释放的内存的地址。此时指针已经变成了悬挂指针(dangling pointer),即一个指向不存在的对象的指针。

悬挂指针的问题与未初始化的指针是一样的。通过删除内存后 nullptr 赋值给指针,可以保证当删除后指针不指向任何对象。

尽管这样做了依然不能彻底解决问题,原因在于还有别的指针可能会指向相同的内存。仅仅只重置那个删除内存的指针,并不会对其它指针造成任何影响,这样其它指针依然可能会被错误使用,从而访问已经被删除的对象。在真实系统中想要找出所有的指向同一个内存的指针是非常困难的。

12.1.3 将 shared_ptr 运用于 new

当不初始化智能指针,其将被初始化为空指针。如果将智能指针初始化为一个从 new 返回的指针,那么此智能指针将接管这块动态内存。如:

shared_ptr<double> p1;
shared_ptr<int> p2(new int(42));

其它的定义和改变 shared_ptr 的方式:

  • shared_ptr<T> p(q); p 将管理由内置指针 q 所指向的对象,q 必须指向由 new 分配的的内存,并且可以转为 T*
  • shared_ptr<T> p(u); p 接管 unique_ptr u 的对象所有权,并使得 u 为空指针;
  • shared_ptr<T> p(q, d); p 接管指针 q 所指向的对象所有权,p 将使用可调用对象 d 替换 delete 来释放 q 所指向的对象;
  • shared_ptr<T> p(p2, d); p 是 shared_ptr p2 的拷贝,增加引用计数,但是 p 用可调用对象 d 代替 delete 来释放内存;
  • p.reset() p.reset(q) p.reset(q, d) 如果 p 是指向对象的唯一指针,reset 将会释放 p 所指向的对象。如果提供了额外的内置指针 q ,将在之后使得 p 指向 q,否则将使 p 变为空指针。如果提供了可调用对象 d,将调用 d 而不是 delete 来释放内存;

以上以内置指针作为参数的构造函数是 explicit 的,因而,不能隐式将内置指针转为智能指针。我们必须使用直接初始化的形式初始化智能指针。

默认情况下用于初始化智能指针的内置指针必须指向动态内存,因为,默认情况下智能指针会使用 delete 来释放相关的对象。我们可以将智能指针指向其它类型的资源,然而,这样做必须提供我们操作来替换 delete 。

不要混合使用内置指针和智能指针

shared_ptr 只能与其它拷贝自己的 shared_ptr 配合使用。当在创建动态对象时就将其它与一个 shared_ptr 绑定,将没有机会将这个对象与另外一个独立的 shared_ptr 进行绑定。如果混合使用内置指针,将导致在智能指针已经释放掉了内存,而指针并不知道这种情况,结果将导致指针变为悬挂指针。一旦将 shared_ptr 与内置指针绑定,这个智能指针将获取内存的所有权,从而,不应该再继续使用内置指针来访问那块内存了。如:

void process(shared_ptr<int> ptr)
{
    //ptr 将销毁其指向的对象
}
int *x = new int(1024);
process(x);
int j = *x; //未定义的,x 是悬挂指针

使用内置指针访问智能指针所拥有的对象是很危险的,因为我们不知道对象将在何时被销毁。

不要使用 shared_ptr.get() 得到的指针用于初始化或者赋值给另外一个智能指针。这个函数的目的在于某些以前的函数并不接受智能指针作为参数,get 返回的指针一定不能在外部被删除。尽管编译器不检查,但使用此返回的指针绑定到另外一个智能指针是一个错误。

12.1.4 智能指针和异常

之前提到过使用异常的程序需要保证,当异常发生时资源可以被回收,其中一个简单地方法就是使用智能指针。智能指针可以保证即使是在函数异常退出地情况下依然会正确释放不再使用地内存。而内置指针则不做任何事情,由于在函数外部根本无法访问这块内存,从而就造成了内存泄漏。

void f()
{
    shared_ptr<int> sp(new int(42)); //即使发生异常亦能正确释放
}
void f2()
{
    int *ip = new int(42);
    delete ip; //发生异常将无法回收其内存
}

有一些类被设计用于 C 和 C++ 的胶水层时,通常需要用用户自己手动释放内存。可以使用用于管理动态内存的技术来管理这些资源。即将资源交给 shared_ptr 进行管理。首先需要定义一个函数来替代 delete 操作符,可以调用这个删除器(deleter)并以存储在 shared_ptr 中的指针作为参数来进行实际的清理工作。如:deleter_func.cc 展示了用法。

智能指针只有被恰当的使用才能发出作用,以下是一些约定:

  • 不要使用相同的内置指针去初始化超过一个智能指针;
  • 不要使用删除 get 函数返回的指针;
  • 不要用 get 函数返回指针去初始化或 reset 别的智能指针;
  • 当使用 get 函数返回的指针时,应当记住当最后一个智能指针销毁时,这个指针会变得无效;
  • 使用智能指针管理资源而不是内存时,记得传递一个删除器(deleter)过去;

12.1.5 unique_ptr

unique_ptr 具有其指向的对象的所有权。不似 shared_ptr ,只有一个 unique_ptr 指向一个对象,其将独占对象。对象将在 unique_ptr 销毁时被释放。

以下是 unique_ptr 特有的操作:

  • unique_ptr<T> u1 unique_ptr<T, D> u2 定义两个 unique_ptr 空指针,它们可以指向类型为 T 的对象。u1 使用 delete 来释放指针,u2 使用类型为 D 的可调用对象进行释放;
  • unique_ptr<T, D> u(d) 定义 unique_ptr 空指针,使用类型为 D 的可调用对象 d 进行对象释放;
  • u = nullptr 删除 u 所指向的对象,并使其称为空指针(只接收 nullptr 类型);
  • u.release() 交出 u 所指向对象的控制权,返回 p 所指向对象的内置指针,并使得 u 为空指针;
  • u.reset() 删除 u 所指向的对象;
  • u.reset(q) u.reset(nullptr) 删除 u 所指向的对象,并使得 u 指向内置指针所指向的对象,否则使得 u 为空指针;

unique_ptr 没有类似于 make_shared 的函数,相反,我们通常直接将 unique_ptr 直接与 new 返回的内置指针绑定。与 shared_ptr 一样,只能使用直接初始化对其进行初始化。因为 unique_ptr 拥有其指向的对象,所以,unique_ptr 不支持拷贝和赋值。

调用 release 会切断 unique_ptr 和对象之间的关系,返回的指针通常用于初始化或赋值另外一个智能指针。这样对象所有权就从一个智能指针转移到了另外一个智能指针,然而,如果我们不使用另外一个智能指针来接收这个指针,将由程序员来管理这个资源。

传递和返回 unique_ptr

不能拷贝 unique_ptr 的原则有一个例外就是可以拷贝或赋值一个即将销毁的 unique_ptr,在新标准中这叫移动(move),将在第十三章进行讨论。如:

unique_ptr<int> clone(int p) {
    return unique_ptr<int>(new int(p));
}
unique_ptr<int> clone(int p) {
    unique_ptr<int> ret(new int(p));
    return ret;
}

新的程序应该放弃使用 auto_ptr 的冲动,auto_ptr 是一个不好的设计,不能用于容器中,不能从函数中返回。

12.1.6 weak_ptr

weak_ptr 是一种不控制其指向的对象的生命周期的智能指针,相反它指向 shared_ptr 管理的对象。将 weak_ptr 绑定到 shared_ptr 并不会改变其引用计数。当最后一个 shared_ptr 被销毁时,其管理的对象依然会被释放,即使 weak_ptr 依然指向这个对象。因此,称之为弱指针。

以下是 weak_ptr 的常用操作:

  • weak_ptr<T> w 创建 weak_ptr 的空指针,其指向 T 类型对象;
  • weak_ptr<T> w(sp) 创建指向与 shared_ptr sp 所指向相同对象的 weak_ptr ,T 必须与 sp 所指向对象的类型可以相互转换;
  • w = p p 可以是 shared_ptr 或者 weak_ptr ,赋值后 w 指向与 p 一样的对象;
  • w.reset() 使得 w 为空指针;
  • w.use_count() 返回指向同一个对象的 shared_ptr 的个数;
  • w.expired() 如果没有 shared_ptr 指向对象时返回 true,否则返回 false;
  • w.lock() 如果已经过期,则返回一个空的 shared_ptr ,否则返回指向该对象的 shared_ptr

使用 weak_ptr ,可以在不影响其指向的对象的生命周期的情况下,安全的访问该对象。

12.2 动态数组

有时候希望在程序中可以一次性分配一个数组的内存。C++ 提供了两种方式来这样做:通过新的 new ,或者通过模板类 allocator 来分离分配和初始化过程。一般来说 allocator 将得到更好的性能以及更灵活的内存管理。

然而,除非是希望直接管理动态数组,使用标准库中容器通常是更好的方式。使用容器将更加简单,更少的 bug 而且具有更好的性能。而且,可以使用默认定义的拷贝、赋值和析构函数。而动态分配数组的类则需要自己定义这些函数来进行相应的内存管理。

除非你直到如何定义拷贝、赋值和析构函数,不要在类中使用这些函数。

12.2.1 new 和数组

通过在类型后给出数组的长度,new 即为我们创建出一个动态数组,并返回指向首元素的指针。

int *pia = new int[get_size()];

方括号中的值必须是一个整数,但不必是常量。

可以用一个类型别名来动态分配数组,虽然没有用到方括号,但依然是数组形式的 new[]。如:

typedef int arrT[42];
int *p = new arrT;
delete [] p;  //即使使用了别名,其释放时依然需要方括号

当用 new 分配数组时,返回的并不是数组类型,而是指向首元素的指针。即使使用类型别名,返回的数据类型依然是元素的指针类型。这种情况下分配数组的事实表面上是不可见的,因为没有 [num],即使这样 new 返回的依然是元素的指针。

由于分配的内存不是数组类型,所以根本不能调用 beginend 标准函数。这些函数需要知道数组的维度才能返回指向首元素和尾后元素的指针。由于同样的原因,不能对动态数组使用范围 for。

初始化动态分配的数组

通常由 new 分配的对象,不论是单个对象还是数组,都是默认初始化的。可以在分配数组后加上括号使其进行值初始化,如:

int *pia = new int[10]; //全部是未定义值
int *pia2 = new int[10](); //值初始化为 0

在新标准下可以使用括弧中的值对动态分配的数组进行列初始化。如:

int *pia3 = new int[10]{0,1,2,3,4,5,6,7,8,9};

如果给的值比数组的长度小,其余的元素将被值初始化。如果多于数组长度,则无法编译通过。值得注意的是不能在括号中填入初始值来初始化元素。

动态创建空数组是合法的,当用 0 作为数组长度而执行 new 操作时,返回的合法的非 0 指针。这个指针被保证不与任何其它 new 返回的指针值一样。这个指针类似于尾后指针,其可以与其它指针进行比较、可以加减 0、可以减去自己产生 0,但是不能用此指针进行解引用。

释放动态数组

与释放单个对象不一样的是,释放动态数组必须加上 [] 来指示当前释放的是数组。如:

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

数组中元素以相反的顺序进行析构,即第一个元素最后析构。当所有元素都析构之后,整个内存被回收。当删除数组时,空方括号是必不可少的。它告诉编译器后面的指针 pa 指向的是一个数组。如果在删除数组时没有提供方括号,或者在删除对象时提供了方括号,那么行为将是未定义的。

即便是对于使用了类型别名的数组,其被释放时依然需要使用方括号。原因在于指针指向的首元素,而不是类型别名表面上的类型对象。

编译器并不会在我们释放数组内存时忘记给出方括号而提示我们,或者当释放单个对象时却添加了方括号。然而结果确实未定义的。

智能指针和动态数组

标准库提供了数组版本的 unique_ptr 来管理动态数组的内存。为了使用这个类,需要在模板参数中的对象类型中加上方括号。如:

unique_ptr<int[]> up(new int[10]);
up.release();

unique_ptr 指向数组时,不能调用箭头或点号进行成员访问。毕竟,它指向的是一个数组而不是单个对象。另一方面,可以使用此指针进行下标操作来访问数组中的元素。如:

for (size_t i = 0; i != 10; ++i)
    up[i] = i;

以下是指向数组的 unique_ptr 特有的操作,除了不支持成员访问外,其它的操作与指向单个对象的 unique_ptr 是一样的:

  • unique_ptr<T[]>u u 可以指向一个动态分配的数组,元素类型为 T;
  • unique_ptr<T[]> u(p) u 指向动态分配的数组,此数组由内置指针 p 指向;
  • u[i] 返回位置 i 处的元素, u 必须是指向数组;

unique_ptr 不同,shared_ptr 没有提供直接管理动态数组的支持,如果想要使用 shared_ptr 就必须得自己提供删除器。如:

shared_ptr<int> sp(new int[10], [](int *p){ delete[] p; });
sp.reset();

12.2.2 allocator 类

限制 new 使用的原因是 new 既分配内存又要构建对象。相同的,delete 既要析构又要释放内存。当我们动态创建对象时,这种方式是合适的。但当我们需要分配一大块内存时,通常会在之后需要的时候进行构建对象,在这种情况下最好是将分配和构建分离开来。以下演示 new 的局限性:

string *const p = new string[n];
string s;
string *q = p;
while (cin >> s && q != p + n)
    *q++ = s;

这里有个问题就是可能根本不需要那么多元素,但是那些没用到的元素依然需要进行初始化。另外一个原因是那些用到的元素,马上又被新值给覆盖掉了。所以,这是一种浪费。更重要的某些类根本就没有默认构造函数,根本就不可能以这种方式分配动态数组。

allocator类

在 memory 头文件中定义了 allocator 类,其可以将分配和构建分开。它提供类型识别的分配未构建的内存。以下是 allocator 类支持的操作:

  • allocator<T> a; 定义一个可以分配 T 类型内存的 allocator 对象 a;
  • a.allocate(n) 分配足够容纳 n 个未初始化的 T 类型对象的内存;
  • a.deallocate(p, n) 释放 p 所指向的内存,其中 p 的指针类型必须是 T*,并且必须是之前由 allocate 分配的内存,n 必须是当时调用时传递的尺寸。所有这些已经构建过的对象都必须在调用此函数之前先被调用 destroy 函数进行析构;
  • a.construct(p, args) p 必须是指向类型 T 的裸内存的指针,args 则被传递给 T 类型的构造函数,args 必须符合其中一个构造函数的原型,这个构造函数将被用于构建 T 类型对象;
  • a.destroy(p) 在 p 指向的对象上进行析构,其中 p 必须是 T* 类型的;

allocator 是模板类,所以在定义 allocator 时需要提供对象类型作为模板参数。如:

allocator<string> alloc;
auto const p = alloc.allocate(n); //分配 n 个未构建的字符串

allocator 分配的内存是未构建的。新标准中允许调用 construct 成员函数来在指定位置构建对象。

auto q = p;
alloc.construct(q++); //构建空字符串
alloc.construct(q++, 10, 'c'); //q 是 cccccccccc
alloc.construct(q++, "hi"); //q 是 hi

使用没有构建的对象是一种错误。必须在构建之后才能使用对象。当使用完毕后必须调用 destroy 进行析构,destroy 函数以指向对象的指针为参数,调用其析构函数。如:

while (q != p)
    alloc.destroy(--q);

只能对已经构建的对象进行析构,如果对未构建过的对象进行析构结果将是未定义的。已经被析构的对象占用的内存,可以被用于别的对象,或者将其返回给系统。通过 deallocate 来释放整个内存,如:

alloc.deallocate(p, n);

传递给 deallocate 的指针一定不能是空指针,且必须指向由 allocate 分配内存所返回的指针,并且 n 必须与传递给 allocate 进行分配时一致。

复制和填充未初始化的内存

allocator 类定义了两个可以构建对象的算法,以下这些函数将在目的地构建元素,而不是给它们赋值:

  • uninitialized_copy(b, e, b2) 从由迭代器 b 和 e 指示的元素范围拷贝到由 b2 迭代器所指示裸内存。b2 必须是由 allocate 分配的,并且足够容纳拷贝进来的数据;
  • uninitialized_copy_n(b, n, b2) 从迭代器 b 开始拷贝 n 个值到迭代器 b2 所指示的裸内存中。限制与上面一致;
  • uninitialized_fill(b, e, t) 在有迭代器 b 和 e 指示的范围内,填充 t 的拷贝;
  • uninitialized_full_n(b, n, t) 在从迭代器 b 开始 n 个元素的裸内存上填充 t 的拷贝;

与 copy 不同的是以上 uninitialized_copy 是在目的地进行构建而非赋值,与 copy 一样,它也返回递增后的目的地迭代器。

关键术语

  • 分配器(allocator)标准库中的类,用于分配未构建的内存;
  • 悬挂指针(dangling pointer):一个指向已经被回收的内存的指针,继续使用此指针是未定义行为,并且错误非常难定位;
  • 删除器(deleter):传递给智能指针用于替换 delete 来销毁指针绑定的对象;
  • 定位 new(placement new):new 的一种形式,具有额外的参数传递给 new 后的括号,如:new (nothrow) int 告诉 new 不能在无法分配内存时抛出异常;

Leave a Reply

Your email address will not be published. Required fields are marked *