6.1 动态内存管理概念

之前我们在介绍对象生命周期时谈到过了编译器在分配内存时根据不同策略将系统内存分为三种:

  • 静态存储区(static)
  • 动态存储区/栈区(stack)
  • 自由空间区(free store)/堆区(heap)

前两个是由编译器自主控制分配和释放的,而最后一个堆区里的对象,是由我们自己的代码来控制其对象的生存期。自由空间区的对象也叫做动态内存对象,所以我们这章所介绍的就是对动态内存的管理。

6.11 动态内存管理的基本操作

一般来说,动态内存的管理有两种基本操作:

  • 内存分配
  • 内存释放

内存分配就是获取一块原始的,未被使用的动态内存空间。 内存释放就是将这块动态内存的使用权还给系统,表示不需要使用里面的数据了,以供系统作其他用途。

6.12 对象的基本操作

而大多数时候,动态内存的操作往往会跟着对象的某种基本操作:

  • 对象创建(构造)
  • 对象销毁(析构)

对象创建是指在所给的动态内存中建立一个给定的对象,并对该对象进行初始化。 对象销毁是指释放该动态内存对象所使用的所有资源(也就是对该对象所使用的其他动态内存对象执行对象销毁和内存释放的操作)。

对象销毁不等于内存释放,一个动态内存对象的销毁并不会释放该对象本身的内存。 也就是调用某对象的析构函数只会销毁该对象,不会释放该对象的内存。

在一个动态内存对象没有被销毁时,最好不要直接释放该对象的内存,因为==释放该对象的内存仅仅是对该对象的内存的释放,而没有释放该对象所使用的其他对象的内存==,导致这些对象的内存无法被释放了。 所以对动态内存对象的管理,应该是先对该对象执行销毁,然后再是释放该对象的内存。

6.13 动态内存管理工作的步骤

所以,对于动态内存对象的构建以及用完后的对象清理工作,大多数管理工作是以以下这4个步骤进行的:

  1. 内存分配
  2. 对象构造
  3. 对象销毁
  4. 内存释放

任何一个步骤如果有省略或者步骤顺序不对,都有可能会造成动态内存管理操作的失败以及出现未定义行为。

在堆区分配的内存是无名的,因此无法为其中创建的对象命名。

6.14 动态内存管理方法分类

根据动态内存操作与对象操作的组合,动态内存管理的方法可以分为三种:

  • 内存与对象操作分离
    • malloc, calloc等函数
    • allocator
  • 内存与对象操作组合
    • new, delete运算符
  • RAII
    • 智能指针
      • shared_ptr
        • weak_ptr
      • unique_ptr
  1. 内存与对象操作分离的管理方法是指动态内存的分配与释放与对象的创建与销毁是分开的。这意味着我们可以分配大块内存,但只在真正需要时才真正执行对象创建操作。

  2. 内存与对象操作组合的管理方法是指在执行动态内存分配时就自动创建了对象,在执行内存释放时自动先执行了对象销毁。

  3. RAII(Resource Acquisition Is Initialization)是指资源获取即初始化,也就是说:在内存分配时就自动创建了对象。但其实这还有一半意思是指:内存释放时执行对象的销毁操作。 所以运用RAII思想的动态内存管理的方法是将内存的操作和对象的操作一起结合起来,该方法创建的对象在创建时自动分配内存,不需要用时自动执行销毁和内存释放操作,而无需手动操作。

6.2 内存与对象操作分离的管理方法

内存与对象操作分离的管理方法是指动态内存的分配与释放与对象的创建与销毁是分开的。这意味着我们可以分配大块内存,但只在真正需要时才真正执行对象创建操作。

内存与对象操作分离的管理方法可以分为两种,一种是从C语言继承的,第二种是C++标准库的:

  • malloc, calloc等函数
  • allocator

6.21 malloc等函数

接下来介绍的几个函数是从C语言中继承的,它们都定义在cstdlib头文件中。

这几个函数只负责内存的分配与释放,不负责对象的创建与销毁,所以我们必须自己来操作对象的创建与销毁。

6.211 malloc函数

malloc函数接受一个表示待分配字节数的size_t(unsigned int),返回一个指向分配空间的第一个字节的地址的空类型指针,如果分配失败(如内存不足),则返回空指针。

malloc函数的函数声明为:

void* malloc(size_t size);

malloc函数一般配合sizeof运算符来创建特定类型的某个对象。

6.212 calloc函数

malloc函数类似,calloc函数接受一个表示分配内存空间数量size_t和表示每个内存空间所占的字节数的size_t,该函数分配的空间的地址是连续的。 返回一个指向分配空间的第一个字节的地址的空类型指针,如果分配失败(如内存不足),则返回空指针。

calloc函数的函数声明为:

void* calloc(size_t n, size_t size);

calloc函数一般配合sizeof运算符来创建以特定类型的对象为元素的类数组结构,所以该指针可以像指向容器元素的指针一样进行元素访问的操作。

// 分配含有5个int类型字节大小的内存空间
int *ptr_arr = (int*)calloc(5,sizeof(int));
// 对这5个空间进行对象创建
for (int i = 0; i < 5; ++i) 
    *(ptr_arr+i) = i + 5;
