跳转到内容

内存管理

当你像 第 1 章“C++ 与标准库速成”以及本书其他各章那样,使用 std::vectorstd::string 等现代构造时,C++ 是一门安全的语言。语言本身也提供了许多“道路、车道线和红绿灯”,例如 C++ Core Guidelines(见 附录 B“带注释的参考书目”),用于分析代码正确性的静态分析器,以及更多工具。

不过,C++ 也允许你“开到野路子上”。其中一个例子就是手动内存管理(分配与释放)。这种手动管理内存,恰恰是 C++ 编程中最容易出错的领域之一。要编写高质量的 C++ 程序,专业 C++ 程序员必须理解内存在底层究竟是如何工作的。本书第三部分的第一章就会深入讨论内存管理的方方面面。你将学习动态内存的常见陷阱,以及若干避免和消除这些问题的技术。

本章会讨论底层内存处理,因为专业 C++ 程序员迟早会遇到这类代码。不过在现代 C++ 代码中,你应该尽量避免底层内存操作。例如,与其使用动态分配的 C 风格数组,不如使用像 vector 这样的标准库容器,它会自动为你完成全部内存管理。与其使用原始指针(raw pointers),不如使用本章后面会介绍的 unique_ptrshared_ptr 这样的智能指针,它们会在底层资源(例如内存)不再需要时自动释放它。归根结底,目标就是在代码中尽量避免出现 new/new[]delete/delete[] 这样的内存分配/释放调用。当然,这并不总能做到,而在现有代码里更是很可能做不到。因此,作为专业 C++ 程序员,你仍然必须理解内存在底层是如何运作的。

在现代 C++ 代码中,你应尽量避免底层内存操作,在涉及所有权时避免原始指针,并避免使用旧式 C 风格构造与函数。请改用安全的 C++ 替代方案,例如能自动管理内存的对象,如 C++ 的 string 类、vector 容器、智能指针等等!

内存是计算机中的底层组成部分,即便在像 C++ 这样较高层的编程语言中,它有时也会不请自来。若想成为一名专业 C++ 程序员,就必须扎实理解动态内存在 C++ 中究竟如何工作。

如果你对“对象在内存中长什么样”有一个心理模型,理解动态内存会轻松得多。在本书中,一个内存单元会画成一个盒子,旁边配有标签。标签表示与这块内存对应的变量名,盒子中的内容表示该内存当前保存的值。

例如,图 7.1 展示了执行下面这行代码之后的内存状态。注意,这行代码应位于某个函数中,这样 i 才是一个局部变量:

int i { 7 };

三个框。前两个分别标为 stack 和 free store,第三个框标为 i,值为 7。

[^图 7.1]

i 被称为一个自动变量(automatic variable),它分配在栈上。当程序流程离开该变量声明所在作用域时,它会被自动释放。

当你使用 new 关键字时,内存会被分配到自由存储区(free store)中。若没有显式初始化,通过 new 分配的内存就是未初始化的;也就是说,它包含该内存位置中残留的任意随机数据。在本章图示中,这种未初始化状态会用问号表示。下面这段代码先在栈上创建一个初始化为 nullptr 的变量 ptr,然后在自由存储区中分配一块内存,并让 ptr 指向它:

int* ptr { nullptr };
ptr = new int;

它也可以写成一行:

int* ptr { new int };

图 7.2 展示了执行这段代码之后的内存状态。注意,即便 ptr 指向自由存储区中的内存,变量 ptr 本身依然在栈上。指针只是一个变量,它既可以存在于栈上,也可以存在于自由存储区中,虽然这一点很容易被遗忘。动态内存本身则总是分配在自由存储区中。

四个框。前两个分别标为 stack 和 free store,第三个框标为 ptr,第四个框为未初始化的值,并标有 *ptr。

[^图 7.2]

正如 C++ Core Guidelines 所要求的那样,1 每次你声明一个指针变量时,都应立刻把它初始化为一个正确的指针值或 nullptr。绝不要让它保持未初始化状态!

下一个例子展示了:指针既可以存在于栈上,也可以存在于自由存储区中:

int** handle { nullptr };
handle = new int*;
*handle = new int;

这段代码首先声明了一个“指向 int 指针的指针”,变量名叫 handle。随后,它动态分配了一块足以容纳“指向 int 的指针”的内存,并把这块新内存的地址存进 handle。接着,这块内存(*handle) 又被赋值为另一个动态分配内存的指针,而那块内存足以容纳一个 int图 7.3 展示了这两层指针:其中一个指针位于栈上(handle),另一个位于自由存储区(*handle)。

五个框。前两个分别标为 stack 和 free store,第三个框标为 handle,第四个框标为 *handle,第五个框为未初始化的值,并标有 **handle。

[^图 7.3]

要为变量创建空间,使用 new 关键字。要把这块空间释放出来给程序的其他部分使用,则使用 delete 关键字。

当你要分配一块内存时,就用 new 指定所需变量的类型。new 会返回指向那块内存的指针,至于是否把这个指针存入变量,则取决于你自己。如果你忽略 new 的返回值,或者保存该指针的变量离开作用域,那么这块内存就会变成孤儿内存(orphaned),因为你再也没有办法访问它了。这就叫做内存泄漏(memory leak)。

例如,下面这段代码会让一块足以容纳一个 int 的内存变成孤儿。图 7.4 展示了执行完代码后的内存状态。当自由存储区中存在某些数据块,而栈上已无法直接或间接访问到它们时,这块内存就成了孤儿,也就是发生了泄漏。

三个框。前两个分别标为 stack 和 free store,第三个框表示一个已泄漏、未初始化的整数。

[^图 7.4]

void leaky()
{
new int; // BUG! Orphans/leaks memory!
println("I just leaked an int!");
}

除非哪天他们造出了拥有无限高速内存的计算机,否则你迟早都得告诉编译器:与某个对象关联的内存什么时候可以被释放出来,并重新用于其他目的。要释放自由存储区中的内存,你需要对指向那块内存的指针使用 delete,如下:

int* ptr { new int };
delete ptr;
ptr = nullptr;

经验法则是:凡是用 new 分配内存的一行代码,只要你用的是原始指针而不是智能指针来保存它,那么都应该有另一行代码用 delete 去释放同一块内存。

如果你是 C 程序员,也许会好奇 malloc() 到底有什么问题。在 C 中,malloc() 用于按字节数分配内存。绝大多数情况下,使用 malloc() 都很简单直接。C++ 中依然保留了 malloc(),但你应尽量避免使用它。new 相比 malloc() 的主要优势在于:new 不只是分配内存,它还会构造对象!

例如,考虑下面两行使用一个假想类 Foo 的代码:

Foo* myFoo { (Foo*)malloc(sizeof(Foo)) };
Foo* myOtherFoo { new Foo{} };

执行这两行之后,myFoomyOtherFoo 都会指向自由存储区中一块足以容纳 Foo 对象的内存区域。通过这两个指针都可以访问 Foo 的数据成员和成员函数。不同之处在于,myFoo 所指向的那个 Foo 对象并不是真正意义上的对象,因为它的构造函数从未被调用。malloc() 只会预留一块指定大小的内存。它既不认识对象,也不关心对象。相反,new 不仅会分配恰当大小的内存,还会调用合适的构造函数来构造该对象。

free()delete 之间也存在类似区别。用 free() 时,对象的析构函数不会被调用。而用 delete 时,析构函数会被调用,对象能够被正确清理。

在 C++ 中应避免使用 malloc()free()

很多程序员——甚至可能是大多数程序员——写代码时都默认假设 new 一定会成功。其逻辑大致是:如果 new 都失败了,说明内存已经极度紧张,情况糟得不能再糟了。在那种状态下,程序还能做什么,往往根本说不清。

默认情况下,当 new 失败时(例如可用内存不足),它会抛出异常。如果这个异常没有被捕获,程序就会终止。在很多程序中,这种行为完全可以接受。第 1 章 已经介绍了异常,第 14 章“错误处理”会更详细说明异常,并给出在内存耗尽时优雅恢复的若干思路。

new 还有一个不会抛异常的替代版本。它会在分配失败时返回 nullptr,其行为与 C 中的 malloc() 相似。使用这个版本的语法如下:

int* ptr { new(nothrow) int };

这语法看起来有些奇怪:你确实需要把 nothrow 写得像是 new 的一个参数(它本来也确实是)。

当然,这样做依然面临和抛异常版本同样的问题——当结果是 nullptr 时你该怎么办? 编译器并不会强制你去检查结果,因此 nothrow 版本的 new 反而比抛异常版本更容易引入其他 bug。正因如此,建议你使用标准版 new。如果程序确实很在意内存耗尽后的恢复,那么 第 14 章 介绍的技术已经足够应对。

数组会把多个相同类型的变量打包成一个带索引的单一变量。对于新手程序员来说,数组很快就会变得很自然,因为你可以很容易地把它理解为“若干编号槽位中的值”。数组在内存中的实际表示,与这种心理模型也相差不远。

当程序为一个数组分配内存时,它实际分配的是一段连续(contiguous) 的内存块,其中每一块都足以容纳数组中的一个元素。例如,一个包含 5 个 int 的局部数组可以这样在栈上声明:

int myArray[5];

这种基本类型数组的各个元素默认都是未初始化的;也就是说,它们包含的是内存中该位置原本残留的内容。图 7.5 展示了数组创建后的内存状态。在栈上创建数组时,其大小必须是一个编译期已知的常量值。

两个区域,分别标为 stack 和 free store。stack 中有五个连续框,表示 myArray 从 0 到 4 的元素。

[^图 7.5]

在栈上创建数组时,可以使用初始化列表来提供初始元素:

int myArray[5] { 1, 2, 3, 4, 5 };

如果初始化列表中的元素个数少于数组大小,其余元素会被零初始化(见 第 1 章),例如:

