第十二章 动态内存

除了自动和 static 对象之外,C++ 还支持动态分配对象。动态分配对象的生命周期与创建位置无关,必须显式释放才会销毁。动态对象的释放极易出错,为了更安全地使用动态对象,标准库定义了两个智能指针类型来管理动态分配对象。

静态内存 用来保存局部 static 对象、类 static 数据成员、定义在所有函数外的变量。栈内存 用来保存定义在函数内的非 static 对象。静态内存和栈内存中的对象由编译器自动创建和销毁:

  • 栈对象在定义它的程序块运行时才存在;
  • static 对象在使用前分配,在程序结束时销毁。

除此之外,每个程序还有一个内存池,称为自由空间 (free store)或 (heap),用来存储动态分配(dynamically allocate)对象------程序运行时分配的对象。动态对象的生命周期由程序控制,必须显式销毁。

动态内存与智能指针

C++ 动态内存管理通过一对运算符完成:

  • new,在动态内存中分配空间并返回指向该对象的指针。
  • delete,接受一个动态对象指针,销毁该对象并释放与之关联的内存。

不管是忘记释放内存导致内存泄漏,还是使用内存已释放的指针,都会出问题。标准库的智能指针 (smart pointer)类型可以更容易地管理动态对象。与常规指针相比,智能指针负责自动释放所指对象。头文件 memory 中定义了三种智能指针类型:

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

shared_ptr

创建智能指针时,必须在模板中提供指针所指类型。

cpp 复制代码
shared_ptr<string> p1;
shared_ptr<list<int>> p2;

if (p1 && p1->empty()) {
  *p1 = "hi";
}

标准库函数 make_shared 定义在头文件 memory 中,它在动态内存中分配一个给定类型的对象并以所接受参数初始化,返回指向该对象的 shared_ptr。如果没有参数,则执行值初始化。

cpp 复制代码
shared_ptr<int> p3 = make_shared<int>(42);
shared_ptr<string> p4 = make_shared<string>(10, '9');
shared_ptr<int> p5 = make_shared<int>();
auto p6 = make_shared<vector<string>>();

每个 shared_ptr 都有一个关联的计数器,称为引用计数 (reference count)。当拷贝或赋值时,引用计数会同步更新。拷贝 shared_ptr 时,比如初始化、函数传参、函数返回,计数器会递增;给 shared_ptr 赋新值或销毁时,比如退出作用域,计数器会递减;计数器变为 0 时,会自动释放所管理的对象。

采用计数器还是其它数据结构由标准库具体实现决定。

cpp 复制代码
auto p = make_shared<int>(42);
auto q(p);
auto r = make_shared<int>(42);
r = q;

shared_ptr 类通过析构函数 (destructor)自动销毁所指对象。每个类都有一个析构函数,用于控制对象销毁操作,一般用于释放对象所分配的资源。shared_ptr 的析构函数会递减引用计数,如果计数为 0,则会销毁对象,并释放内存。

cpp 复制代码
shared_ptr<Foo> factory(T arg) {
  return make_shared<Foo>(arg);
}

void use_factory(T arg) {
  auto p = factory(arg);
}

shared_ptr 如果存放于一个容器中,后续不再需要全部元素,只需其中一部分时,要用 erase 删除不再需要的那些元素。

使用动态内存出于以下三个原因之一:

  1. 不知道自己需要使用多少对象,比如容器。
  2. 不知道所需对象的精确类型。
  3. 需要在多个对象间共享数据。

使用动态内存的一个常见原因是允许多个对象共享相同状态。

某些类分配的资源具有与原对象相独立的生命周期。

直接管理内存

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

  • new 分配内存。
  • delete 释放 new 分配的内存。

自己管理内存的类与使用智能指针的类不同,它们不能依赖于类对象拷贝、赋值、销毁操作的任何默认定义。

自由空间分配的内存是匿名的,因此 new 表达式返回一个指向该对象的指针。