// 输出5 6 7 8 9
for (int i = 0; i < 5; ++i) 
    cout << *(ptr_arr+i) << " ";
6.213 realloc函数

realloc函数接受一个void*和表示分配字节数的size_t,该void*必须是malloc或者calloc返回的指针或者其指针的副本,否则会出错,如果是空指针则该函数直接新分配一个给定字节数的内存空间。 realloc函数将给定指针所指的所有分配内存的大小改为给定的分配字节数,并重新返回给定的指针(指向已修改大小的分配空间的第一个字节的地址的空类型指针),如果修改失败(如内存不足),则返回空指针。

calloc返回的指针使用时,是将calloc所分配的n*size大小的空间修改为给定的分配字节数。

realloc函数的函数声明为:

void* realloc(void* ptr, size_t size);

6.213 free函数

free函数接受一个void*,该空类型指针必须是malloc或者calloc返回的指针或者其指针的副本,否则会出错,如果是空指针则该函数不做任何操作。 free函数将给定指针所指的内存释放。

free函数的函数声明为:

void free(void* ptr);

6.214 malloc等函数的使用

因为这几个函数只负责内存的分配与释放,所以我们要自己对其内存进行对象创建与销毁。

因为这几个函数都是返回指针,所以只能通过有关指针的操作来进行动态内存对象的创建:

  1. 用对应对象来赋值。 用对应对象来赋值需要该类型的拷贝赋值运算符不能是删除的。
  2. 调用对应类型的构造函数来赋值(临时对象)。 调用对应类型的构造函数来赋值需要该类型的拷贝或者移动赋值运算符不能是删除的。
  3. 用定位new表达式直接在内存中创建对象。 用定位new表达式直接在内存中创建对象是最好的方法,只要对应类型有构造函数就能创建对象,且该方法对对应类型没有前两个方法所需的限制。
#include <iostream>
#include <string>
#include <cstdlib>
using namespace std;
/* 使用定位new表达式的创建方法:
定义了一个含有
非静态的string类型的数据成员
的类型Te,该类型可以用定位new表达式创建对象
*/
struct Te
{
    int ins;
    static string sta_str;
    string str;
    Te(int ins = 18, string str = "Te_str"): ins(ins), str(str) 
    { cout << "constructed!\n"; }
    Te& operator=(const Te& cls) = delete;
    ~Te() { cout << "deleted!\n"; }
};
string Te::sta_str = "Te_static_str";

int main()
{
    // 分配内存
    void *vp = malloc(sizeof(Te));
    // 创建对象
    Te *tp = new(vp) Te(30,"good_created");
    /* 两个指针保存同一个地址,输出
    constructed!
    good_created good_created
    deleted!
    */
    cout << (*(Te*)vp).str << " " << (*tp).str << endl;
    // 销毁对象
    tp->~Te();
    // 释放内存
    free(vp);
    return 0;
}

要注意用1,2这两个方法时,malloc等函数不能创建含有非静态的类类型数据成员的类型的对象,否则会出错。

#include <iostream>
#include <string>
#include <cstdlib>
using namespace std;
/* 使用第二种创建方法:
定义了一个含有
非静态的string类型的数据成员
的类型Te,此时
该类型不能用
malloc等函数创建对象*/
struct Te
{
    int ins;
    string str;
    Te(int ins = 18, string str = "Te_str"): ins(ins), str(str) {}
    Te& operator=(const Te& cls)
    {
        this->ins = cls.ins;
        this->str = cls.str;
        return *this;
    };
};

Te *ptr = (Te*)malloc(sizeof(Te));
// 出错!
*ptr = Te();
cout << ptr->str;
free(ptr);
#include <iostream>
#include <string>
using namespace std;
/* 使用第二种创建方法:
定义了一个类类型Te,
该类型没有
非静态的类类型数据成员。
所以该类型可以用
malloc等函数创建对象。*/
struct Te
{
    int ins;
    static string str;
    Te(int ins = 18): ins(ins) {}
    Te& operator=(const Te& cls)
    {
        this->ins = cls.ins;
        return *this;
    };
};
string Te::str = "Te_static_str";

Te *ptr = (Te*)malloc(sizeof(Te));
// 正确:用调用对应类型的构造函数来赋值
*ptr = Te();
// 输出Te_static_str
cout << ptr->str;
free(ptr);

要记住当分配的内存不需要再使用后要即使释放。

6.22 allocator类

标准库allocator类定义在头文件memory中,它帮助我们将内存分配和对象构造分离开来。

malloc等函数不一样。allocator类既提供内存分配和释放操作,也提供对象创建和销毁操作。

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

一个allocator对象只能分配其给定类型的对象。

allocator<string> alloc; // 可以分配string的allocator对象
auto const p = alloc.allocate(n); //分配n个string类型大小的空间。
6.221 allocator类的操作