int myArray[5] { 1, 2 }; // 1, 2, 0, 0, 0

若想把所有元素都零初始化,直接写成下面这样即可:

int myArray[5] { }; // 0, 0, 0, 0, 0

当使用初始化列表时,编译器还能自动推导元素个数,从而无需显式写出数组大小:

int myArray[] { 1, 2, 3, 4, 5 };

在自由存储区中声明数组也没什么不同,只是你要用指针来引用数组所在位置。下面这段代码为 5 个未初始化的 int 分配了数组内存,并把指向这块内存的指针保存在名为 myArrayPtr 的变量中:

int* myArrayPtr { new int[5] };

正如 图 7.6 所示,自由存储区中的数组和栈上的数组非常相似,只是位置不同。变量 myArrayPtr 指向数组的第 0 个元素。

new 运算符一样,new[] 也支持 nothrow 参数,以便在分配失败时返回 nullptr 而不是抛异常:

int* myArrayPtr { new(nothrow) int[5] };

三个区域。前两个分别标为 stack 和 free store,第三个框标为 myArrayPtr。自由存储区中有五个连续框,表示 myArrayPtr 指向的 0 到 4 号元素。

[^图 7.6]

在自由存储区中动态创建的数组同样也可以用初始化列表初始化:

int* myArrayPtr { new int[] { 1, 2, 3, 4, 5 } };

每一次 new[] 调用,都应该配对一次 delete[] 来清理内存。注意 delete[] 后面的那对空方括号 []!

delete [] myArrayPtr;
myArrayPtr = nullptr;

把数组放在自由存储区中的好处,在于你可以在运行期决定它的大小。例如,下面这个代码片段从一个假想函数 askUserForNumberOfDocuments() 中拿到文档数,并用该结果创建一个 Document 对象数组。

Document* createDocumentArray()
{
size_t numberOfDocuments { askUserForNumberOfDocuments() };
Document* documents { new Document[numberOfDocuments] };
return documents;
}

记住,每一次 new[] 调用都必须对应一次 delete[]。因此在这个例子中,非常重要的一点是:调用 createDocumentArray() 的那一方必须用 delete[] 去清理返回的内存。另一个问题是,C 风格数组并不知道自己的大小;因此,createDocumentArray() 的调用者根本无法知道返回数组里到底有多少元素!

在前面这个函数中,documents 是一个动态分配的数组。不要把它和动态数组(dynamic array) 混淆。这个数组本身并不“动态”,因为一旦分配完成,其大小就不会改变。动态内存只允许你在运行期指定分配块的大小,并不会自动根据数据量调整自身尺寸。

C++ 中还有一个叫 realloc() 的函数,它是从 C 语言遗留下来的。不要用它! 在 C 中,realloc() 通过分配一块新的、更大或更小的内存,把旧数据复制过去,再删除原内存块,从而“实质上”改变数组大小。这种方式在 C++ 中极其危险,因为用户自定义对象并不能很好地接受按位复制(bitwise copying)。

在 C++ 中绝对不要使用 realloc()! 它不是你的朋友。

对象数组与基本/原生类型数组没有本质区别,唯一不同之处在于其元素初始化方式。当你使用 new[N] 分配一个包含 N 个对象的数组时,系统会为 N 个连续块分配足够的空间,其中每个块都足以容纳一个对象。对于对象数组,new[] 会自动对数组中的每个对象调用零参数(即默认)构造函数;而基本类型数组的元素默认则是未初始化的。也就是说,用 new[] 分配对象数组时,返回的其实是一个指向“已经完整构造并初始化好的对象数组”的指针。

例如,考虑下面这个类:

class Simple
{
public:
Simple() { println("Simple constructor called!"); }
˜Simple() { println("Simple destructor called!"); }
};

如果你分配一个包含 4 个 Simple 对象的数组,那么 Simple 构造函数会被调用 4 次。

Simple* mySimpleArray { new Simple[4] };

图 7.7 展示了这个数组的内存图。如你所见,它和基本类型数组并无不同。

三个区域。前两个分别标为 stack 和 free store,第三个框标为 mySimpleArray。自由存储区中有一排连续框,表示 mySimpleArray 数组中的对象元素。

[^图 7.7]

当你使用 new[](也就是数组版本的 new) 来分配内存时,就必须使用 delete[](也就是数组版本的 delete) 来释放它。这个版本除了释放相关内存之外,还会自动对数组中的对象调用析构函数。

Simple* mySimpleArray { new Simple[4] };
// Use mySimpleArray…
delete [] mySimpleArray;
mySimpleArray = nullptr;

如果你没有使用数组版本的 delete,程序可能会表现得很诡异。在某些编译器上,只有数组第一个元素的析构函数会被调用,因为编译器只知道你在删除“一个对象指针”,其余数组元素就会变成孤儿对象。在另一些编译器上,则可能发生内存破坏,因为 newnew[] 完全可能采用截然不同的内存分配方案。

凡是用 new 分配的东西,都要用 delete 释放;凡是用 new[] 分配的东西,都要用 delete[] 释放。

当然,只有当数组元素本身是对象时,析构函数才会被调用。如果你拥有的是“指针数组”,那你仍然需要逐个删除每个指针所指向的对象,就像你当初逐个分配它们一样,如下:

const size_t size { 4 };
Simple** mySimplePtrArray { new Simple*[size] };
// Allocate an object for each pointer.
for (size_t i { 0 }; i < size; ++i) { mySimplePtrArray[i] = new Simple{}; }
// Use mySimplePtrArray…
// Delete each allocated object.
for (size_t i { 0 }; i < size; ++i) {
delete mySimplePtrArray[i];
mySimplePtrArray[i] = nullptr;
}
// Delete the array itself.
delete [] mySimplePtrArray;
mySimplePtrArray = nullptr;

在现代 C++ 中,只要涉及所有权,就应该避免使用原始 C 风格指针。与其在 C 风格数组里存储原始指针,不如在 std::vector 这类现代标准库容器中存储智能指针。本章后面会讲到智能指针,它们会在合适时机自动释放与之关联的内存。

多维数组把“带索引的值”的概念扩展到了多个索引。例如,井字棋游戏就可能用一个二维数组来表示 3×3 网格。下面这个例子展示了这样一个在栈上声明、零初始化并配有少量测试代码的数组:

char board[3][3] {};
// Test code
board[0][0] = 'X'; // X puts marker in position (0,0).
board[2][1] = 'O'; // O puts marker in position (2,1).

你可能会好奇:二维数组中的第一个下标到底代表 x 坐标还是 y 坐标? 真相是:只要你自己保持一致,其实并不重要。一个 4×7 的网格既可以声明为 char board[4][7],也可以声明为 char board[7][4]。对于大多数应用来说,把第一个下标想成 x 轴、第二个下标想成 y 轴会更容易理解。

在内存中,这个 3×3 的栈上二维数组 board 看起来就像 图 7.8。因为内存本身并没有两个轴(地址只是线性连续的),所以计算机会把二维数组表示成看起来和一维数组一样的结构。不同之处仅在于数组的大小,以及访问它的方式。

两个区域,分别标为 stack 和 free store。stack 中按顺序排布了二维数组 board 从 0,0 到 2,2 的九个元素。

[^图 7.8]

多维数组的大小,等于它所有维度乘起来之后,再乘以单个元素的大小。在 图 7.8 中,这个 3×3 的棋盘占用 3 × 3 × 1 = 9 字节,这里假设一个字符是 1 字节。若是一个 4×7 的字符棋盘,那么数组大小就是 4 × 7 × 1 = 28 字节。

要访问多维数组中的某个值,计算机会把每个下标都当成“先访问多维数组中的某个子数组”。例如,在这个 3×3 网格中,表达式 board[0] 实际上就对应于 图 7.9 中高亮显示的那个子数组。当你再加上第二个下标,例如 board[0][2],计算机就能在该子数组中继续根据第二个下标找到正确元素,如 图 7.10 所示。

这些技术还可以延伸到 N 维数组,不过三维以上通常已经很难直观想象,而且实际也极少使用。

两个区域,分别标为 stack 和 free store。stack 中按顺序排布了 board 从 0,0 到 2,2 的九个元素,其中前三个元素被高亮,表示子数组 board[0]。

[^图 7.9]

两个区域,分别标为 stack 和 free store。stack 中按顺序排布了 board 从 0,0 到 2,2 的九个元素,其中第三个元素被高亮,表示 board[0][2]。

[^图 7.10]

如果你需要在运行期决定多维数组的尺寸,就可以使用自由存储区上的数组。就像一维动态数组通过一个指针来访问一样,多维动态数组也同样通过指针访问。唯一的区别在于:对于二维数组,你需要从“指向指针的指针”开始;对于 N 维数组,你就需要 N 层指针。乍看之下,你可能会以为动态分配多维数组的正确声明和分配方式是这样的:

char** board { new char[i][j] }; // BUG! Doesn't compile

这段代码无法编译,因为自由存储区上的多维数组并不像栈上的数组那样工作。它们的内存布局并不是连续的。相反,你需要先为自由存储区数组的第一维分配一个连续数组。这一维数组中的每个元素,其实都是指向另一个数组的指针,而后者存储的是第二维中的元素。图 7.11 展示了一个 2×2 动态分配棋盘的这种布局。

两个区域,分别标为 stack 和 free store。图中展示一个动态分配的二维 board:第一维元素位于一处,每个元素再指向第二维子数组中的元素。

[^图 7.11]

遗憾的是,编译器并不会替你分配各个子数组的内存。你可以像分配一维自由存储区数组那样分配第一维数组,但每个子数组都必须显式分配。下面这个函数就正确分配了一个二维数组所需的内存:

char** allocateCharacterBoard(size_t xDimension, size_t yDimension)
{
char** myArray { new char*[xDimension] }; // Allocate first dimension
for (size_t i { 0 }; i < xDimension; ++i) {
myArray[i] = new char[yDimension]; // Allocate ith subarray
}
return myArray;
}

类似地,当你想释放多维自由存储区数组所关联的内存时,单纯使用数组版 delete[] 也不会替你清理各个子数组。你的释放代码应当与分配代码镜像对应,例如下面这个函数:

void releaseCharacterBoard(char**& myArray, size_t xDimension)
{
for (size_t i { 0 }; i < xDimension; ++i) {
delete [] myArray[i]; // Delete ith subarray
myArray[i] = nullptr;
}
delete [] myArray; // Delete first dimension
myArray = nullptr;
}

现在你已经了解了数组相关的全部细节,仍然建议你尽可能避免这些老式 C 风格数组,因为它们完全不提供内存安全保证。这里之所以还要解释它们,只是因为你会在遗留代码中遇到它们。在新代码中,请使用 C++ 标准库容器,例如 std::arrayvector。例如,一维动态数组就应使用 vector<T>。二维动态数组可以用 vector<vector<T>>,更高维也以此类推。当然,直接操作 vector<vector<T>> 这样的数据结构仍然会显得繁琐,尤其在构造时更是如此,而且它们也会遭遇前面 note 中提到的同样内存碎片问题。因此,如果你的应用确实需要 N 维动态数组,建议你编写一些辅助类,提供更易用的接口。例如,对于“行长度相同的二维数据”,你应该考虑自己编写(或者复用现成的) Matrix<T>Table<T> 类模板,把内存分配/释放和元素访问算法统统隐藏起来。第 12 章“使用模板编写泛型代码”会解释如何编写类模板。

请使用 std::arrayvector 等 C++ 标准库容器,而不是 C 风格数组!

指针之所以名声不佳,是因为滥用它们实在太容易了。既然指针只是一个内存地址,那么从理论上讲,你甚至可以手动把这个地址改成任意值,包括像下面这样可怕的代码:

char* scaryPointer { (char*)7 };

这行代码构造了一个指向内存地址 7 的指针,而那很可能只是随机垃圾数据,或者是程序其他地方正在使用的内存。只要你开始使用那些并不是专门为你保留出来的内存区域——例如不是通过 new 分配的、也不是位于栈上的——你迟早会破坏某个对象对应的内存,或者破坏自由存储区管理本身所依赖的内存,最终让程序出故障。这种故障可能表现为多种形式。例如,它可能体现为错误结果(因为数据已被破坏),也可能体现为硬件异常(因为访问了不存在的内存,或试图写入受保护内存)。如果你运气好,你会得到某种严重错误,通常直接导致操作系统或 C++ 运行时库终止程序;如果你运气不好,你只会得到悄悄出错的错误结果。

理解指针有两种思路。偏数学思维的读者,可能更愿意把指针看成地址。这种看法会让本章稍后要讲的指针算术(pointer arithmetic)更容易理解。指针并不是什么神秘的“内存通道”;它们只是一些恰好对应于内存位置的数字。图 7.12 展示了这种“地址视角”下一个 2×2 网格的样子。2

两个区域,分别标为 stack 和 free store。图中用具体地址值展示一个二维 board 在内存中的地址视角表示。

[^图 7.12]

而更喜欢空间化表示的读者,也许会从“箭头视角”中获益更多。在这种视角里,指针是一层间接性,它仿佛在对程序说:“嘿! 往那边看。” 在这种视角下,多层指针就对应于一条通向数据的路径上的若干步。图 7.11 展示的就是指针在内存中的图形化视图。

当你使用 * 运算符去解引用(dereference) 一个指针时,你是在告诉程序:在内存中再往深处看一层。从地址视角来看,把解引用想成“跳转到指针所表示的那个地址”。而在图形视角下,每一次解引用就对应着沿着一根箭头,从箭尾走到箭头所指的箭头。

当你使用 & 运算符取得某个位置的地址时,你就在内存里增加了一层间接性。从地址视角来看,程序是在记录那个位置对应的数值地址,并可以把它存储为一个指针。从图形视角来看,& 运算符则创建了一根新的箭头,其箭头指向该表达式所标识的位置。箭尾那一端就可以被存进指针变量中。

由于指针本质上只是内存地址(或者说“指向某处的箭头”),所以它们在类型上相对比较“弱”。一个指向 XML 文档的指针,在大小上和一个指向整数的指针完全一样。编译器也允许你使用C 风格转换(C-style cast),轻松把任意指针类型转换成另一个指针类型:

Document* documentPtr { getDocument() };
char* myCharPtr { (char*)documentPtr };

当然,一旦使用这种转换后的指针,就可能引发灾难性的运行时错误。静态转换(static cast) 则会稍微安全一点。对于彼此无关的数据类型指针,编译器会拒绝执行静态转换:

Document* documentPtr { getDocument() };
char* myCharPtr { static_cast<char*>(documentPtr) }; // BUG! Won't compile

第 10 章“理解继承技术”会结合示例详细讨论各种转换风格。

你已经见识过数组和指针之间的一些重叠之处了。自由存储区上分配的数组,实际上是通过指向第一个元素的指针来引用的。栈上的数组则通过数组语法([]) 和普通变量声明方式来引用。不过,正如你即将看到的,它们之间的重叠并不止这些。指针和数组的关系很复杂。

自由存储区上的数组并不是唯一可以使用指针来引用数组的地方。你同样可以使用指针语法来访问栈上数组中的元素。一个数组的地址,其实就是其第一个元素(索引 0) 的地址。编译器知道,当你用数组变量名整体指代某个数组时,你实际上是在引用其第一个元素的地址。从这个意义上说,栈上数组和自由存储区数组的指针用法是一样的。下面这段代码在栈上创建了一个零初始化的数组,然后通过一个指针来访问它:

int myIntArray[10] {};
int* myIntPtr { myIntArray };
// Access the array through the pointer.
myIntPtr[4] = 5;

能够通过指针来引用栈上数组,在把数组传递给函数时非常有用。下面这个函数把一个整型数组作为指针接收。注意,调用者需要显式传入数组大小,因为单靠指针本身并不包含任何大小信息。这也是你应该使用标准库提供的现代容器的另一个原因。

void doubleInts(int* theArray, size_t size)
{
for (size_t i { 0 }; i < size; ++i) { theArray[i] *= 2; }
}

调用这个函数的人,既可以传入栈上数组,也可以传入自由存储区数组。若是自由存储区数组,那么指针本身本就已经存在,它会按值传入函数。若是栈上数组,调用者既可以直接传数组变量,由编译器自动把它当成指向数组的指针,也可以显式传入第一个元素的地址。下面展示了这三种形式:

size_t arrSize { 4 };
int* freeStoreArray { new int[arrSize]{ 1, 5, 3, 4 } };
doubleInts(freeStoreArray, arrSize);
delete [] freeStoreArray;
freeStoreArray = nullptr;
int stackArray[] { 5, 7, 9, 11 };
arrSize = std::size(stackArray); // Since C++17, requires <array>
//arrSize = sizeof(stackArray) / sizeof(stackArray[0]); // Pre-C++17, see Ch1
doubleInts(stackArray, arrSize);
doubleInts(&stackArray[0], arrSize);

数组传参的语义和指针极为相似,因为编译器在把数组传给函数时,会把它视为指针。也就是说,一个把数组作为参数并修改其内部值的函数,实际上是在修改原始数组本身,而不是某个副本。就像指针一样,传数组实际上也模拟了按引用传递的效果,因为你真正传入函数的,是原始数组的地址,而不是一个副本。下面这个 doubleInts() 的实现虽然参数形式写的是数组,而不是指针,但它照样会修改原始数组:

void doubleInts(int theArray[], size_t size)
{
for (size_t i { 0 }; i < size; ++i) { theArray[i] *= 2; }
}

函数原型中 theArray 后面方括号里的任何数字都会被完全忽略。因此,下面这三种写法是完全等价的:

void doubleInts(int* theArray, size_t size);
void doubleInts(int theArray[], size_t size);
void doubleInts(int theArray[2], size_t size);

你也许会想知道:为什么会是这样? 为什么编译器在函数定义里看到数组语法时,不直接复制整个数组? 原因是效率——复制数组元素需要时间,而且它们可能占用大量内存。始终把数组作为指针传递,就意味着编译器不必额外插入复制整个数组的代码。

其实还有一种方式,可以把“长度已知的栈上数组”真正“按引用”传给函数,不过语法并不直观。而这招对自由存储区数组并不适用。例如,下面这个 doubleIntsStack() 只能接收大小为 4 的栈上数组:

void doubleIntsStack(int (&theArray)[4]);

你还可以使用函数模板(将在 第 12 章 中详细讨论),让编译器自动推导栈上数组的大小:

template <size_t N>
void doubleIntsStack(int (&theArray)[N])
{
for (size_t i { 0 }; i < N; ++i) { theArray[i] *= 2; }
}

由于编译器允许你在期望指针的地方传入数组(就像上一节的 doubleInts() 那样),你也许会误以为指针和数组就是同一回事。事实上,它们之间有一些微妙但非常重要的区别。指针和数组确实共享许多性质,也有时可以互换使用(如前面所示),但它们绝不是同一个东西。

单独存在的指针本身并没有意义。它可能指向随机内存,也可能指向单个对象,还可能指向数组。你当然总能对一个指针使用数组语法,但这并不总是合适,因为指针并不总是表示数组。例如,考虑下面这行代码:

int* ptr { new int };

指针 ptr 是一个合法指针,但它并不是数组。你可以用数组语法访问其所指值(ptr[0]),但这样写在风格上很可疑,也没有任何实际好处。事实上,对于非数组指针使用数组语法,几乎就是在主动邀请 bug 上门。ptr[1] 处的内存里可能是什么都有!