cpp 复制代码
int *pi = new int;
string *ps = new string;

默认情况下,动态分配的对象默认初始化,这意味着内置类型或复合类型对象的值是未定义的,类类型对象使用默认构造函数初始化。动态分配对象可以直接初始化:

  • () 构造初始化,无参数表示值初始化
  • {} 列表初始化
cpp 复制代码
int *pi1 = new int(1024);
int *pi2 = new int();

string *ps1 = new string(10, '9');
string *ps2 = new string();

vector<int> *pv = new vector<int>{0, 1, 2, 3};

动态分配对象最好都进行初始化。

动态分配对象类型可以使用 auto 从单一初始化器中自动推断出来。

cpp 复制代码
auto pia1 = new auto(42); // int *pia1
auto psa1 = new auto(string()); // string *psa1
auto pla1 = new auto{a, b, c}; // 错误

new 也可以分配 const 对象,动态分配 const 对象必须初始化。

cpp 复制代码
auto pci1 = new const int(42); // const int *pci1
auto pcs1 = new const string; // const string *pcs1

一旦程序用光了所有可用的内存,new 表达式就会失败,默认情况下,将会抛出 bad_alloc 类型的异常。new 可以接受额外参数,这种形式称为定位 new (placement new)。将标准库定义的名为 nothrow 的对象传递给 new 可以避免抛出异常,此时返回空指针来表示分配内存失败。

bad_allocnothrow 都定义在头文件 new 中。

cpp 复制代码
int *p1 = new int;
int *p2 = new (nothrow) int;

动态内存通过 delete 表达式 (delete expression)来释放。delete 表达式接受一个指针,销毁所指对象并释放相应内存。

cpp 复制代码
delete p;

delete 所接受的指针必须指向动态分配内存,或者是一个空指针。释放一个非 new 分配的内存,或者对同一指针重复释放多次,结果都是不确定的。delete 非指针对象,编译器将会报错;但由于编译器无法分辨指针所指对象是否为动态分配的,也不能分辨所指对象内存是否已被释放,因此这些操作可以通过编译,但结果不确定。

cpp 复制代码
int i, *pi1 = &i, *pi2 = nullptr;
double *pd = new double(33), *pd2 = pd;

delete i; // 报错
delete pi1; // 行为不确定
delete pd;
delete pd2; // 行为不确定
delete pi2;

auto pci = new const int(1024);
delete pci;

动态对象的生命周期直到被释放为止。返回指向动态内存的指针的函数需要调用者释放内存,这很容易出问题。由于内置类型对象被销毁时不会做额外操作,指针所指对象不会被自动释放,因此内置指针管理的动态内存在被显式释放前会一直存在。使用 newdelete 管理动态内存有三个常见问题,坚持只使用智能指针可以避免这些问题:

  1. 忘记 delete,导致 "内存泄漏"。这类问题难以定位,需要程序运行很长时间耗尽内存后,才能检测出问题。
  2. 使用已释放的对象。释放内存后将指针置空,可以检测此类问题。
  3. 同一内存重复释放,导致自由空间被破坏。比如,两个指针指向同一动态分配对象,各自都被释放一次。

空悬指针(dangling pointer)是指向曾保存数据对象但现已无效的内存的指针,它和未初始化的指针有同样的问题。为了避免空悬指针,可以:

  • 在指针即将离开作用域前释放相应内存。
  • delete 之后给指针赋值 nullptr;但是当多个指针指向同一内存时,这种方式也很难解决问题。

shared_ptrnew 结合使用

shared_ptr 有一个接受指针参数的 explicit 构造函数。可以使用 new 返回的指针来初始化智能指针。

cpp 复制代码
shared_ptr<int> p1 = new int(1024); // 错误
shared_ptr<int> p2(new int(42));

shared_ptr<int> clone(int p) {
  return new int(p); // 错误
}