以下是allocator类所支持的操作:

  • 内存分配
    1. 指明可以分配的对象类型:

      allocator<指明的类型T> 对象名a

      定义了一个名为a的allocator对象,它可以为类型为T的对象分配内存。

    2. 指明分配对象的数量:

      对象名a.allocate(数量n)

      分配一段原始的、未构造的内存,含有n个类型为T的对象的空间,并返回一个指向该内存开始地址的T*类型的指针。

  • 对象创建

    对象名a.construct(指针p, 需要传递给指明类型构造函数的实参们args)

      p必须是指向a所的分配的内存的类型为T*的指针(不能是指向其他allocator对象或其他对象的指针);arg被传递给类型为T的构造函数,用来在p指向的内存中构造一个对象。
    
  • 对象销毁

    对象名a.destroy(指针p)

      p必须是指向a所的分配的内存的类型为T*的指针(不能是指向其他allocator对象或其他对象的指针),该函数对p指向的对象执行析构函数。
    
  • 内存释放

    对象名a.deallocate(指针p, 数量n)

      p必须是先前由a.allocate函数所返回的指针或者其副本;n必须是先前a.allocate函数所指定的数量。
      释放从T*指针p中地址开始的内存,这块内存的大小为n\*sizeof(T)。
    

calloc函数类似,我们对于allocate函数返回的指针,可以像指向容器元素的指针一样进行元素访问的操作。

和之前提到的步骤一样,我们在使用allocator类构建动态内存对象时也需要按照这四个步骤进行。

// 内存分配:分配一个能保存5个string类型大小的空间
allocator<string> strs;
string* p = strs.allocate(5);
// 对象创建:遍历每个string空间并在其中构造string对象
for (int i = 0; i < 5; ++i)
    strs.construct(p+i, "str" + to_string(i+5));
// 遍历每个string空间并输出对应string对象的值
// 输出为str5 str6 str7 str8 str9
for (int i = 0; i < 5; ++i)
    cout << *(p+i) << " ";
// 对象销毁:遍历每个string空间并销毁在其中的string对象
for (int i = 0; i < 5; ++i)
    strs.destroy(p+i);
// 内存释放:释放之前分配的保存5个string类型大小的空间
strs.deallocate(p, 5);

不能使用未构造对象的内存,其行为是未定义的。

我们只能对真正构造了对象的内存空间进行destroy操作,否则行为是未定义的。

当某空间的对象被销毁后,还可以重新使用这部分内存來保存其他同类型的对象。

6.222 allocator类的快捷对象构建操作

标准库还为allocator类定义了两种算法,可以在未构造对象的内存中创建对象,它们都定义在头文件memory中。

allocator2

6.3 内存与对象操作组合的管理方法

内存与对象操作组合的管理方法是指:

  1. 在执行动态内存分配操作后,该操作自动创建了给定类型的对象。
  2. 在执行内存释放操作时,该操作自动先执行了给定对象的销毁操作。

所以我们在使用这些管理方法时,就不必考虑对象的构造与销毁了。

内存与对象操作组合的管理方法有一种,这种操作支持某部分的自定义,接下来会来介绍这种操作:

6.31 new和delete运算符

C++语言定义了两个运算符来分配和释放动态内存。

运算符new分配内存并创建对象。 delete销毁所给指针所指的对象并释放该对象的内存。

6.311 new表达式

new表达式的使用形式为:

new 类型(包含类型修饰符) (可选 显式初始化)

new表达式一个所给类型尺寸大小的内存并在其中创建一个该类型的对象。 new表达式返回一个指向该类型对象的指针。

int *pi = new int; // pi指向一个动态分配的、默认初始化的无名int对象。

和普通的声明或定义语句一样,new里的类型可以有各种类型修饰符。

int ins = 8;
int *pi = &ins;
// 分配并初始化一个int*
int **ppi = new int*(pi);
// 输出8
cout << **ppi;
// 分配并初始化一个const int
const int *pci = new const int(1024);

在没有给定显式初始化时,动态分配的对象是默认初始化的,所以对于默认构造函数的类型,必须要显示初始化。

string *ps = new string; // 初始化为空string
int *pi = new int; // pi指向一个未初始化的int

对给定类型的显式初始化,只能用直接初始化的方式进行初始化。 所以调用类型的值初始化要在类型后面加圆括号()或花括号{}

和一般变量的初始化一样,用列表初始化时,列表所给的初始值数量不能超过所给类型所规定的初始值数量。

string *psl = new string; //默认初始化为空string 
string *ps = new string(); //值初始化为空string 
int *pil = new int; //默认初始化;*pil的值未定义 
int *pi2 = new int(); //值初始化为0; *pi2为0
int *pi3 = new int{}; //值初始化为0; *pi3为0
int *pi4 = new int{3}; //初始化为3; *pi4为3
int *pi5 = new int{3,9}; //错误:初始化值数量过多。

我们可以用auto类型来让编译器来自动推断所要分配的内存以及创建对象的类型,和一般变量的初始化一样,要显式初始化动态对象才能使用auto。

// 正确:初始化为36; *pi为36
int *pi = new auto{36};
// 正确:初始化为3.66; *pd为3.66
double *pd = new auto(3.66);
// 正确:初始化为strs; **ps为strs
const char **ps = new auto("strs");
// 错误:用auto必须要显式初始化。
int *pi2 = new auto;
6.312 delete表达式

delete表达式是专门用于释放new表达式所分配的内存的。 delete销毁所给指针所指的对象并释放该对象的内存。