数组会自动退化为指针,但并非所有指针都是数组。

C++ 相对于 C 的一大优势,就是你不需要像在 C 中那样时时操心内存。只要你是围绕对象来编写代码,你通常只需要确保每个类都能正确管理自己的内存即可。借助构造与析构,编译器会告诉你何时该做这件事。把内存管理隐藏在类内部,会让代码可用性大幅提升,标准库类就是最好的证明。不过在某些应用场景,或者在遗留代码中,你仍然可能会碰到需要直接以更底层方式处理内存的情况。无论是出于遗留、性能、调试还是好奇,了解一些直接操作原始字节的技巧,都会很有帮助。

C++ 编译器会利用指针的声明类型,允许你执行指针算术(pointer arithmetic)。如果你声明了一个指向 int 的指针,并让它增加 1,那么这个指针在内存中会前进“一个 int 的大小”,而不是仅仅前进 1 个字节。这类操作在数组场景下最有用,因为数组中的数据类型统一,且在内存中顺序排列。例如,假设你在自由存储区中声明了一个 int 数组:

int* myArray { new int[8] };

你已经熟悉下面这种把索引 2 位置设为某值的写法:

myArray[2] = 33;

借助指针算术,你也可以等价地写成下面这种形式,即先获得一个“相对 myArray 向前移动 2 个 int”后的指针,再对它解引用并赋值:

*(myArray + 2) = 33;

作为一种访问单个元素的替代语法,指针算术似乎并不怎么吸引人。它真正强大的地方在于:像 myArray + 2 这样的表达式本身仍然是一个指向 int 的指针,因此它可以代表一个更小的 int 数组起点。

让我们看一个使用宽字符串(wide strings) 的例子。宽字符串会在 第 21 章“字符串本地化与正则表达式”中讨论,但此时不必关心细节。当前只需知道:宽字符串支持 Unicode 字符,因此可表示例如日文这样的字符串。wchar_t 是一种能够容纳这类 Unicode 字符的字符类型,通常会比 char 更大,即不止 1 字节。若想告诉编译器某个字符串字面量是宽字符串字面量,就要在它前面加上 L。例如,假设你有如下宽字符串:

const wchar_t* myString { L"Hello, World" };

再假设你有一个函数,它接收宽字符串并返回一个“输入内容转成大写后的新字符串”:

wchar_t* toCaps(const wchar_t* text);

你当然可以把 myString 整体传给它来做大写转换。不过如果你只想把 myString 的一部分转成大写,那就可以利用指针算术只引用字符串的后半部分。下面这段代码通过把指针加 7,仅对宽字符串中的 World 部分调用 toCaps(),尽管 wchar_t 通常并不止 1 字节:

toCaps(myString + 7);

指针算术的另一个常见用途是做减法。同类型的两个指针相减,得到的并不是它们之间相差多少字节,而是两者之间相隔多少个“被指向类型的元素”。

对于你会遇到的 99% 的场景(有些人甚至会说是 100%),C++ 内建的内存分配机制都已经足够了。在底层,newdelete 会负责以合适大小分发内存块、维护可用内存区域列表,并在删除时把内存块归还给这张列表。

只有在资源约束极为严苛,或在非常特殊的条件下(例如共享内存管理),自定义内存管理才可能成为可行方案。别担心——它并没有听上去那么可怕。基本思路就是:由某些类一次性分配一大块内存,然后在需要时再把这块大内存切成更小的部分逐步发放出去。

这样做为什么会更好? 自己管理内存有可能减少额外开销。当你用 new 分配内存时,程序通常还得额外记录一点信息,比如这次一共分配了多少内存。这样当你调用 delete 时,系统才能知道该释放多少。对于绝大多数对象来说,这点额外开销和真正分配出去的内存相比微不足道,几乎没区别。但对小对象,或者对象数量极其庞大的程序来说,这部分开销就可能产生影响。

如果由你自己管理内存,你也许事先就知道每个对象的大小,于是便有可能避免每个对象各自承担那部分开销。对于海量小对象来说,这可能带来极其可观的差异。要实现自定义内存管理,需要重载 newdelete 运算符,这一主题会在 第 15 章“重载 C++ 运算符”中讲解。

在支持垃圾回收(garbage collection) 的环境中,程序员很少,甚至永远不会显式释放某个对象关联的内存。相反,一旦某些对象不再被任何引用持有,运行时库就会在某个时刻自动把它们清理掉。

垃圾回收并不像在 C# 和 Java 中那样内建在 C++ 语言里。在现代 C++ 中,你应该使用智能指针来管理内存;而在遗留代码中,你会看到通过 newdelete 在对象层面管理内存的写法。像 shared_ptr(本章后面会讲) 这样的智能指针,已经提供了非常接近垃圾回收的效果;也就是说,当指向某项资源的最后一个 shared_ptr 实例销毁时,那项资源就会在那一刻被销毁。C++ 中当然也可以实现真正的垃圾回收,但这既不容易,也未必值得——因为一旦你把自己从“释放内存”这件事中解放出来,很可能又会迎来新的头痛问题。

垃圾回收的一种方法叫 mark and sweep(标记-清扫)。采用这种方法时,垃圾回收器会周期性地检查程序中的每一个指针,并标记“该指针指向的内存仍在使用”。在一个周期结束时,所有未被标记的内存都会被视为“不再使用”,然后被释放。要在 C++ 中实现这样的算法并不容易,而且一旦做错,它甚至会比直接使用 delete 更容易出错!

人们曾尝试在 C++ 中设计出安全易用的垃圾回收机制,但即便有一天 C++ 真的出现了完美的垃圾回收实现,它也未必适合所有应用。垃圾回收的缺点包括:

  • 当垃圾回收器正在积极运行时,程序可能会变得没有响应。
  • 垃圾回收会带来“非确定性析构函数”。因为对象直到被垃圾回收时才会真正销毁,所以析构函数不会在对象离开作用域那一刻立刻执行。这意味着诸如关闭文件、释放锁等由析构函数承担的资源清理工作,都不会马上发生,而是会被拖延到未来某个不确定的时刻。

编写垃圾回收机制非常困难。你几乎一定会写错,它会很脆弱,而且大概率还会很慢。所以,如果你确实想在应用中使用垃圾回收内存,我建议你认真调研现有可复用的专业垃圾回收库。

垃圾回收有点像:你为一次野餐买了一堆盘子,用过之后就把它们丢在院子里,等某个人将来某个时候来把它们捡走并扔掉。显然,内存管理应该还能有更环保的办法。

对象池(object pools) 就像回收再利用。你只买数量合适的一批盘子,每次用完后就把盘子洗干净,这样以后还能继续使用。对象池特别适合这样的场景:随着时间推移,你需要大量同一种类型的对象,而每次新建一个对象本身都有开销。

第 29 章“编写高效的 C++”会进一步介绍如何利用对象池来提升性能效率。

使用 new/delete/new[]/delete[] 以及底层内存操作来处理动态内存,极易引发错误。导致内存相关 bug 的具体情况通常很难精确归类。每一个内存泄漏和坏指针都有自己的细节。并不存在某种“银弹”能一举解决所有内存问题。本节会讨论若干常见的问题类别,以及你可以用来检测和修复它们的一些工具。

分配不足的数据缓冲区与越界内存访问

Section titled “分配不足的数据缓冲区与越界内存访问”

分配不足(underallocation) 是 C 风格字符串中的常见问题。它通常发生在程序员忘记为末尾的 \0 哨兵字符额外分配一个字符空间时。对于字符串,当程序员错误地假设“有某个固定最大长度”时,同样也会发生分配不足。基本的内建 C 风格字符串函数并不会遵守任何固定大小限制——它们会非常愉快地一路写出字符串边界,冲进未知内存区域。

下面这段代码演示了分配不足。它从网络连接中读取数据,并把这些数据放进一个 C 风格字符串中。之所以放在循环里做,是因为网络连接每次只会收到一小块数据。每次循环都会调用 getMoreData(),它返回一个指向动态分配内存的指针。当 getMoreData() 返回 nullptr 时,表示所有数据都已经接收完毕。strcat() 是一个 C 函数,它会把第二个参数表示的 C 风格字符串拼接到第一个参数表示的目标 C 风格字符串末尾。它默认认为目标缓冲区足够大。

char buffer[1024] { 0 }; // Allocate a whole bunch of memory.
while (true) {
char* nextChunk { getMoreData() };
if (nextChunk == nullptr) {
break;
} else {
strcat(buffer, nextChunk); // BUG! No guarantees against buffer overrun!
delete [] nextChunk;
}
}

要解决这个例子中的潜在“分配不足”问题,有三种方式,按推荐程度由高到低排列如下:

  1. 使用 C++ 风格字符串,让它们替你管理拼接所需的内存。
  2. 不要把缓冲区分配成全局变量或栈变量,而是把它分配到自由存储区中。当剩余空间不够时,重新分配一个更大的缓冲区,使其至少足以容纳当前内容加上新数据块,把旧缓冲区内容复制到新缓冲区,附加上新内容,再删除旧缓冲区。
  3. 编写一个接收“最大字符数(包括 \0 字符)”的 getMoreData() 版本,让它返回的字符数绝不超过该上限;同时由你自己跟踪缓冲区中剩余空间和当前位置。

数据缓冲区分配不足通常会导致越界内存访问(out-of-bounds memory access)。例如,如果你正在向某个内存缓冲区写入数据,当你错误以为它比实际更大时,就可能开始把数据写到已分配缓冲区之外。迟早会有某个关键内存区域被覆盖,然后程序崩溃。试想一下,如果程序中对象关联的内存突然被覆盖,会发生什么? 结果绝不会好看。