默认情况下,初始化智能指针的普通指针必须指向动态内存,智能指针默认使用 delete 释放所关联的对象。如果要绑定到其它类型的资源的指针上,就必须要提供自己的操作代替 delete

shared_ptr 对对象析构的协调仅限于自身的拷贝,因此最好使用 make_shared 而不是 new,这样可以在分配对象时将 shared_ptr 与之绑定,避免无意中将一块内存绑定到多个独立创建的 shared_ptr

cpp 复制代码
void process(shared_ptr<int> ptr) {
  // ...
}

int *x(new int(1024));
process(shared_ptr<int>(x)); // x 所指内存会被释放
int j = *x; // x 为空悬指针

当一个 shared_ptr 绑定到内置指针时,内存管理将交给 shared_ptr,不应再使用内置指针来访问 shared_ptr 所指内存。

使用内置指针访问智能指针所指对象很危险,因为无法知道对象何时销毁。

智能指针的 get 成员返回一个指向所管理对象的内置指针。get 用于将指针的访问权限传递给代码,只有确定代码不会 delete 指针的情况下,才能使用 get;而且永远不要用 get 初始化另一个智能指针。

cpp 复制代码
shared_ptr<int> p(new int(42));
int *q = p.get();

{
  shared_ptr<int>(q);
}

int foo = *p;

shared_ptrreset 成员用于重置所指对象,并更新引用计数,必要时会释放所指的旧对象,常与 unique 成员一起使用。

cpp 复制代码
if (!p.unique()) {
  p.reset(new string(*p));
}

*p += newVal;

智能指针与异常

异常安全的程序需要在异常发生后正确释放资源。确保资源正确释放的一个简单方法是使用智能指针。而使用内置指针直接管理内存不会自动释放。

cpp 复制代码
void f() {
  shared_ptr<int> sp(new int(42));
  // 此处抛出异常
} // shared_ptr sp 自动释放

void f1() {
  int *ip = new int(42);
  // 此处抛出异常,下面释放内存的代码将不会执行
  delete ip;
}

不是所有的类都定义了析构函数,特别是那些为 C、C++ 两种语言设计的类,通常要求用户显式释放所使用的资源。使用 shared_ptr 可以避免资源泄漏。

默认情况下,shared_ptr 假定所指的都是动态内存,因此销毁时默认对所管理的指针执行 delete;也可以在创建 shared_ptr 时传入一个自定义的删除器 (deleter)函数代替 delete

cpp 复制代码
struct destination; // 正在连接的目标
struct connection; // 连接所需信息
connection connect(destination*); // 打开连接
void disconnect(connection); // 关闭连接
void end_connection(connection *p) {
  disconnect(*p);
};

void f(destination &d) {
  connection c = connect(&d);
  shared_ptr<connection> p(&c, end_connection);
}

使用智能指针必须遵守一些基本规范:

  • 同一内置指针不能初始化(或 reset)多个智能指针。
  • 不要对 get 返回的指针执行 delete
  • 不能 get 初始化或 reset 另一个智能指针。
  • get 返回的指针在最后一个相应的智能指针销毁后就无效了。
  • 使用智能指针管理的资源如果不是 new 分配的内存,必须传一个删除器。

unique_ptr

unique_ptr 独占所指对象,任一时刻只能有一个 unique_ptr 指向给定对象。unique_ptr 可以默认构造,也可以接受内置指针 explicit 构造。

cpp 复制代码
unique_ptr<double> p1;
unique_ptr<int> p2(new int(42));

unique_ptr 独占所指对象,因此不支持拷贝、赋值操作,必须通过 releasereset 将非 constunique_ptr 的指针所有权转移给另一个 unique_ptr

cpp 复制代码
unique_ptr<string> p1(new string("hello"));
unique_ptr<string> p2(p1); // 错误
unique_ptr<string> p3;
p3 = p2; // 错误

unique_ptr<string> p2(p1.release());
p3.reset(p2.release());