delete表达式的形式为:

delete 指针

我们传递给delete的指针必须是指向new所分配的内存,或者是一个空指针。 释放一块并非new分配的内存,或者将相同的指针值释放多次,其行为是未定义的。

int i, *pil = &i, *pi2 = nullptr;
double *pd = new double(33), *pd2 = pd; 
delete i; //错误:i不是一个指针
delete pil; //未定义:pil指向一个局部变量 
delete pd; //正确
delete pd2; //未定义:pd2指向的内存已经被释放了
delete pi2; //正确:释放一个空指针总是没有错
6.313 new[ ]表达式

之前我们说过new里的类型可以有各种类型修饰符,那么理所当然,我们可以通过将其类型写为数组类型来创建一个动态对象的数组。 所以,当我们想要一次性分配多个同类型的对象时,就可以使用new[ ]表达式。

new[ ]的普通使用形式为:

new 类型(包含类型修饰符) [容量大小] (可选 显式初始化)

new[ ]表达式分配一个能包含所有所给容量大小数量的对象的内存并在其中创建所给容量大小数量的对象。 new[ ]表达式返回一个指向动态对象数组的首元素的指针。

和数组的初始化一样: 没有显式初始化时,其里面的元素都为默认初始化; auto不能用于数组类型。

和数组不一样的是: 类型修饰符[ ]内的==容量大小不能省略==。

和数组一样的是: 可以用空圆/花括号来值初始化。

new[ ]表达式的初始化规则与new表达式一样。

和数组类似,可以将其返回的指针当做指向容器内元素的指针一样进行元素访问。

// 正确:输出为2 6 8 1 3
int *p = new int[5]{2,6,8,1,3};
// 正确:值初始化,输出为0 0 0 0 0
int *p = new int[5]();
for (int i = 0; i < 5; ++i)
    cout << *(p+i) << " ";

当定义一个动态对象的多维数组时,数组不一样的是,如果列表初始化的列表不为空,则初始值必须要嵌套才行,否则会出错。

// 正确:初始值已嵌套,输出为6 9 8 4 5 3 6 2 -8
int(*a2p)[3] = new int[3][3]{{6,9,8},{4,5,3},{6,2,-8}};
// 正确:两种定义的列表都为空,输出为0 0 0 0 0 0 0 0 0
int(*a2p)[3] = new int[3][3]{};
int(*a2p)[3] = new int[3][3]();
for (int i = 0; i < 3; ++i)
   for (int j = 0; j < 3; ++j)
       cout << *(*(a2p+i)+j) << " ";
6.314 delete[ ]表达式

delete[ ]表达式是专门用于释放new[ ]表达式所分配的内存的。 delete[ ]表达式先自动将所给指针中所保存的内存中的每个对象进行销毁(逆序销毁:即,最后一个元素首先被销毁,然后是倒数第二个,依此类推),然后释放所给指针中所保存的所有内存。

new[ ]表达式类似,delete[ ]表达式的普通形式为:

delete[ ] 指针

我们传递给delete[ ]的指针必须是指向new[ ]所分配的内存的第一个元素,或者是一个空指针。 释放一块并非new[ ]分配的内存,或者将相同的指针值释放多次,其行为是未定义的。

在释放一个数组指针时必须使用方括号,不能用普通的delete表达式,否则其行为是未定义的。

type def int arrT [42] ; // arrT是42个int的数组的类型别名
int *p = new arrT; //分配一个42个int的数组;p指向第一个元素
delete [] p; //方括号是必需的,因为我们当初分配的是一个
6.315 定位new表达式

有时候,我们为了一些操作,需要向new传递一些额外的参数,此时可以用new相关表达式的另一种形式,这种形式也叫做定位new(placement new)。 定位new表达式的形式为:

new (指针/nothrow对象) 类型(包含类型修饰符) (可选 [容量大小]) (可选 显式初始化)

定位new表达式传递的实参为nothrow对象时,是用于阻止内存异常的。

定位new表达式传递的实参为指针时,是用于在指针所指的地址中创建给定类型的对象的。

定位new表达式只能额外传递最多一个参数,且必须是指针(或者能隐式转换成指针)或者nothrow对象。

接下来会详细介绍这两个用途。

6.3151 定位new用于阻止内存异常

badallocnothrow都定义在头文件new中。

虽然现代计算机通常都配备大容量内存,但是自由空间被耗尽的情况还是有可能发生。 一旦一个程序用光了它所有可用的自由空间内存,new相关表达式就会失败。 默认情况下,如果new不能分配所要求的内存空间,它会抛出一个类型为bad_alloc的异常。

所以为了阻止new相关表达式的表达式发出异常,我们可以使用定位new表达式并传递一个由标准库定义的名为nothrow的对象。 如果将nothrow传递给new,则new相关表达式就不会再抛出异常了,如果这时的new不能分配所需内存,则它会返回一个空指针。

//如果分配失败,new返回一个空指针
int *pl = new int; // 如果分配失败,new抛出std::bad_alloc
int *p2 = new (nothrow) int; //如果分配失败,new返回一个空指针
6.3152 定位new用于构造对象