当处理那些由于某种原因丢失了末尾 \0 终止字符的 C 风格字符串时,也会发生越界内存访问。例如,如果把一个终止不正确的字符串交给下面这个函数,它会不断用 'm' 覆写字符串,并且会非常开心地继续把字符串之后的内存也全都写成 'm',从而覆盖掉字符串边界之外的内存。

void fillWithM(char* text)
{
int i { 0 };
while (text[i] != '\0') {
text[i] = 'm';
++i;
}
}

那些导致“写出数组末尾之外”的 bug,通常被称为缓冲区溢出错误(buffer overflow errors)。这类 bug 曾被多种臭名昭著的恶意软件(例如病毒和蠕虫)利用。狡猾的黑客可以借助“能够覆写部分内存”的能力,把恶意代码注入到正在运行的程序中。

避免使用完全没有保护能力的旧式 C 风格字符串和数组。请改用现代、安全的构造,例如 C++ 的 stringvector,它们会替你管理全部内存。

在现代 C++ 中,本不该有内存泄漏。所有内存管理都应交给更高层的类来完成,例如 std::vectorstring 等等。只有当你“开下野路子”,手动进行内存分配与释放时,内存泄漏才会冒出来。

查找并修复这类泄漏通常令人沮丧。程序终于能运行了,表面上看结果也都正确。可没过多久你就会发现:程序在运行过程中吞掉的内存越来越多。你的程序发生了内存泄漏。

内存泄漏发生在:你分配了内存,却忘记释放它。乍一听,这似乎只是粗心程序员犯的低级错误,理应很容易避免。毕竟,只要你写的每个类里,每一个 new 都有一个对应的 delete,那应该就不会有内存泄漏了,对吧? 实际上,并不总是如此。比如下面这段代码里,Simple 类本身写得没问题,会释放自己分配的任何内存。然而当 doSomething() 被调用时,它会把 outSimplePtr 指针改指向另一个 Simple 对象,却没有先删除旧对象,从而演示出一次内存泄漏。一旦你丢掉了指向某个对象的最后一个指针,几乎就不可能再去删除它了。

class Simple
{
public:
Simple() { m_intPtr = new int{}; }
˜Simple() { delete m_intPtr; }
void setValue(int value) { *m_intPtr = value; }
private:
int* m_intPtr;
};
void doSomething(Simple*& outSimplePtr)
{
outSimplePtr = new Simple{}; // BUG! Doesn't delete the original.
}
int main()
{
Simple* simplePtr { new Simple{} }; // Allocate a Simple object.
doSomething(simplePtr);
delete simplePtr; // Only cleans up the second object.
}

请记住,这段代码只是为了演示! 在生产级代码中,m_intPtrsimplePtr 都不应是原始指针,而应使用本章后面要讲的智能指针。

像前面这样的例子,内存泄漏很可能是因为程序员之间沟通不充分,或者代码文档不清晰造成的。doSomething() 的调用者也许并没有意识到,变量是按引用传递的,因此他根本不会预料到这个指针会在函数内部被重新赋值。即便他注意到了参数类型是“对非 const 指针的引用”,也可能只是隐约感觉这里有点不对劲,因为围绕 doSomething() 并没有任何注释解释这种行为。

在 Windows 上用 Visual C++ 查找和修复内存泄漏

Section titled “在 Windows 上用 Visual C++ 查找和修复内存泄漏”

内存泄漏难以追踪,因为你很难直接查看内存,并一眼看出哪些对象没有再被使用、它们最初又是在哪儿分配的。不过,有些程序能替你完成这件事。内存泄漏检测工具的范围很广,从昂贵的专业软件包,到可以免费下载的工具都有。如果你使用 Microsoft Visual C++,那么它的调试库本身就内建支持内存泄漏检测。不过这项检测默认并未启用(除非你创建的是 MFC 工程)。若想在其他项目中启用它,首先需要在代码开头包含下面三行。这里用到了 #define 预处理宏,它会在 第 11 章“模块、头文件与杂项主题”中解释。不过此刻你只需原样照抄这三行即可。

#define _CRTDBG_MAP_ALLOC
#include <cstdlib>
#include <crtdbg.h>

这三行代码的顺序必须与这里展示的一致。接下来,你还需要按如下方式重定义 new 运算符。这里又用到了一些其他预处理宏,它们同样会在 第 11 章 中解释。现在也请先原样使用即可。

#ifdef _DEBUG
#ifndef DBG_NEW
#define DBG_NEW new ( _NORMAL_BLOCK , __FILE__ , __LINE__ )
#define new DBG_NEW
#endif
#endif // _DEBUG

#ifdef _DEBUG 这句用来确保只有在编译应用程序的调试版本时,才会重定义 new。这通常正是你想要的行为。发布版构建一般不会进行任何内存泄漏检测,因为那会带来性能损耗。

最后,你还需要在 main() 函数的第一行加入下面这句:

_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);

这会告诉 Visual C++ 的 CRT(C RunTime) 库:当应用程序退出时,把所有检测到的内存泄漏输出到调试输出控制台中。对于前面那个发生泄漏的程序,调试控制台里会看到类似下面的输出:

Detected memory leaks!
Dumping objects ->
c:\leaky\leaky.cpp(15) : {147} normal block at 0x014FABF8, 4 bytes long.
Data: < > 00 00 00 00
c:\leaky\leaky.cpp(33) : {146} normal block at 0x014F5048, 4 bytes long.
Data: <Pa> 50 61 20 01
Object dump complete.

这份输出清楚地表明:究竟是在什么文件、哪一行分配了内存,却从未释放。行号就在文件名后面圆括号里。花括号中的数字则是内存分配计数器。例如 {147} 表示这是程序启动以来的第 147 次分配。你还可以使用 VC++ 的 _CrtSetBreakAlloc() 函数,告诉 VC++ 调试运行时在执行某次特定分配时中断进调试器。例如,你可以在 main() 开头加上如下代码,让调试器在第 147 次分配发生时停住:

_CrtSetBreakAlloc(147);

在这个泄漏程序里,实际上有两处泄漏:第一个从未删除的 Simple 对象(第 33 行),以及它在自由存储区中创建的那个整数(第 15 行)。在 Visual C++ 的调试器输出窗口里,你甚至可以直接双击某一条泄漏记录,它就会自动跳转到对应代码行。

当然,像 Microsoft Visual C++(本节讨论的工具) 和 Valgrind(下一节讨论) 这类程序,并不能真的替你修复泄漏——那样就太没意思了。这些工具只能提供线索,帮助你定位真正的问题。通常,这意味着你得顺着代码一步步追踪,找出究竟在哪里某个指针被覆盖了,而原对象又没有先被释放。大多数调试器都提供“观察点”(watch point) 功能,能够在这种事情发生时中断程序执行。

在 Linux 上用 Valgrind 查找和修复内存泄漏

Section titled “在 Linux 上用 Valgrind 查找和修复内存泄漏”

Valgrind 是 Linux 平台上一款免费的开源工具,它在很多方面都很强,其中之一就是能精确指出:某个泄漏对象最初是在哪一行代码分配出来的。

下面这段输出就是对前面那个泄漏程序运行 Valgrind 得到的结果。它精确定位了“哪些内存被分配了却从未释放”。Valgrind 找到的也正是那两处泄漏:第一个从未删除的 Simple 对象,以及它在自由存储区上创建的整数:

==15606== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
==15606== malloc/free: in use at exit: 8 bytes in 2 blocks.
==15606== malloc/free: 4 allocs, 2 frees, 16 bytes allocated.
==15606== For counts of detected errors, rerun with: -v
==15606== searching for pointers to 2 not-freed blocks.
==15606== checked 4455600 bytes.
==15606==
==15606== 4 bytes in 1 blocks are still reachable in loss record 1 of 2
==15606== at 0x4002978F: __builtin_new (vg_replace:malloc.c:172)
==15606== by 0x400297E6: operator new(unsigned) (vg_replace:malloc.c:185)
==15606== by 0x804875B: Simple::Simple() (leaky.cpp:4)
==15606== by 0x8048648: main (leaky.cpp:24)
==15606==
==15606==
==15606== 4 bytes in 1 blocks are definitely lost in loss record 2 of 2
==15606== at 0x4002978F: __builtin_new (vg_replace:malloc.c:172)
==15606== by 0x400297E6: operator new(unsigned) (vg_replace:malloc.c:185)
==15606== by 0x8048633: main (leaky.cpp:20)
==15606== by 0x4031FA46: __libc_start_main (in /lib/libc-2.3.2.so)
==15606==
==15606== LEAK SUMMARY:
==15606== definitely lost: 4 bytes in 1 blocks.
==15606== possibly lost: 0 bytes in 0 blocks.
==15606== still reachable: 4 bytes in 1 blocks.
==15606== suppressed: 0 bytes in 0 blocks.

强烈建议你使用 std::vectorarraystring、智能指针(本章稍后会讲),以及其他现代 C++ 构造,从源头避免内存泄漏。

一旦你通过 delete 释放了某个指针关联的内存,这块内存就重新可供程序其他部分使用了。不过没有任何机制能阻止你继续使用这个已经失效的指针,而它此时就成了悬空指针(dangling pointer)。重复删除(double deletion) 也是一个问题。如果你第二次对同一个指针使用 delete,那么程序很可能会试图释放“已经被其他对象重新占用”的那块内存。

重复删除和使用已释放内存,都很难追踪,因为症状未必会立刻暴露出来。如果两次删除发生得很近,程序甚至有可能无限期“看起来还能工作”,因为那块内存可能还没来得及被重新利用。类似地,如果你在某对象被删除之后立刻又使用它,大概率它的内容短时间内看起来仍然是“完好的”。

当然,没有任何保证说这种行为一定能工作,或者以后还能继续工作。内存分配器并没有义务在对象被删除之后继续保留它。即便它碰巧还能工作,使用已删除对象依然属于极差的编程风格。