release 会切断 unique_ptr 与它所管理对象间的联系。release 返回的指针通常被用来初始化或绑定到另一个智能指针。如果不用另一个智能指针保存 release 返回的指针,就必须自己负责释放资源。

cpp 复制代码
p2.release(); // 错误,丢失了指针
auto p = p2.release(); // p 需要手动释放

禁止拷贝 unique_ptr 有一个例外,可以拷贝或赋值一个即将销毁的 unique_ptr,比如从函数返回一个 unique_ptr

cpp 复制代码
unique_ptr<int> clone(int p) {
  return unique_ptr<int>(new int(p));
}

较早的标准库中有一个 auto_ptr 类,它具有 unique_ptr 部分特性,而非全部。不能在容器中保存 auto_ptr,也不能从函数返回 auto_ptr。应尽可能使用 unique_ptr

unique_ptr 默认使用 delete 释放所指对象。重载 unique_ptr 删除器会改变 unique_ptr 的类型,需要在生成 unique_ptr 类型时额外指定删除器类型;并在创建或 reset 一个 unique_ptr 对象时,提供一个指定类型的删除器。

cpp 复制代码
unique_ptr<objT, delT> p(new objT, fcn);

weak_ptr

weak_ptr 指向 shared_ptr 所管理的对象,它不控制所指对象生命周期,不会改变所绑定的 shared_ptr 的引用计数。

cpp 复制代码
auto sp = make_shared<int>(42);
weak_ptr<int> wp(sp);

由于对象可能不存在,不能使用 weak_ptr 直接访问对象,而必须通过 lock 返回的 shared_ptr

cpp 复制代码
if (shared_ptr<int> np = wp.lock()) {
  // ...
}

动态数组

C++ 及标准库提供了两种分配对象数组的方法:new T[] 表达式、allocator 类。

allocator 将分配与初始化分离,使用 allocator 通常会有更好的性能和更灵活的内存管理能力。

大多数应用都没有直接访问动态数组的需求,应该使用标准库容器而不是动态分配数组。使用容器可以使用默认的拷贝、赋值、析构,而分配动态数组的类必须自定义拷贝、赋值、析构。

new 和数组

new 分配对象数组时:

  • 可以通过 [] 将一个整数(不必是常量)指定为数组长度。
  • 也可以使用数组的类型别名分配数组,编译器仍会调用 new T[]

不管哪种方式,new T[] 得到的都是第一个元素的指针。由于得到的不是一个数组类型,因此不能对动态数组调用 beginend,也不能使用范围 for

cpp 复制代码
int *pia = new int[get_size()];
typedef int arrT[42];
int *p = new arrT;

默认情况下,new 分配的对象都是默认初始化。可以对数组中的元素执行值初始化、列表初始化:

cpp 复制代码
int *pia = new int[10];
int *pia2 = new int[10]();
string *psa = new string[10];
string *psa2 = new string[10]();

int *pia3 = new int[3]{0, 1, 2};
string *psa3 = new string[3]{"hello"};

与内置数组一样,列表只初始化动态数组开始部分的元素,剩余元素执行值初始化,而且列表中元素数目不能大于数组长度,否则依具体情况,可能在编译时报错或在运行时抛出 bad_array_new_length 异常。

类型 bad_array_new_length 定义在头文件 new 中。

动态数组不能在 () 中使用初始化器,因此不能使用 auto 分配数组。

动态分配空数组是合法的。此时,new 返回的是一个合法的非空指针,此指针保证与 new 返回的其它指针都不同。它和尾后指针一样,可以进行比较操作,可以为其加、减 0,可以减去自身,但不能解引用。

cpp 复制代码
char *cp = new char[0];

动态数组使用 delete [] 释放内存,数组中元素按逆序销毁。

cpp 复制代码
delete [] pa; // pa 指向一个动态分配的数组或为空

释放数组时必须使用 delete [],它告诉编译器该指针指向数组的首元素,如果使用 delete,行为将不确定。