当我们希望在某个内存地址上构造一个对象时,就可以使用定位new表达式并传递一个保存该地址的指针来在该地址上构造并初始化一个给定类型的对象。

此时new==不分配任何内存==,它只是在指定的地址上构造对象并初始化,然后返回一个和实参指针相同地址的,指向给定类型的指针。

要注意此时的定位new是构造一个新的对象,就算之前的地址有对象,定位new也是==直接抹除该对象数据==(不进行对象销毁,所以也就不调用其析构函数),然后在该地址上直接创建一个新的对象。

给定地址的字节大小必须不小于定位new所指定的类型的尺寸大小,否则会出错。

不管所给的地址中是否已经含有某对象的数据,定位new会从低位开始直接覆盖掉所需要的空间的所有数据,然后==调用所给类型的对应构造函数==来构造对象。

所以当给定地址的字节大小大于定位new所指定的类型的尺寸大小时,定位new所构造的对象地址只占低位所需要的字节,高位字节的数据仍然保留了下来。

要注意此时的定位new不分配任何内存,所以当定位new用于对动态内存中创建对象时,使用完毕的清理工作不应该由new对应的delete来做,应该由当初分配内存操作所对应的清理操作来进行清理工作。

void* sp = malloc(sizeof(string));
// 正确:sp所保存的地址不小于string类型的尺寸。\
构造一个string对象,并初始化为strs1
string* p = new(sp) string("strs1");
// 正确:两个指针保存同一个地址,所以输出strs1 strs1
cout << *p << " " << *(string*)sp << endl;
// 正确:因为内存是由malloc函数分配的\
,所以要调用对应的free函数。
free(sp);

void* sp2 = malloc(sizeof(100));
// 正确:sp2所保存的地址不小于string类型的尺寸。\
构造一个string对象,并初始化为strs2
string* p2 = new(sp2) string("strs2");
// 正确:两个指针保存同一个地址,所以输出strs2 strs2
cout << *p2 << " " << *(string*)sp2 << endl;
// 错误:内存是由malloc函数分配的\
,与new无关。
delete p2;

void* sp3 = malloc(0);
// 错误:sp3所保存的地址小于string类型的尺寸,会出错。
string* p3 = new(sp3) string("strs3");

这种用法的定位new表达式所传递的地址甚至不需要是动态内存,此时定位new可以用来对该地址重新创建一个对象。

// double对象dou被默认初始化,\
不能直接使用。
double dou;
// 所以重新在该地址上创建了一个dou对象,值为3.48。
double *dp = new(&dou) double(3.48);
// 输出3.48 3.48
cout << dou << " " << *dp;
6.316 自定义运算符函数

某些应用程序对内存分配有特殊的需求,因此我们无法将标准内存管理机制直接应用于这些程序。它们常常需要自定义内存分配的细节,比如使用关键字new将对象放置在特定的内存空间中。为了实现这一目的,应用程序需要自定义某些部分以控制内存分配的过程。

之前我们也谈到过,newdelete相关表达式是将内存操作与对象操作组合起来的操作。

6.3161 new和delete运算符工作原理

具体来说,对于newnew[ ]表达式,该操作有三个步骤:

  1. 调用一个名为operator new(或者operator new[ ])的标准库函数,分配一块足够大的、原始的、未命名的内存空间以便存储特定类型的对象(或者对象的数组)。
  2. 编译器运行相应的构造函数以构造这些对象,并为其传入初始值。
  3. 对象被构造完成后,返回一个指向该对象的指针。

对于deletedelete[ ]表达式,该操作有三个步骤:

  1. 对所给指针所指的对象或者数组中的元素执行对应的析构函数。
  2. 编译器调用名为operator delete(或者operator delete[ ])的标准库函数释放内存空间。

所以我们可以看出来在使用newdelete相关表达式时,newdelete运算符函数在其中只是充当内存方面的操作,而其他操作是由编译器自动执行。

要注意newdelete运算符与newdelete运算符函数不是同一个操作: newdelete运算符函数只有内存方面的操作;而newdelete运算符既有内存方面的操作,又有对象方面的操作。

6.3162 自定义new和delete运算符函数。

我们可以自定义newdelete的运算符函数。但是和其他运算符函数不同(比如operator=),这两个函数并没有重载newdelete运算符。实际上,我们根本无法自定义newdelete运算符的行为。

我们提供新的operator new函数和operator delete函数的目的在于改变内存分配的方式,但是不管怎样,我们都不能改变new运算符和delete运算符的基本含义。

当我们定义了newdelete的运算符函数时,编译器将使用我们自定义的版本替换标准库定义的版本。

可重载的运算符函数

不是所有的newdelete的运算符函数我们都可以重载,我们只能重载某些部分的运算符函数,以下是可重载的运算符函数的声明:

标准库定义了operator new函数和operator delete函数的8个重载版本。其中前4个版本可能抛出bad_alloc异常,后4个版本则不会抛出异常。

//这些版本可能抛出异常
void *operator new(size_t);
void *operator new[](size_t); 
void operator delete(void*) noexcept;
void operator delete[](void*) noexcept;