为了避免重复删除以及访问已经释放的内存,你应该在释放完内存之后,把指针设为 nullptr

很多内存泄漏检测程序也同样能够检测出重复删除和访问已释放对象的问题。

正如前一节所示,C++ 中的内存管理一直是错误和 bug 的高发地带。许多这类 bug 都源于动态内存分配和指针的使用。当你的程序大量使用动态内存分配,并且在对象之间传来传去很多指针时,就很难确保对每个指针都“恰好在正确时机调用一次 delete”。一旦出错,后果非常严重:如果你对动态分配内存释放了不止一次,或者使用了一个已经对应空闲内存的指针,就可能导致内存破坏或致命运行时错误;如果你忘记释放动态分配内存,就会造成内存泄漏。

智能指针能帮助你管理动态分配的内存,也是避免内存泄漏的推荐技术。从概念上说,智能指针能够持有一项动态分配的资源,例如内存。当智能指针离开作用域或被重置时,它能够自动释放自己持有的资源。智能指针既可以用于管理函数内部作用域中的动态资源,也可以作为类中的数据成员。它还可以用于通过函数参数传递动态资源的所有权。

C++ 提供了若干语言特性,使智能指针非常有吸引力。首先,你可以通过模板为任意指针类型编写类型安全的智能指针类;见 第 12 章。其次,你还可以通过运算符重载(见 第 15 章) 为智能指针对象提供接口,让客户端代码像使用“笨指针”(dumb raw pointers) 一样使用智能指针。具体来说,你可以重载 *->[] 运算符,从而让客户端代码以和普通指针相同的方式去解引用智能指针对象。

智能指针有多种类型。最简单的一种采用“唯一/独占所有权”语义。由于它是某项资源的唯一拥有者,所以当它离开作用域或被重置时,就可以自动释放该资源。标准库提供的 std::unique_ptr 就是这种具有唯一所有权(unique ownership) 语义的智能指针。

稍微高级一点的智能指针则支持共享所有权(shared ownership);也就是说,多个这类智能指针可以共同引用同一项资源。当这类智能指针离开作用域或被重置时,只有当它是最后一个仍然指向该资源的智能指针时,它才会真正释放资源。标准库提供的 std::shared_ptr 就支持这种共享所有权。

这两种标准智能指针 unique_ptrshared_ptr 都定义在 <memory> 中,接下来的几节会详细介绍。

永远不要把资源分配的结果赋给原始指针! 无论你采用什么资源分配方式,都应立刻把资源指针保存进某个智能指针中(unique_ptrshared_ptr),或者交给其他 RAII 类管理。RAII 是 Resource Acquisition Is Initialization 的缩写。RAII 类会取得某项资源的所有权,并在正确的时机负责释放它。这是一种设计技术,会在 第 32 章“融入设计技术与框架”中讨论。

unique_ptr 对资源拥有独占所有权。当 unique_ptr 被销毁或被重置时,资源会自动释放。其优点之一在于:即便函数中有 return 语句,或者抛出了异常,内存和资源也始终都会被释放。例如,当一个函数有多个 return 分支时,这会极大简化代码,因为你不必在每个 return 前都记得释放资源。

经验法则是:凡是只有单一所有者的动态资源,都应始终存放在 unique_ptr 实例中。

考虑下面这个函数,它通过在自由存储区中分配一个 Simple 对象却不释放它,赤裸裸地制造了内存泄漏:

void leaky()
{
Simple* mySimplePtr { new Simple{} }; // BUG! Memory is never released!
mySimplePtr->go();
}

有时候你也许会以为,自己的代码已经正确释放了动态分配内存。但遗憾的是,在所有场景下它大概率并不正确。看下面这个函数:

void couldBeLeaky()
{
Simple* mySimplePtr { new Simple{} };
mySimplePtr->go();
delete mySimplePtr;
}

这个函数动态分配了一个 Simple 对象,使用它,然后也确实正确调用了 delete。然而,这里依然可能发生内存泄漏! 如果 go() 成员函数抛出异常,那么 delete 调用就永远不会执行,从而导致内存泄漏。

正确做法是使用 unique_ptr,并通过辅助函数 std::make_unique() 来创建它。unique_ptr 是一种泛型智能指针,可以指向任意类型的内存。因此它是一个类模板,而 make_unique() 则是一个函数模板。二者都需要在尖括号 < > 中提供模板参数,用来指定 unique_ptr 要指向的内存类型。模板的详细内容会在 第 12 章 中讨论,但此刻你并不需要理解那些细节,也能正确使用智能指针。

下面这个函数使用 unique_ptr 替代了原始指针。Simple 对象不再需要显式删除;当 unique_ptr 实例离开作用域(函数结束,或者由于异常抛出),它会在自己的析构函数中自动释放 Simple 对象。

void notLeaky()
{
auto mySimpleSmartPtr { make_unique<Simple>() };
mySimpleSmartPtr->go();
}

这段代码使用了 make_unique(),再配合 auto 关键字,这样你只需要写一次指针所指向的类型——这里就是 Simple。这是创建 unique_ptr 的推荐方式。如果 Simple 的构造函数需要参数,只要把参数直接传给 make_unique() 即可。

make_unique() 使用值初始化(value initialization)。对于基本类型而言,这意味着初始化为 0;对于对象而言,则意味着默认构造。如果你并不需要这种值初始化(例如你本来就会立刻覆盖掉初始值),就可以使用 make_unique_for_overwrite() 跳过值初始化,从而提升性能。它会执行默认初始化(default initialization)。对基本类型来说,这意味着完全不初始化,于是它们会保留内存中的任意内容;而对象则依然会进行默认构造。

你也可以通过直接调用 unique_ptr 构造函数来创建它,如下所示。注意,这时 Simple 必须写两次:

unique_ptr<Simple> mySimpleSmartPtr { new Simple{} };

正如本书前面已经讨论过的,类模板实参推导(CTAD) 往往能让编译器根据构造函数参数自动推导类模板的类型参数。例如,它让你可以写 vector v{1,2} 而不是 vector<int> v{1,2}。但 CTAD 对 unique_ptr 不起作用,所以你不能省略它的模板类型参数。

在 C++17 之前,使用 make_unique() 不仅仅是因为它能让类型只写一次,还因为它更安全! 看看下面这个对 foo() 函数的调用:

foo(unique_ptr<Simple> { new Simple{} }, unique_ptr<Bar> { new Bar { data() } });

如果 SimpleBar 的构造函数抛出异常,或者 data() 函数抛出异常,那么在某些编译器优化下,有可能会导致 SimpleBar 泄漏。而如果使用 make_unique(),就不会发生泄漏:

foo(make_unique<Simple>(), make_unique<Bar>(data()))

从 C++17 起,这两种对 foo() 的调用都已经是安全的了,但我仍然推荐使用 make_unique(),因为它让代码更容易阅读。

标准智能指针最优秀的特性之一,就是它们能带来巨大收益,却几乎不要求使用者学习大量新语法。智能指针依然可以像普通指针一样被解引用(使用 *->)。例如,前面的例子中,就用 -> 调用了 go() 成员函数:

mySimpleSmartPtr->go();

和普通指针一样,你也可以写成这样:

(*mySimpleSmartPtr).go();

get() 成员函数可用于直接取得底层原始指针。这在你需要把指针传给某个必须接收原始指针的函数时很有用。例如,假设你有下面这个函数:

void processData(Simple* simple) { /* Use the simple pointer… */ }

那么你可以这样调用它:

processData(mySimpleSmartPtr.get());

你还可以用 reset() 释放 unique_ptr 当前持有的底层指针,并可选择让它改为管理另一个新指针。示例如下:

mySimpleSmartPtr.reset(); // Free resource and set to nullptr
mySimpleSmartPtr.reset(new Simple{}); // Free resource and set to a new
// Simple instance

你可以用 release() 把底层指针从 unique_ptr 中“摘下来”;它会返回资源对应的原始指针,并把智能指针自身设为 nullptr。效果上说,智能指针失去了对该资源的所有权,此后就由你自己负责在用完后释放这个资源! 例如:

Simple* simple { mySimpleSmartPtr.release() }; // Release ownership
// Use the simple pointer…
delete simple;
simple = nullptr;

由于 unique_ptr 表示唯一所有权,它不能被复制(copied)! 不过先剧透一下,你可以借助移动语义(move semantics) 把一个 unique_ptr 移动(move) 到另一个 unique_ptr 中,这一点会在 第 9 章“精通类和对象”中详细讨论。先简单预告一下,std::move() 工具函数可以显式转移 unique_ptr 的所有权,如下代码所示。现在先别担心语法细节;到 第 9 章 时一切都会变得清晰。

class Foo
{
public:
Foo(unique_ptr<int> data) : m_data { move(data) } { }
private:
unique_ptr<int> m_data;
};
auto myIntSmartPtr { make_unique<int>(42) };
Foo f { move(myIntSmartPtr) };

unique_ptr 也可以保存动态分配的老式 C 风格数组。下面这个例子创建了一个 unique_ptr,用于持有一个动态分配的、包含 10 个整数的 C 风格数组:

auto myVariableSizedArray { make_unique<int[]>(10) };

myVariableSizedArray 的类型是 unique_ptr<int[]>,并且支持使用数组下标语法访问元素。示例如下:

myVariableSizedArray[1] = 123;

与非数组版本一样,make_unique() 对数组中的全部元素都执行值初始化,这与 std::vector 的行为一致。对基本类型而言,这意味着全部初始化为零。若想创建一个默认初始化的数组,可以改用 make_unique_for_overwrite();对于基本类型来说,这就意味着元素未初始化。要记住,未初始化数据应尽量避免使用,因此请谨慎使用这种形式。