如果使用 delete 释放数组,或者使用 delete [] 释放非数组对象,编译器可能不会给出警告,但程序的运行时行为不确定。

为对象类型加上 [],便可以使用 unique_ptr 管理 new 分配的动态数组,这种 unique_ptr 将使用 delete [] 释放内存。

cpp 复制代码
unique_ptr<int []> up(new int[10]);
up.reset();

指向数组的 unique_ptr 不能使用点和箭头成员运算符,但可以使用下标运算符。

cpp 复制代码
for (size_t i = 0; i != 10; ++i) {
  up[i] = i;
}

shared_ptr 不直接支持管理动态数组,若要使用 shared_ptr 管理动态数组,必须提供自定义删除器。管理动态数组的 shared_ptr 不支持下标运算和指针算术运算,需要通过 get 来获取数组元素。

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

for (size_t i = 0; i != 10; ++i) {
  *(sp.get() + i) = i;
}

allocator

new 将内存分配和对象构造合在一起,delete 将对象析构和内存释放合在一起,这丧失了一定的灵活性,而且对于按需构造的场景,付出了更多的初始构造开销。

标准库头文件 memory 中的 allocator 模板类将内存分配和对象构造分离开,它提供了一种类型感知的内存分配方法。allocator 分配内存时,会根据给定的对象类型来确定内存大小和对齐位置:

cpp 复制代码
allocator<string> alloc;
auto const p = alloc.allocate(n);

allocator 分配的内存是原始的、未构造的。借助 construct 成员可以按需构造,它接受一个指针和一些额外参数,并使用给定参数在给定位置构造一个对象。

如果未构造对象就使用原始内存,程序的行为将不确定。

cpp 复制代码
auto q = p;

alloc.construct(q++); // 默认构造
alloc.construct(q++, 10, 'c');
alloc.construct(q++, "hello");

销毁对象必须借助成员函数 destroy 按需销毁,它接受一个指针,对所指对象调用析构函数。

只能对真正构造过的元素进行 destroy

cpp 复制代码
while (q != p)
  alloc.destroy(--q);

deallocate 成员用于释放内存,它接受一个非空指针和一个大小参数,指针必须指向 allocate 分配的内存,大小参数必须和分配内存时提供的一样。

cpp 复制代码
alloc.deallocate(p, n);

标准库在头文件 memory 中为 allocator 类定义了两个伴随算法,用于在未初始化内存中创建对象。

cpp 复制代码
auto p = alloc.allocate(vi.size() * 2);
auto q = uninitialized_copy(vi.begin(), vi.end(), p);
uninitialized_fill_n(q, vi.size(), 42);

uninitialized_copy 接受的第三个参数是目标位置迭代器,必须指向未构造的内存。

相关推荐
懒羊羊大王&2 小时前
模版进阶(沉淀中)
c++
owde3 小时前
顺序容器 -list双向链表
数据结构·c++·链表·list
GalaxyPokemon3 小时前
Muduo网络库实现 [九] - EventLoopThread模块
linux·服务器·c++
W_chuanqi3 小时前
安装 Microsoft Visual C++ Build Tools
开发语言·c++·microsoft
tadus_zeng4 小时前
Windows C++ 排查死锁
c++·windows
EverestVIP4 小时前
VS中动态库(外部库)导出与使用
开发语言·c++·windows
胡斌附体5 小时前
qt socket编程正确重启tcpServer的姿势
开发语言·c++·qt·socket编程
GalaxyPokemon5 小时前
Muduo网络库实现 [十] - EventLoopThreadPool模块
linux·服务器·网络·c++
守正出琦5 小时前
日期类的实现
数据结构·c++·算法
ChoSeitaku5 小时前
NO.63十六届蓝桥杯备战|基础算法-⼆分答案|木材加工|砍树|跳石头(C++)
c++·算法·蓝桥杯