//这些版本承诺不会抛出异常
void *operator new(size_t, nothrow_t&) noexcept; 
void *operator new[](size_t, nothrow_t&) noexcept; 
void operator delete(void*, nothrow_t&) noexcept;
void operator delete[](void*, nothrow_t&) noexcept;
  • 对于可重载的所有运算符函数来说:
    1. 类型nothrow_t是定义在new头文件中的一个struct,在这个类型中不包含任何成员。

      new头文件还定义了一个类型为nothrow_t,名为nothrowconst对象,用户可以通过这个对象请求new的非抛出版本。

    2. 当我们将上述运算符函数定义成类的成员时,它们是隐式静态的。我们无须显式地声明static,当然这么做也不会引发错误。
  • 对于new运算符函数来说:
    1. 返回类型必须是void*,且第一个形参的类型必须是size_t且该形参不能含有默认实参。

      当编译器调用operator new时,是把存储指定类型对象所需的字节数传给size_t形参;当调用operator new[]时,传给size_t形参的则是存储数组中所有元素所需的空间。

    2. 我们可以为new运算符函数提供额外的多个形参,但是第一个额外的形参类型不能是void*

      此时,用到该函数的new表达式必须使用new的定位形式将实参传给额外形参。

  • 对于delete运算符函数来说:
    1. 返回类型必须是void,且第一个形参的类型必须是void*且该形参不能含有默认实参。

      执行一条delete表达式将调用相应的operator函数,并用指向待释放内存的指针来初始化void*形参。

    2. 我们可以为delete运算符函数提供额外的多个形参。

      此时,用到该函数的delete表达式的形式和new的定位形式类似,用于将实参传给额外形参。

    3. delete运算符函数定义成类的成员时,第一个额外的形参类型可以是size_t,此时,该形参的初始值是第一个形参所指对象的字节数,size_t形参可用于删除继承体系中的对象。 如果基类有一个虚析构函数,则传递给operator delete的字节数将因待删除指针所指对象的动态类型不同而有所区别。而且实际运行的operator delete函数版本也由对象的动态类型决定。
    4. 因为operator delete函数只是释放内存,基本不会抛出异常,所以当我们重载delete运算符函数时,需要使用noexcept异常说明符指定其不抛出异常。

运算符函数的查找顺序

我们既可以在全局作用域和命名空间中定义operator new函数和operator delete函数,也可以将它们定义为成员函数。

当编译器发现一条new表达式或delete表达式后,将在程序中查找可供调用的operator函数,查找顺序为:

  1. 如果newdelete所指定的对象的类型及其该类型的基类里有成员函数版本的对应运算符函数,则优先调用成员函数版本
  2. 如果没有成员函数版本,则比较其他可见版本的函数并匹配最优函数调用。

我们可以使用作用域运算符显式指定new表达式或delete表达式中的运算符函数。例如,::new只在全局作用域中查找匹配的operator new函数, ::delete与之类似。

6.4 RAII思想的管理方法

运用RAII思想的动态内存管理的方法是将内存的操作和对象的操作一起结合起来。 该方法模拟编译器在管理局部对象的操作。创建对象时自动分配内存,不需要用时自动执行销毁和内存释放操作,而无需手动操作。

运用RAII思想的动态内存管理的方法主要是运用智能指针。

接下来我们会主要介绍这几种智能指针:

  • shared_ptr
    • weak_ptr伴随类
  • unique_ptr

6.41 智能指针

智能指针类型都定义在memory头文件中。

标准库提供了叫做智能指针类型的类模板来管理动态对象。智能指针的行为类似常规指针,重要的区别是它负责自动销毁和释放所指向的对象。

其中提供了两大智能指针以及一个伴随类智能指针:

  • shared_ptr
    • weak_ptr伴随类
  • unique_ptr

这两种智能指针的区别在于管理底层指针的方式:

  • shared_ptr允许多个指针指向同一个对象。
  • unique_ptr则“独占”所指向的对象。
  • weak_ptr是一种弱引用,指向shared_ptr所管理的对象。

除了weak_ptr类指针能被shared_ptr类指针拷贝和赋值以外,不同类型的智能指针之间不能拷贝和赋值。 但除了unique_ptr类指针以外,同类型的智能指针之间能拷贝和赋值。

所有智能指针都不能指向非动态分配的对象。

所有智能指针都不支持算术运算,但除了weak_ptr类指针,其他同类型的指针支持关系运算。

以下是shared_ptrunique_ptr都支持的操作:

支持的操作|解释

  • |- shared_ptr<T> sp
    unique_ptr<T> up | 空智能指针sp或up,可以指向类型为T的对象
    (当空智能指针为shared_ptr类时,该指针计数器为0) p|将智能指针p用作一个条件判断,若p指向一个对象,则为true p|解引用智能指针p,获得它指向的对象 p->mem|访问智能指针p所指向对象的成员
    等价于(
    p).mem p.get()|返回一个指向智能指针p所指对象的对应类型的指针。
    (不会引起p计数器的变化)
    要小心使用,因为此时智能指针仍有该对象的内存管理所有权。因此,若智能指针释放了其对象,返回的指针所指向的对象也就消失了。 swap(p,q)
    p.swap(q)|交换同类型的智能指针p和q中的保存的地址。(还会交换p和q中计数器的值、分配器以及删除器等)