虽然 unique_ptr 的确能存储动态分配的 C 风格数组,但我仍然建议你优先使用标准库容器,例如 std::arrayvector

默认情况下,unique_ptr 会使用标准的 newdelete 运算符来分配和释放内存。你也可以改成使用自己定义的分配/释放函数。这在与第三方 C 库打交道时尤其有用。例如,假设你有一个 C 库,要求必须用 my_alloc() 分配、用 my_free() 释放:

int* my_alloc(int value) { return new int { value }; }
void my_free(int* p) { delete p; }

若想在恰当时机对已分配资源正确调用 my_free(),你就可以使用带自定义删除器(custom deleter) 的 unique_ptr:

unique_ptr<int, decltype(&my_free)> myIntSmartPtr { my_alloc(42), my_free };

这段代码会使用 my_alloc() 为一个整数分配内存,然后由 unique_ptr 在适当时机调用 my_free() 来释放内存。unique_ptr 的这一能力也不仅仅限于管理内存本身。它同样可以用来管理其他资源。比如,当 unique_ptr 离开作用域时,它可以自动关闭文件、网络套接字,或其他任何资源。

遗憾的是,给 unique_ptr 配置自定义删除器的语法稍显笨拙。你必须把删除器的类型作为模板类型参数显式写出来,而这个类型本身应该是“接收单个指针参数并返回 void 的函数指针类型”。在这个例子中,使用了 decltype(&my_free),它会得到“指向 my_free() 函数的指针类型”。相比之下,shared_ptr 配置自定义删除器要更容易一些。下一节讲 shared_ptr 时会展示如何利用它在离开作用域时自动关闭文件。

有时候,多个对象或多处代码都需要持有同一个指针的副本。unique_ptr 不能复制,因此无法用于这种场景。这时就要用支持共享所有权、且可复制的智能指针 std::shared_ptr。不过,如果多个 shared_ptr 实例都指向同一资源,它们如何知道应该在什么时候真正释放该资源呢? 这个问题要通过引用计数(reference counting) 来解决,稍后的“小节‘为什么需要引用计数’”会解释。不过在此之前,我们先看看如何构造和使用 shared_ptr

shared_ptr 的使用方式和 unique_ptr 很像。创建时应使用 make_shared(),它比分别手工创建 shared_ptr 再构造对象更高效。示例如下:

auto mySimpleSmartPtr { make_shared<Simple>() };

始终使用 make_shared() 来创建 shared_ptr

unique_ptr 一样,类模板实参推导对 shared_ptr 也不起作用,所以你仍然需要显式指定模板类型。

make_shared() 同样执行值初始化,与 make_unique() 类似。如果你并不希望如此,则可以使用 make_shared_for_overwrite() 来执行默认初始化,它与 make_unique_for_overwrite() 对应。

shared_ptr 也可以像 unique_ptr 那样,用来保存动态分配的 C 风格数组,并且同样可以配合 make_shared() 使用。但即便现在技术上做得到,我依然建议你优先使用标准库容器,而不是 C 风格数组。

shared_ptr 也支持 get()reset() 成员函数,和 unique_ptr 一样。唯一的区别在于:调用 reset() 后,底层资源只会在“最后一个 shared_ptr 被销毁或重置”时才真正释放。需要注意的是,shared_ptr 不支持 release()。你可以使用 use_count() 成员函数来获取当前有多少个 shared_ptr 实例在共享同一项资源。

unique_ptr 一样,shared_ptr 默认也使用标准的 newdelete(若保存的是 C 风格数组则是 new[]delete[]) 来分配和释放内存。你同样可以改变这种行为:

// Implementations of my_alloc() and my_free() as before.
shared_ptr<int> myIntSmartPtr { my_alloc(42), my_free };

如你所见,这里不必把自定义删除器的类型写成模板类型参数,因此比给 unique_ptr 配置自定义删除器更方便。

下面这个例子展示了如何使用 shared_ptr 来保存一个文件指针。这样当 shared_ptr 被销毁(在这个例子里,也就是离开作用域)时,文件指针就会自动通过调用 close() 而关闭。注意,C++ 本身有面向对象的文件类可用(见 第 13 章“揭秘 C++ I/O”),这些类本来就会自动关闭文件。这里之所以使用旧式 C 风格的 fopen()fclose(),只是为了演示 shared_ptr 除了管理纯内存外,还能管理什么。例如,当你不得不使用某个没有 C++ 替代品的 C 风格库,而它也有类似“打开资源 / 关闭资源”的函数时,你就可以像这个例子一样,把这些资源封装进 shared_ptr

void close(FILE* filePtr)
{
if (filePtr == nullptr) { return; }
fclose(filePtr);
println("File closed.");
}
int main()
{
FILE* f { fopen("data.txt", "w") };
shared_ptr<FILE> filePtr { f, close };
if (filePtr == nullptr) {
println(cerr, "Error opening file.");
} else {
println("File opened.");
// Use filePtr
}
}

前面已经简单提到过:一个带共享所有权的智能指针(例如 shared_ptr) 在离开作用域或被重置时,只有当它是最后一个仍然指向该资源的智能指针时,才应该释放资源。这是如何做到的呢? shared_ptr 所采用的一种解法就是引用计数(reference counting)。

从一般概念上说,引用计数是一种跟踪“某个类的实例数”或“某个特定对象当前被使用次数”的技术。带引用计数的智能指针,会记录“有多少个智能指针实例被构造出来,共同引用同一个真实指针/同一个对象”。每当这类带引用计数的智能指针被复制一次,就会创建一个新的实例,指向相同资源,同时引用计数加 1。当其中某个智能指针离开作用域或被重置时,引用计数减 1。当引用计数降到 0 时,说明资源已经没有其他拥有者,于是智能指针就释放该资源。

带引用计数的智能指针能解决很多内存管理问题,例如重复删除。举例来说,假设你有下面两个原始指针,它们都指向同一块内存。Simple 类在本章前面已经出现过,它只是简单在对象创建和销毁时打印信息。

Simple* mySimple1 { new Simple{} };
Simple* mySimple2 { mySimple1 }; // Make a copy of the pointer.

如果你对这两个原始指针都执行 delete,就会发生重复删除:

delete mySimple2;
delete mySimple1;

当然,理想情况下你永远不该见到这种代码,但在真实项目里,当调用链层层嵌套、其中某个函数删除了内存,而另一个函数早就已经删过它时,这种情况并非不可能发生。

若改用带引用计数的 shared_ptr,这种重复删除就会被避免:

auto smartPtr1 { make_shared<Simple>() };
auto smartPtr2 { smartPtr1 }; // Make a copy of the pointer.

在这种情况下,只有当这两个智能指针都离开作用域或被重置时,Simple 实例才会被释放,而且恰好只释放一次。

但这一切只有在完全没有原始指针参与时才成立! 例如,假设你先用 new 分配了一块内存,然后再创建两个 shared_ptr,都去指向同一个原始指针:

Simple* mySimple { new Simple{} };
shared_ptr<Simple> smartPtr1 { mySimple };
shared_ptr<Simple> smartPtr2 { mySimple };

这两个智能指针在销毁时都会尝试删除同一个对象。具体结果取决于你的编译器,这段代码很可能直接崩溃! 如果还能输出点东西,可能会像下面这样:

Simple constructor called!
Simple destructor called!
Simple destructor called!

天哪! 一次构造却两次析构? unique_ptr 也会遇到同样的问题。你可能会惊讶,连带引用计数的 shared_ptr 居然也会这样。但这其实是符合其设计的。想要让多个 shared_ptr 安全地指向同一块内存,唯一安全的做法就是直接复制那个已有的 shared_ptr

正如某种类型的原始指针可以转换成另一种类型的指针一样,保存某种类型的 shared_ptr 也可以转换成另一种类型的 shared_ptr。当然,这要受类型规则约束,并不是所有转换都合法。用于转换 shared_ptr 的函数有 const_pointer_cast()dynamic_pointer_cast()static_pointer_cast()reinterpret_pointer_cast()。它们的行为和工作方式与非智能指针版本的 const_cast()dynamic_cast()static_cast()reinterpret_cast() 类似。第 10 章 会结合示例详细讨论这些转换。

注意,这些转换函数只适用于 shared_ptr,不适用于 unique_ptr

shared_ptr 支持别名(aliasing)。这允许某个 shared_ptr 与另一个 shared_ptr 共享“某个拥有所有权的指针”(即 owned pointer) 的所有权,但它自己实际指向的是另一个不同对象(即 stored pointer,已存储指针)。例如,这可以用来让某个 shared_ptr 指向对象中的某个成员,而它真正拥有的仍然是整个对象本身。示例如下:

class Foo
{
public:
Foo(int value) : m_data { value } { }
int m_data;
};
auto foo { make_shared<Foo>(42) };
auto aliasing { shared_ptr<int> { foo, &foo->m_data } };

只有当两个 shared_ptr(fooaliasing) 都销毁后,Foo 对象才会被真正销毁。

owned pointer 用于引用计数,而 stored pointer(已存储指针) 则是在你对该指针解引用或调用 get() 时实际返回的那个指针。

在现代 C++ 代码中,只有在不涉及所有权时,原始指针才是被允许的! 一旦涉及所有权,默认使用 unique_ptr,在确实需要共享所有权时再使用 shared_ptr。另外,应使用 make_unique()make_shared() 来创建这些智能指针。这样一来,你的代码里几乎就不再需要直接调用 new,更永远不应该再需要手工调用 delete