6.411 shared_ptr类

因为智能指针也都是模板,所以当我们创建一个智能指针时,必须提供指针可以指向的类型。

shared_ptr<string> pl; // shared_ptr,可以指向string
shared_ptr<list<int>> p2; //shared_ptr,可以指向int的list
6.4111 shared_ptr类的操作

以下是shared_ptr类独有的操作:

支持的操作|解释

  • |- make_shared<T> (args)|返回一个shared_ptr,指向一个动态分配的类型为T的对象。使用实参表args初始化此对象。
    如果args为空,则执行值初始化。 sharedptr<T> sp(sq/q)
    sharedptr<T> sp(q, d)|sq是非unique_ptr的智能指针;q只能是能够隐式转换为T类型的普通类型指针或空指针,且如果没有传递d,则必须保存的是动态内存;d是可调用对象。

    如果传递的是sq,则shared_ptr类型指针sp是sq的拷贝(先加1再拷贝,注意拷贝会将sp的分配器和删除器都设置为sq对应的),且sq中的计数器会加1。
    如果传递的是q,则指针sp保存q中的地址,并且sp的计数器设为1。
    如果传递了d,则sp将用d来代替关联对象的清理工作。 sp.reset()
    sp.reset(q)
    sp.reset(q,d)|q只能是能够隐式转换为T
    类型的普通类型指针或空指针,且如果sp没有改变其清理方式,则必须保存的是动态内存;d是可调用对象。

    不管调用的是哪种原型,首先,如果sp的计数器为0,则计数器不变;否则所有共享该指针所指的对象的指针(包括自己)的计数器都减1,如果减1后有任意一个共享该对象的指针的计数器为0,则对该对象执行清理工作。

    如果没有传递任何实参,则会将sp设为空智能指针。
    如果传递了q,则sp保存q中的地址,并且sp的计数器设为1。
    如果还传递了d,则sp将用d来代替关联对象的清理工作。 sp = sq|sp和sq必须都是shared_ptr,且其类型必须能相互隐式转换。

    此操作会递减sp的引用计数(若sp的引用计数变为0, 则将对其管理的对象执行清理操作),接着递増sq的引用计数,最后sp变为sq的拷贝(注意拷贝会将sp的分配器和删除器都设置为sq对应的)。 sp.use_count()|返回与shared_ptr指针sp共享对象的智能指针数量。 sp.unique()|若sp.use_count()为1,返回true;否则返回false。

默认初始化的智能指针中保存着一个空指针,我们还可以用其他shared_ptr指针和保存动态内存的指针来初始化或重置shared_ptr指针。

默认情况下,shared_ptr假定它们指向的是动态内存,所以在没有自定义清理操作时,其他类型的指针必须保存的是动态内存的才行,否则出现未定义行为。

6.4112 计数器

我们可以认为每个shared_ptr都有一个关联的计数器,通常称其为引用计数(reference count)。

引起shared_ptr的计数器变化的操作有两种:

  1. 当进行shared_ptr类之间的拷贝或赋值操作时。
  2. shared_ptr指针对象被销毁时。
    • 局部非静态shared_ptr指针对象离开其作用域时。
    • 临时shared_ptr指针对象完成其表达式时。
    • 动态分配的shared_ptr指针对象被销毁时。

当进行shared_ptr类之间的拷贝或赋值操作时,计数器就会有变化,具体变化步骤为: 1. 对于拷贝或赋值到的指针来说:如果该指针的计数器为0,则计数器不变;否则所有共享该指针所指的对象的指针(包括自己)的计数器都减1,如果减1后有任意一个共享该对象的指针的计数器为0,则对该对象执行清理工作。 2. 对于用于拷贝或赋值的指针来说:将所有共享该指针所指的对象的指针(包括自己)的计数器都加1。 3. 最后将拷贝或赋值到的指针的地址和计数器的值修改为用于拷贝或赋值的指针的地址和计数器的值。

shared_ptr指针对象被销毁时,和拷贝或赋值时一样: 如果该指针的计数器为0,则计数器不变;否则所有共享该指针所指的对象的指针(包括自己)的计数器都减1,如果减1后有任意一个共享该对象的指针的计数器为0,则对该对象执行清理工作。

shared_ptr指针的清理操作只有在该指针的计数器为0时才执行。

6.412 智能指针的清理操作

清理操作是指先销毁对象,然后释放内存的组合操作。

对于shared_ptr类和unique_ptr类来说,它们都有着自己的清理其指向对象的操作。 shared_ptr指针和unique_ptr类的清理操作在默认情况下是都使用delete运算符来释放它所关联的对象。

我们也可以自定义一种可调用对象来修改某智能指针指针的清理操作,用于智能指针的清理操作的可调用对象也叫做删除器。

用于删除器的可调用对象必须要满足以下条件:

  • 可调用对象的形参表的形参数量必须不小于1。
  • 形参表的第一个形参必须为智能指针所指定类型的指针,或者能隐式转换成所指定类型的指针。
  • 可调用对象的形参表如果还有其他形参,则这些形参必须都有默认实参。