C++ 中还有一种与 shared_ptr 相关的智能指针类,叫做 weak_ptrweak_ptr 可以保存对某项由 shared_ptr 管理的资源的引用。weak_ptr 本身并不拥有该资源,因此它不会阻止 shared_ptr 去释放资源。当 weak_ptr 销毁时(例如离开作用域),它不会销毁所指资源;但它可以用来判断:关联的 shared_ptr 是否已经释放了这项资源。weak_ptr 的构造函数需要一个 shared_ptr 或另一个 weak_ptr 作为参数。若想访问 weak_ptr 中保存的指针,你必须先把它转换成 shared_ptr。有两种方式可以做到:

  • weak_ptr 实例调用 lock() 成员函数,它会返回一个 shared_ptr。如果与该 weak_ptr 关联的 shared_ptr 在此期间已经释放了资源,那么返回的 shared_ptr 就是 nullptr
  • 新建一个 shared_ptr,并把 weak_ptr 作为参数传给 shared_ptr 构造函数。如果对应资源已经被释放,那么这里会抛出 std::bad_weak_ptr 异常。

下面这个例子演示了 weak_ptr 的用法:

void useResource(weak_ptr<Simple>& weakSimple)
{
auto resource { weakSimple.lock() };
if (resource) { println("Resource still alive."); }
else { println("Resource has been freed!"); }
}
int main()
{
auto sharedSimple { make_shared<Simple>() };
weak_ptr<Simple> weakSimple { sharedSimple };
// Try to use the weak_ptr.
useResource(weakSimple);
// Reset the shared_ptr.
// Since there is only 1 shared_ptr to the Simple resource, this will
// free the resource, even though there is still a weak_ptr alive.
sharedSimple.reset();
// Try to use the weak_ptr a second time.
useResource(weakSimple);
}

这段代码的输出如下:

Simple constructor called!
Resource still alive.
Simple destructor called!
Resource has been freed!

shared_ptr 一样,weak_ptr 也支持 C 风格数组。

如果一个函数把指针作为其参数之一,那么只有在涉及所有权转移或所有权共享时,它才应该接收智能指针。若要共享 shared_ptr 的所有权,只需把 shared_ptr 作为按值参数接收即可。同样地,若要转移 unique_ptr 的所有权,也只需把 unique_ptr 作为按值参数接收即可。后者需要借助移动语义,会在 第 9 章 中详细讨论。

如果既不涉及所有权转移,也不涉及所有权共享,那么函数参数就应简单地使用“指向底层资源的非 const 引用或 const 引用”;如果 nullptr 是合法值,则也可以直接使用指向底层资源的原始指针。像 const shared_ptr<T>&const unique_ptr<T>& 这样的参数类型,通常没有太大意义。

标准智能指针 shared_ptrunique_ptrweak_ptr 都可以轻松且高效地按值从函数返回,这得益于 第 1 章 讨论的强制和非强制拷贝省略,以及 第 9 章 将讲解的移动语义。此刻还不需要理解移动语义的细节。你只需要知道:这意味着按值返回智能指针是高效的。例如,你完全可以写出下面这个 create() 函数,并像 main() 中那样使用它:

unique_ptr<Simple> create()
{
auto ptr { make_unique<Simple>() };
// Do something with ptr…
return ptr;
}
int main()
{
unique_ptr<Simple> mySmartPtr1 { create() };
auto mySmartPtr2 { create() };
}

让某个类继承自 std::enable_shared_from_this,就可以使该对象上的成员函数安全地返回一个指向自己的 shared_ptrweak_ptr。若没有这个基类,其中一种返回指向 this 的有效 shared_ptr/weak_ptr 的办法,是在类中增加一个 weak_ptr 成员,然后返回它的副本或由它构造出的 shared_ptrenable_shared_from_this 为其派生类增加了两个成员函数:

  • shared_from_this(): 返回一个 shared_ptr,它与其他 shared_ptr 共享该对象的所有权
  • weak_from_this(): 返回一个 weak_ptr,用于跟踪该对象的所有权

这是个较高级的特性,这里不做深入讲解,不过下面这段代码会简要演示它的用法。shared_from_this()weak_from_this() 都是 public 成员函数。不过,你也许会觉得 public 接口里出现 from_this 这类名字有些别扭。所以仅作为演示,下面这个 Foo 类定义了一个名为 getPointer() 的自有成员函数:

class Foo : public enable_shared_from_this<Foo>
{
public:
shared_ptr<Foo> getPointer() {
return shared_from_this();
}
};
int main()
{
auto ptr1 { make_shared<Foo>() };
auto ptr2 { ptr1->getPointer() };
}

注意,只有当某个对象的指针已经被存进某个 shared_ptr 中时,你才能对该对象使用 shared_from_this();否则就会抛出 bad_weak_ptr 异常。在这个例子中,main() 里用 make_shared() 创建了一个名为 ptr1shared_ptr,其中包含了一个 Foo 实例。在此之后,对该 Foo 实例调用 shared_from_this() 才是合法的。另一方面,weak_from_this() 则始终可以调用,只不过如果调用时该对象的指针尚未被存入任何 shared_ptr,那么它返回的 weak_ptr 可能是空的。

下面这种 getPointer() 的实现则是完全错误的:

class Foo
{
public:
shared_ptr<Foo> getPointer() {
return shared_ptr<Foo>(this);
}
};

如果 main() 依然使用前面展示的那段代码,那么这个 Foo 的实现就会导致重复删除。因为这会产生两个彼此完全独立的 shared_ptr(ptr1ptr2),它们都指向同一个对象,并会在离开作用域时各自尝试删除该对象。

智能指针与 C 风格函数的互操作

Section titled “ 智能指针与 C 风格函数的互操作”

很多 C 风格函数会用返回值来表示“函数是否成功执行,或是否发生错误”。既然返回值已经被拿去报告错误了,那么其他输出数据就只能通过额外的输出参数来返回。例如:

using errorcode = int;
errorcode my_alloc(int value, int** data) { *data = new int { value }; return 0; }
errorcode my_free(int* data) { delete data; return 0; }

在这套 C 风格 API 中,my_alloc() 返回一个 errorcode,而通过名为 data 的输出参数返回实际分配出的数据。在 C++23 之前,你无法直接把像 unique_ptr 这样的智能指针和 my_alloc() 直接对接。取而代之,你只能像下面这样绕一圈:

unique_ptr<int, decltype(&my_free)> myIntSmartPtr(nullptr, my_free);
int* data { nullptr };
my_alloc(42, &data);
myIntSmartPtr.reset(data);

对一件本来相对简单的事情来说,这就显得太绕了。C++23 引入了 std::out_ptr()inout_ptr() 两个函数来帮助解决这个问题,它们都定义在 <memory> 中。借助它们,上面的代码可以更优雅地写成:

unique_ptr<int, decltype(&my_free)> myIntSmartPtr(nullptr, my_free);
my_alloc(42, inout_ptr(myIntSmartPtr));

如果你能确定传给 inout_ptr() 的那个指针当前就是 nullptr,那么也可以直接使用 out_ptr

在 C++11 之前的旧版标准库中,曾经包含过一个非常基础的智能指针实现,叫做 auto_ptr。可惜的是,auto_ptr 存在严重缺陷。其中之一就是:当它被放进 vector 这类标准库容器中使用时,行为并不正确。C++11 已正式弃用 auto_ptr,而从 C++17 开始,它已经从标准库中被彻底移除。它被 unique_ptrshared_ptr 取代。这里之所以提到 auto_ptr,只是为了确保你知道它的存在,并且以后绝对不要使用它。

绝不要使用旧的 auto_ptr 智能指针! 默认应使用 unique_ptr,若确实需要共享所有权时则使用 shared_ptr

本章中,你学习了动态内存的方方面面。除了借助内存检测工具和谨慎编码之外,若想避免动态内存相关问题,有两个最重要的结论。

首先,你必须理解指针在底层是如何工作的。了解了两种不同的指针心理模型之后,你现在应当已经知道编译器是如何分发内存的。

其次,只要涉及所有权,你就应避免原始指针,同时也应避免使用旧式 C 风格构造与函数。相反,请使用安全的 C++ 替代方案,例如能够自动管理内存的对象,如 C++ 的 string 类、vector 容器、智能指针等等。

通过完成下面这些练习,你可以巩固本章讨论的内容。所有练习的参考解答都包含在本书网站 www.wiley.com/go/proc++6e 的代码下载包中。不过,如果你在某道题上卡住了,请先回过头重读本章相关内容,尝试自己找出答案,再去查看网站上的解答。

  1. 练习 7-1: 分析下面这段代码。你能列出其中存在的问题吗? 本练习不要求修复这些问题;修复会留到练习 7-2。

    const size_t numberOfElements { 10 };
    int* values { new int[numberOfElements] };
    // Set values to their index value.
    for (int index { 0 }; index < numberOfElements; ++index) {
    values[index] = index;
    }
    // Set last value to 99.
    values[10] = 99;
    // Print all values.
    for (int index { 0 }; index <= numberOfElements; ++index) {
    print("{} ", values[index]);
    }
  2. 练习 7-2: 把练习 7-1 中那段代码重写为使用现代且安全的 C++ 构造。

  3. 练习 7-3: 编写一个基础类,用来存储一个三维点,其中包含 x、y 和 z 坐标。为它添加一个接收 x、y、z 三个参数的构造函数。再编写一个函数,它接收一个三维点,并使用 std::print() 打印其坐标。在 main() 中,动态分配这个类的一个实例,然后调用你的函数。

  4. 练习 7-4: 本章前面在讨论越界内存访问时展示过下面这个函数。你能否使用安全的现代 C++ 替代方案来重写它? 请在 main() 中测试你的解法。

    void fillWithM(char* text)
    {
    int i { 0 };
    while (text[i] != '\0') {
    text[i] = 'm';
    ++i;
    }
    }
  1. C++ Core Guidelines 中的 ES.20 指南(见附录 B)写道:“Always initialize an object.”

  2. 图 7.12 中的地址仅用于说明。真实系统中的地址强烈依赖于具体硬件和操作系统。