6.413 weak_ptr伴随类

weak_ptr是一种不控制所指向对象生存期的智能指针,它指向由一个shared_ptr管理的对象。将一个weak_ptr绑定到一个shared_ptr不会改变shared_ptr的计数器。

一旦最后一个指向对象的shared_ptr被销毁,对象就会被释放。即使有weak_ptr指向对象,对象也还是会被释放,因此,weak_ptr的名字抓住了这种智能指针“弱”共享对象的特点。

以下是weak_ptr所支持的操作:

weak_ptr

我们只能用shared_ptr或者weak_ptr指针来拷贝和赋值weak_ptr的指针。

不能用除了shared_ptrweak_ptr的其他类型指针来拷贝和赋值weak_ptr的指针,否则会出错。

auto p = make_shared<int>(42);
weak_ptr<int> wp (p) ; // wp弱共享p; p的引用计数未改变

由于对象可能不存在,我们不要使用weak_ptr直接访问对象,最好调用lock函数,此函数检查weak_ptr指向的对象是否仍存在。如果存在,lock函数返回一个指向共享对象的shared_ptr;否则返回一个空shared_ptr指针。

// 在这段代码中,\
只有当lock调用返回true\
我们才会进入if语句体。\
if中,使用np访问共享对象是安全的。
if (shared_ptr<int> np = wp.lock()) //如果np不为空则条件成立
{ 
//在if中,np与p共享对象
}
6.414 unique_ptr类

一个unique_ptr“拥有”它所指向的对象。 与shared_ptr不同,某个时刻只能有一个unique_ptr指针指向一个给定对象。当某个unique_ptr指针被销毁时,它所指向的对象也会被清理。

6.4141 unique_ptr类所支持的操作

以下是unique_ptr类独有的操作。

支持的操作|解释

  • |- unique_ptr<T> up(q)
    unique_ptr<T, D> up(d)
    unique_ptr<T, D> up(q, d)|q只能是能够隐式转换为T类型的普通类型指针或空指针,且如果没有传递d,则必须保存的是动态内存;D是可调用对象的类型;d是D类型对应的可调用对象。

    如果传递了q,则up保存q中的地址;否则up为空unique_ptr。
    如果指定了D,传递了d,则将用d来代替关联对象的清理工作;否则用delete运算符来进行清理工作。
    (第二和第三种形式中,D和d缺一不可,不能只有其中一个) up.reset()
    up.reset(nullptr)
    up.reset(q)|q只能是能够隐式转换为T
    类型的普通类型指针或空指针,且如果up没有改变其清理方式,则必须保存的是动态内存;d是可调用对象。

    清理up所指向的对象
    如果没有传递任何实参或者只传递空指针,则会将up设为空智能指针。
    如果传递了q,则up保存q中的地址。 up = nptr|nptr必须是空指针(不能是空智能指针)。

    清理up所指向的对象,并将up设为空智能指针。 up.release()|返回一个指向指针up所指对象的对应类型的指针,并将up设为空智能指针。
    此操作是指针up放弃对该对象的内存管理所有权,所以不会再执行该对象的清理工作。

我们只能用保存动态内存的普通指针来拷贝unique_ptr指针。

默认情况下,unique_ptr假定它们指向的是动态内存,所以在没有自定义清理操作时,其他类型的指针必须保存的是动态内存的才行,否则出现未定义行为。

unique_ptr传递的删除器必须要在尖括号中unique_ptr指向类型之后指定删除器类型,且提供的可调用对象也必须是指定的类型。

6.415 智能指针的动态数组

对于shared_ptrunique_ptr类,标准库都提供了一个可以分配动态内存对象的数组的版本,为了用一个智能指针来管理动态数组,我们必须在对象类型后面跟一对空方括号(也就是<类型(可选 类型修饰符)[ ]>)。

// sp和up分别指向一个包含10个未初始化int的数组
shared_ptr<int[]> sp(new int[5]);
unique_ptr<int[]> up(new int[10]);
//自动用delete []销毁其指针
sp.reset();
up.release(); 

当一个智能指针指向一个数组时,我们不能使用点和箭头成员运算符。毕竟智能指针指向的是一个数组而不是单个对象,因此这些运算符是无意义的。另外,当一个智能指针指向一个数组时,我们可以使用下标运算符来访问数组中的元素。

以下是指向动态数组的shared_ptrunique_ptr类所特有的操作。除了不能使用点和箭头成员运算符,shared_ptrunique_ptr类所拥有的其他操作不变。

支持的操作 解释
unique_ptr<T[ ]> up
shared_ptr<T[ ]> sp
sp或up可以指向一个动态分配的数组,数组元素类型为T。
unique_ptr<T[ ]> up(q)
shared_ptr<T[ ]> sp(q)
q只能是能够隐式转换为T*类型的指向一个动态分配的数组的指针

动态内存sp或up指向指针q所指向的动态分配的数组。
up[i]/sp[i] up或sp必须指向一个数组。
返回up或sp所拥有的数组中位置i处的对象的引用。

智能指针来清理动态数组对象的默认操作为delete[ ]表达式,所以自定义管理动态数组的智能指针的删除器时要注意对应的形参类型。